flutter_go_torrent_streamer 0.0.3
flutter_go_torrent_streamer: ^0.0.3 copied to clipboard
A Flutter plugin for magnet link (BitTorrent) streaming on Android, enabling real-time video playback while downloading.
example/lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_go_torrent_streamer/flutter_go_torrent_streamer.dart';
import 'package:video_player/video_player.dart';
import 'dart:io';
import 'dart:async';
import 'package:path_provider/path_provider.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Torrent Streamer',
debugShowCheckedModeBanner: false,
theme: ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.deepPurple,
brightness: Brightness.dark,
),
cardTheme: CardTheme(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
home: const MainLayout(),
);
}
}
class MainLayout extends StatefulWidget {
const MainLayout({super.key});
@override
State<MainLayout> createState() => _MainLayoutState();
}
class _MainLayoutState extends State<MainLayout> {
int _selectedIndex = 0;
final List<Widget> _pages = [
const DownloadManagerPage(),
const SettingsPage(),
];
void _onItemTapped(int index) {
setState(() {
_selectedIndex = index;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Row(
children: [
NavigationRail(
selectedIndex: _selectedIndex,
onDestinationSelected: _onItemTapped,
labelType: NavigationRailLabelType.all,
destinations: const [
NavigationRailDestination(
icon: Icon(Icons.download_rounded),
selectedIcon: Icon(Icons.download),
label: Text('Downloads'),
),
NavigationRailDestination(
icon: Icon(Icons.settings_outlined),
selectedIcon: Icon(Icons.settings),
label: Text('Settings'),
),
],
),
const VerticalDivider(thickness: 1, width: 1),
Expanded(child: _pages[_selectedIndex]),
],
),
);
}
}
class SettingsPage extends StatefulWidget {
const SettingsPage({super.key});
@override
State<SettingsPage> createState() => _SettingsPageState();
}
class _SettingsPageState extends State<SettingsPage> {
final TextEditingController _downloadLimitController = TextEditingController(
text: '0',
);
final TextEditingController _uploadLimitController = TextEditingController(
text: '0',
);
final TextEditingController _connectionsController = TextEditingController(
text: '0',
);
final TextEditingController _portController = TextEditingController(
text: '0',
);
final TextEditingController _userAgentController = TextEditingController(
text: '',
);
@override
void dispose() {
_downloadLimitController.dispose();
_uploadLimitController.dispose();
_connectionsController.dispose();
_portController.dispose();
_userAgentController.dispose();
super.dispose();
}
Future<void> _applyConfig() async {
final config = TorrentStreamerConfig(
downloadSpeedLimit: int.tryParse(_downloadLimitController.text) ?? 0,
uploadSpeedLimit: int.tryParse(_uploadLimitController.text) ?? 0,
connectionsLimit: int.tryParse(_connectionsController.text) ?? 0,
port: int.tryParse(_portController.text) ?? 0,
userAgent: _userAgentController.text,
);
try {
await FlutterTorrentStreamer().configure(config);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("Configuration applied successfully")),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text("Failed to apply configuration: $e"),
backgroundColor: Colors.red,
),
);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text("Settings")),
body: ListView(
padding: const EdgeInsets.all(16.0),
children: [
const Text(
"Global Configuration",
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
const Text(
"These settings apply to all active and future sessions.",
style: TextStyle(color: Colors.grey),
),
const SizedBox(height: 24),
TextField(
controller: _downloadLimitController,
decoration: const InputDecoration(
labelText: "Download Speed Limit (bytes/s)",
helperText: "0 for unlimited",
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
),
const SizedBox(height: 16),
TextField(
controller: _uploadLimitController,
decoration: const InputDecoration(
labelText: "Upload Speed Limit (bytes/s)",
helperText: "0 for unlimited",
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
),
const SizedBox(height: 16),
TextField(
controller: _connectionsController,
decoration: const InputDecoration(
labelText: "Max Connections Per Torrent",
helperText: "0 for default",
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
),
const SizedBox(height: 16),
TextField(
controller: _portController,
decoration: const InputDecoration(
labelText: "Listen Port",
helperText:
"0 for random (requires restart for existing sessions)",
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
),
const SizedBox(height: 16),
TextField(
controller: _userAgentController,
decoration: const InputDecoration(
labelText: "User Agent",
helperText: "Leave empty for default",
border: OutlineInputBorder(),
),
),
const SizedBox(height: 32),
FilledButton.icon(
onPressed: _applyConfig,
icon: const Icon(Icons.save),
label: const Text("Apply Configuration"),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
),
],
),
);
}
}
// -------------------- Download Manager Page --------------------
class DownloadManagerPage extends StatefulWidget {
const DownloadManagerPage({super.key});
@override
State<DownloadManagerPage> createState() => _DownloadManagerPageState();
}
class _DownloadManagerPageState extends State<DownloadManagerPage> {
List<SessionInfo> _sessions = [];
Timer? _refreshTimer;
final TextEditingController _magnetController = TextEditingController(
text:
'magnet:?xt=urn:btih:08ada5a7a6183aae1e09d831df6748d566095a10&dn=Sintel',
);
bool _backgroundModeEnabled = false;
@override
void initState() {
super.initState();
// Engine automatically restores tasks, so we just start refreshing the UI
_startRefreshTimer();
}
@override
void dispose() {
_refreshTimer?.cancel();
_magnetController.dispose();
super.dispose();
}
void _startRefreshTimer() {
_refreshTimer = Timer.periodic(const Duration(seconds: 1), (timer) async {
try {
final sessions = await FlutterTorrentStreamer().getAllSessions();
if (mounted) {
setState(() {
_sessions = sessions;
});
// Auto-manage background mode if enabled
// Ideally, this should be managed by a global service, not UI.
// But for this example, we keep it simple.
}
} catch (e) {
debugPrint("Error fetching sessions (获取会话失败): $e");
}
});
}
void _toggleBackgroundMode(bool value) async {
setState(() {
_backgroundModeEnabled = value;
});
if (value) {
await FlutterTorrentStreamer().enableBackgroundMode();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Background Mode Enabled')),
);
}
} else {
await FlutterTorrentStreamer().disableBackgroundMode();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Background Mode Disabled')),
);
}
}
}
Future<void> _addNewTask() async {
final magnet = _magnetController.text;
if (magnet.isEmpty) return;
String savePath = "";
if (Platform.isAndroid) {
final tempDir =
await getExternalStorageDirectory(); // Use external storage for visibility if needed, or temp
// Fallback to temp if external is null, but usually for downloads we want persistence.
// For this demo, we'll use a specific app folder.
final dir = tempDir ?? await getApplicationDocumentsDirectory();
savePath = '${dir.path}/Download/torrent_streamer';
} else {
final dir = await getApplicationDocumentsDirectory();
savePath = "${dir.path}/Downloads";
}
// Ensure directory exists
await Directory(savePath).create(recursive: true);
try {
final sessionId = await FlutterTorrentStreamer().startStream(
magnet,
savePath,
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Task Added Successfully')),
);
_magnetController.clear();
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error adding task: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
Future<void> _stopTask(String sessionId) async {
await FlutterTorrentStreamer().stopStream(sessionId);
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Task Stopped')));
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("Download Manager"),
elevation: 0,
actions: [
Row(
children: [
const Text("Bg Mode", style: TextStyle(fontSize: 12)),
Switch(
value: _backgroundModeEnabled,
onChanged: _toggleBackgroundMode,
),
const SizedBox(width: 8),
],
),
],
),
body: Column(
children: [
Container(
padding: const EdgeInsets.all(16.0),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: const BorderRadius.vertical(
bottom: Radius.circular(16),
),
),
child: Row(
children: [
Expanded(
child: TextField(
controller: _magnetController,
decoration: InputDecoration(
labelText: "Paste Magnet Link",
hintText: "magnet:?xt=urn:...",
prefixIcon: const Icon(Icons.link),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
filled: true,
),
),
),
const SizedBox(width: 16),
FilledButton.icon(
onPressed: _addNewTask,
icon: const Icon(Icons.add),
label: const Text("Add Task"),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 16,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
],
),
),
Expanded(
child:
_sessions.isEmpty
? const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.download_done_outlined,
size: 64,
color: Colors.grey,
),
SizedBox(height: 16),
Text(
"No active tasks",
style: TextStyle(color: Colors.grey, fontSize: 18),
),
],
),
)
: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: _sessions.length,
itemBuilder: (context, index) {
final session = _sessions[index];
return _buildSessionCard(session);
},
),
),
],
),
);
}
String _formatSpeed(int bytesPerSec) {
if (bytesPerSec < 1024) return '$bytesPerSec B/s';
if (bytesPerSec < 1024 * 1024)
return '${(bytesPerSec / 1024).toStringAsFixed(1)} KB/s';
if (bytesPerSec < 1024 * 1024 * 1024)
return '${(bytesPerSec / (1024 * 1024)).toStringAsFixed(1)} MB/s';
return '${(bytesPerSec / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB/s';
}
String _formatETA(int seconds) {
if (seconds < 0) return '∞';
if (seconds < 60) return '${seconds}s';
if (seconds < 3600) return '${seconds ~/ 60}m ${seconds % 60}s';
final hours = seconds ~/ 3600;
final mins = (seconds % 3600) ~/ 60;
return '${hours}h ${mins}m';
}
Widget _buildSessionCard(SessionInfo session) {
final isDownloading = session.state.toLowerCase().contains("download");
final isStreaming = session.mode == "stream";
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => TorrentPlayerPage(sessionInfo: session),
),
);
},
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
isStreaming ? Icons.play_circle_outline : Icons.downloading,
color: isStreaming ? Colors.orange : Colors.blue,
),
const SizedBox(width: 12),
Expanded(
child: Text(
session.name.isEmpty
? "Resolving Metadata..."
: session.name,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
IconButton(
icon: const Icon(Icons.delete_outline, color: Colors.red),
onPressed: () => _stopTask(session.id),
tooltip: "Stop Task",
),
],
),
const SizedBox(height: 8),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: session.progress / 100,
backgroundColor: Colors.grey[800],
valueColor: AlwaysStoppedAnimation<Color>(
session.progress >= 100
? Colors.green
: Theme.of(context).colorScheme.primary,
),
minHeight: 6,
),
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"${session.progress.toStringAsFixed(1)}%",
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
Row(
children: [
Icon(Icons.speed, size: 14, color: Colors.grey[400]),
const SizedBox(width: 4),
Text(
_formatSpeed(session.downloadSpeed),
style: TextStyle(fontSize: 12, color: Colors.grey[400]),
),
const SizedBox(width: 12),
Icon(Icons.people, size: 14, color: Colors.grey[400]),
const SizedBox(width: 4),
Text(
"${session.peers}/${session.seeds}",
style: TextStyle(fontSize: 12, color: Colors.grey[400]),
),
const SizedBox(width: 12),
Icon(Icons.timer, size: 14, color: Colors.grey[400]),
const SizedBox(width: 4),
Text(
_formatETA(session.eta),
style: TextStyle(fontSize: 12, color: Colors.grey[400]),
),
],
),
],
),
const SizedBox(height: 4),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color:
Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(4),
),
child: Text(
session.state,
style: const TextStyle(fontSize: 12),
),
),
Text(
session.mode.toUpperCase(),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Colors.grey[400],
),
),
],
),
],
),
),
),
);
}
}
// -------------------- Player Page --------------------
class TorrentPlayerPage extends StatefulWidget {
final SessionInfo sessionInfo;
const TorrentPlayerPage({super.key, required this.sessionInfo});
@override
State<TorrentPlayerPage> createState() => _TorrentPlayerPageState();
}
class _TorrentPlayerPageState extends State<TorrentPlayerPage> {
late TorrentStreamSession _session;
VideoPlayerController? _controller;
List<TorrentFile> _files = [];
bool _isLoading = true;
String _status = '';
int _playingIndex = -1;
@override
void initState() {
super.initState();
_session = TorrentStreamSession(
widget.sessionInfo.id,
widget.sessionInfo.url,
FlutterTorrentStreamer(),
);
_loadFiles();
_startPolling();
}
@override
void dispose() {
_pollTimer?.cancel();
_controller?.dispose();
super.dispose();
}
Timer? _pollTimer;
void _startPolling() {
_pollTimer = Timer.periodic(const Duration(seconds: 2), (timer) {
if (_files.isEmpty) {
_loadFiles();
}
});
}
Future<void> _loadFiles() async {
try {
final files = await _session.getFiles();
if (mounted) {
setState(() {
_files = files;
_isLoading = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_status = 'Error loading files: $e';
_isLoading = false;
});
}
}
}
Future<void> _downloadFile(int index) async {
await _session.downloadFile(index);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Switched to Background Download'),
behavior: SnackBarBehavior.floating,
),
);
}
}
Future<void> _playFile(int index) async {
try {
setState(() {
_playingIndex = index;
_status = 'Preparing playback...';
});
await _session.selectFile(index);
if (_controller != null) {
await _controller!.dispose();
}
// Important: Use the session URL for streaming
_controller = VideoPlayerController.networkUrl(
Uri.parse(_session.streamUrl),
);
_controller!.addListener(() {
if (_controller!.value.hasError && mounted) {
setState(() {
_status = 'Playback Error: ${_controller!.value.errorDescription}';
});
}
});
await _controller!.initialize();
await _controller!.play();
if (mounted) {
setState(() {
_status = 'Playing: ${_files[index].name}';
});
}
} catch (e) {
if (mounted) {
setState(() {
_status = 'Error playing file: $e';
_playingIndex = -1;
});
}
}
}
String _formatSize(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
if (bytes < 1024 * 1024 * 1024)
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(
widget.sessionInfo.name.isEmpty
? 'Task Details'
: widget.sessionInfo.name,
),
),
body: Column(
children: [
// Video Player Area
AspectRatio(
aspectRatio: _controller?.value.aspectRatio ?? 16 / 9,
child: Container(
color: Colors.black,
child:
_controller != null && _controller!.value.isInitialized
? Stack(
alignment: Alignment.bottomCenter,
children: [
VideoPlayer(_controller!),
VideoProgressIndicator(
_controller!,
allowScrubbing: true,
colors: const VideoProgressColors(
playedColor: Colors.red,
),
),
],
)
: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.play_circle_outline,
size: 48,
color: Colors.grey[700],
),
const SizedBox(height: 8),
Text(
_status.isEmpty
? "Select a video file to play"
: _status,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white70),
),
if (_status.startsWith("Preparing"))
const Padding(
padding: EdgeInsets.only(top: 16.0),
child: CircularProgressIndicator(),
),
],
),
),
),
),
const Divider(height: 1),
// File List Header
Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
const Icon(Icons.folder_open),
const SizedBox(width: 8),
const Text(
"Files",
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const Spacer(),
if (_isLoading)
const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
],
),
),
// File List
Expanded(
child:
_files.isEmpty && !_isLoading
? const Center(
child: Text("No files found or metadata resolving..."),
)
: ListView.separated(
itemCount: _files.length,
separatorBuilder: (c, i) => const Divider(height: 1),
itemBuilder: (context, index) {
final file = _files[index];
final isPlaying = index == _playingIndex;
return ListTile(
leading: Icon(
Icons.insert_drive_file,
color: isPlaying ? Colors.orange : Colors.grey,
),
title: Text(
file.name,
style: TextStyle(
color: isPlaying ? Colors.orange : null,
fontWeight:
isPlaying
? FontWeight.bold
: FontWeight.normal,
),
),
subtitle: Text(_formatSize(file.size)),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.download),
onPressed: () => _downloadFile(index),
tooltip: 'Download Background',
),
IconButton(
icon: Icon(
isPlaying
? Icons.pause_circle
: Icons.play_circle,
),
color: Colors.orange,
onPressed: () => _playFile(index),
tooltip: 'Stream & Play',
),
],
),
onTap: () => _playFile(index),
);
},
),
),
],
),
);
}
}