mirror of
https://gitlab.com/foxixus/neomovies_mobile.git
synced 2025-10-28 01:18:50 +05:00
feat: Implement comprehensive torrent downloads management system
- Fix torrent platform service integration with Android engine - Add downloads page with torrent list and progress tracking - Implement torrent detail screen with file selection and priorities - Create native video player with fullscreen controls - Add WebView players for Vibix and Alloha - Integrate corrected torrent engine with file selector - Update dependencies for auto_route and video players Features: ✅ Downloads screen with real-time torrent status ✅ File-level priority management and selection ✅ Three player options: native, Vibix WebView, Alloha WebView ✅ Torrent pause/resume/remove functionality ✅ Progress tracking and seeder/peer counts ✅ Video file detection and playback integration ✅ Fixed Android torrent engine method calls This resolves torrent integration issues and provides complete downloads management UI with video playback capabilities.
This commit is contained in:
@@ -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();
|
||||
|
||||
// Restore orientation: phones back to portrait, tablets/TV keep free rotation
|
||||
if (DeviceUtils.isLargeScreen(context)) {
|
||||
Future<void> _initializePlayer() async {
|
||||
try {
|
||||
final file = File(widget.filePath);
|
||||
if (!await file.exists()) {
|
||||
setState(() {
|
||||
_error = 'Файл не найден: ${widget.filePath}';
|
||||
_isLoading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
_controller = VideoPlayerController.file(file);
|
||||
|
||||
await _controller!.initialize();
|
||||
|
||||
_controller!.addListener(() {
|
||||
setState(() {});
|
||||
});
|
||||
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
|
||||
// Auto play
|
||||
_controller!.play();
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_error = 'Ошибка инициализации плеера: $e';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _togglePlayPause() {
|
||||
if (_controller!.value.isPlaying) {
|
||||
_controller!.pause();
|
||||
} else {
|
||||
_controller!.play();
|
||||
}
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void _toggleFullscreen() {
|
||||
setState(() {
|
||||
_isFullscreen = !_isFullscreen;
|
||||
});
|
||||
_setOrientation(_isFullscreen);
|
||||
}
|
||||
|
||||
void _setOrientation(bool isFullscreen) {
|
||||
if (isFullscreen) {
|
||||
SystemChrome.setPreferredOrientations([
|
||||
DeviceOrientation.portraitUp,
|
||||
DeviceOrientation.portraitDown,
|
||||
DeviceOrientation.landscapeLeft,
|
||||
DeviceOrientation.landscapeRight,
|
||||
]);
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
|
||||
} else {
|
||||
SystemChrome.setPreferredOrientations([
|
||||
DeviceOrientation.portraitUp,
|
||||
]);
|
||||
SystemChrome.setEnabledSystemUIMode(
|
||||
SystemUiMode.manual,
|
||||
overlays: SystemUiOverlay.values,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _toggleControls() {
|
||||
setState(() {
|
||||
_isControlsVisible = !_isControlsVisible;
|
||||
});
|
||||
|
||||
if (_isControlsVisible) {
|
||||
// Hide controls after 3 seconds
|
||||
Future.delayed(const Duration(seconds: 3), () {
|
||||
if (mounted && _controller!.value.isPlaying) {
|
||||
setState(() {
|
||||
_isControlsVisible = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
String _formatDuration(Duration duration) {
|
||||
String twoDigits(int n) => n.toString().padLeft(2, '0');
|
||||
final minutes = twoDigits(duration.inMinutes.remainder(60));
|
||||
final seconds = twoDigits(duration.inSeconds.remainder(60));
|
||||
final hours = duration.inHours;
|
||||
|
||||
// Restore system UI
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||
if (hours > 0) {
|
||||
return '$hours:$minutes:$seconds';
|
||||
} else {
|
||||
return '$minutes:$seconds';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return WillPopScope(
|
||||
onWillPop: () async {
|
||||
_restoreSystemSettings();
|
||||
return true;
|
||||
},
|
||||
child: _VideoPlayerScreenContent(
|
||||
title: widget.title,
|
||||
mediaId: widget.mediaId,
|
||||
selectedSource: _selectedSource,
|
||||
onSourceChanged: (source) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_selectedSource = source;
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _VideoPlayerScreenContent extends StatelessWidget {
|
||||
final String mediaId; // IMDB ID
|
||||
final String? title;
|
||||
final VideoSource selectedSource;
|
||||
final ValueChanged<VideoSource> onSourceChanged;
|
||||
|
||||
const _VideoPlayerScreenContent({
|
||||
Key? key,
|
||||
required this.mediaId,
|
||||
this.title,
|
||||
required this.selectedSource,
|
||||
required this.onSourceChanged,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
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: [
|
||||
// Source selector header
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
color: Colors.black87,
|
||||
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),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.end,
|
||||
),
|
||||
),
|
||||
],
|
||||
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,
|
||||
),
|
||||
),
|
||||
|
||||
// Video player
|
||||
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: [
|
||||
// Top bar
|
||||
if (_isFullscreen) _buildTopBar(),
|
||||
|
||||
// Center play/pause
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: _buildCenterControls(),
|
||||
),
|
||||
),
|
||||
|
||||
// Bottom controls
|
||||
_buildBottomControls(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTopBar() {
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
Expanded(
|
||||
child: WebPlayerWidget(
|
||||
key: ValueKey(selectedSource.id),
|
||||
mediaId: mediaId,
|
||||
source: selectedSource,
|
||||
child: Text(
|
||||
widget.title,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -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')),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user