flutter_go_torrent_streamer 0.0.4 copy "flutter_go_torrent_streamer: ^0.0.4" to clipboard
flutter_go_torrent_streamer: ^0.0.4 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 {
      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 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),
                        );
                      },
                    ),
          ),
        ],
      ),
    );
  }
}
1
likes
0
points
386
downloads

Publisher

unverified uploader

Weekly Downloads

A Flutter plugin for magnet link (BitTorrent) streaming on Android, enabling real-time video playback while downloading.

Repository (GitHub)
View/report issues

License

unknown (license)

Dependencies

ffi, flutter, path, path_provider, plugin_platform_interface

More

Packages that depend on flutter_go_torrent_streamer

Packages that implement flutter_go_torrent_streamer