|
@@ -1,6 +1,11 @@
|
|
|
+import 'dart:io';
|
|
|
+import 'dart:async';
|
|
|
+import 'package:flutter/material.dart';
|
|
|
import 'package:media_kit/media_kit.dart';
|
|
|
import 'package:media_kit_video/media_kit_video.dart';
|
|
|
-import '../models/post.dart';
|
|
|
+import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
|
|
+import 'package:path/path.dart' as path;
|
|
|
+import '../../main.dart';
|
|
|
|
|
|
class VideoPlayerWidget extends StatefulWidget {
|
|
|
final String videoUrl;
|
|
@@ -12,41 +17,238 @@ class VideoPlayerWidget extends StatefulWidget {
|
|
|
}
|
|
|
|
|
|
class _VideoPlayerWidgetState extends State<VideoPlayerWidget> {
|
|
|
- late final Player player;
|
|
|
- late final VideoController controller;
|
|
|
+ late final Player _player;
|
|
|
+ late final VideoController _controller;
|
|
|
bool _isInitialized = false;
|
|
|
+ bool _hasError = false;
|
|
|
+ bool _usingCache = false;
|
|
|
+ double _downloadProgress = 0;
|
|
|
+ double _bufferingProgress = 0;
|
|
|
+ bool _isBuffering = false;
|
|
|
+ File? _cachedFile;
|
|
|
+ StreamSubscription? _cacheSubscription;
|
|
|
|
|
|
@override
|
|
|
void initState() {
|
|
|
super.initState();
|
|
|
- player = Player();
|
|
|
- controller = VideoController(player);
|
|
|
-
|
|
|
- player.open(Media(widget.videoUrl));
|
|
|
- player.stream.error.listen((error) => print("Player error: $error"));
|
|
|
-
|
|
|
- player.stream.buffering.listen((buffering) {
|
|
|
- if (!buffering && !_isInitialized) {
|
|
|
- setState(() => _isInitialized = true);
|
|
|
+ _initializePlayer();
|
|
|
+ }
|
|
|
+
|
|
|
+ Future<void> _initializePlayer() async {
|
|
|
+ try {
|
|
|
+ _cachedFile = await videoCacheManager.getSingleFile(widget.videoUrl);
|
|
|
+ _usingCache = _cachedFile != null;
|
|
|
+
|
|
|
+ _player = Player();
|
|
|
+ _controller = VideoController(_player);
|
|
|
+
|
|
|
+ final source = _cachedFile != null
|
|
|
+ ? Media(_cachedFile!.path, httpHeaders: {})
|
|
|
+ : Media(widget.videoUrl);
|
|
|
+
|
|
|
+ await _player.open(source);
|
|
|
+ await _player.setVolume(0);
|
|
|
+
|
|
|
+ if (_cachedFile == null) {
|
|
|
+ _cacheVideo();
|
|
|
+ }
|
|
|
+
|
|
|
+ _player.stream.error.listen(_handlePlayerError);
|
|
|
+ _player.stream.buffering.listen(_handleBufferingUpdate);
|
|
|
+
|
|
|
+ } catch (e) {
|
|
|
+ _handleError("Player init error: $e");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ void _handleBufferingUpdate(bool isBuffering) {
|
|
|
+ setState(() {
|
|
|
+ _isBuffering = isBuffering;
|
|
|
+ _bufferingProgress = 0;
|
|
|
+
|
|
|
+ if (!isBuffering && !_isInitialized && !_hasError) {
|
|
|
+ _isInitialized = true;
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ void _handlePlayerError(String error) {
|
|
|
+ _handleError("Player error: ${error})");
|
|
|
+ }
|
|
|
+
|
|
|
+ void _handleError(String message) {
|
|
|
+ print(message);
|
|
|
+ if (!_hasError) setState(() => _hasError = true);
|
|
|
+ }
|
|
|
+
|
|
|
+ Future<void> _cacheVideo() async {
|
|
|
+ final fileStream = videoCacheManager.getFileStream(
|
|
|
+ widget.videoUrl,
|
|
|
+ withProgress: true,
|
|
|
+ );
|
|
|
+
|
|
|
+ _cacheSubscription = fileStream.listen((fileResponse) {
|
|
|
+ if (fileResponse is DownloadProgress) {
|
|
|
+ setState(() => _downloadProgress = fileResponse.progress ?? 0);
|
|
|
+ } else if (fileResponse is FileInfo) {
|
|
|
+ setState(() {
|
|
|
+ _cachedFile = fileResponse.file;
|
|
|
+ _usingCache = true;
|
|
|
+ _downloadProgress = 0;
|
|
|
+ });
|
|
|
}
|
|
|
+ }, onError: (e) => print("Cache error: $e"));
|
|
|
+ }
|
|
|
+
|
|
|
+ void _retryPlayback() {
|
|
|
+ setState(() {
|
|
|
+ _hasError = false;
|
|
|
+ _isInitialized = false;
|
|
|
+ _downloadProgress = 0;
|
|
|
+ _bufferingProgress = 0;
|
|
|
+ _cacheSubscription?.cancel();
|
|
|
});
|
|
|
+ _initializePlayer();
|
|
|
}
|
|
|
|
|
|
@override
|
|
|
Widget build(BuildContext context) {
|
|
|
- return Stack(
|
|
|
- children: [
|
|
|
- Video(controller: controller),
|
|
|
-
|
|
|
- if (!_isInitialized)
|
|
|
- const Center(child: CircularProgressIndicator()),
|
|
|
- ],
|
|
|
+ return GestureDetector(
|
|
|
+ child: Stack(
|
|
|
+ children: [
|
|
|
+ // Video display
|
|
|
+ if (_isInitialized)
|
|
|
+ Video(controller: _controller),
|
|
|
+
|
|
|
+ // Loading/buffering states
|
|
|
+ if (!_isInitialized || _isBuffering)
|
|
|
+ _buildLoadingState(),
|
|
|
+
|
|
|
+ // Error state
|
|
|
+ if (_hasError)
|
|
|
+ _buildErrorState(),
|
|
|
+
|
|
|
+ // Cache progress indicator
|
|
|
+ if (_downloadProgress > 0 && _downloadProgress < 1)
|
|
|
+ _buildCacheProgress(),
|
|
|
+
|
|
|
+ // Buffering indicator
|
|
|
+ if (_isBuffering && _isInitialized)
|
|
|
+ _buildBufferingOverlay(),
|
|
|
+ ],
|
|
|
+ ),
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ Widget _buildLoadingState() {
|
|
|
+ return Container(
|
|
|
+ color: Colors.black,
|
|
|
+ child: Center(
|
|
|
+ child: Column(
|
|
|
+ mainAxisSize: MainAxisSize.min,
|
|
|
+ children: [
|
|
|
+ CircularProgressIndicator(
|
|
|
+ value: _isBuffering ? _bufferingProgress : null,
|
|
|
+ valueColor: const AlwaysStoppedAnimation(Colors.white),
|
|
|
+ ),
|
|
|
+ const SizedBox(height: 16),
|
|
|
+ Text(
|
|
|
+ _usingCache ? 'Loading cached video' : 'Streaming video',
|
|
|
+ style: const TextStyle(color: Colors.white),
|
|
|
+ ),
|
|
|
+ ],
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ Widget _buildBufferingOverlay() {
|
|
|
+ return Container(
|
|
|
+ color: Colors.black54,
|
|
|
+ child: Center(
|
|
|
+ child: Column(
|
|
|
+ mainAxisSize: MainAxisSize.min,
|
|
|
+ children: [
|
|
|
+ CircularProgressIndicator(
|
|
|
+ value: _bufferingProgress,
|
|
|
+ valueColor: const AlwaysStoppedAnimation(Colors.white),
|
|
|
+ ),
|
|
|
+ const SizedBox(height: 8),
|
|
|
+ const Text('Buffering...', style: TextStyle(color: Colors.white)),
|
|
|
+ ],
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ Widget _buildCacheProgress() {
|
|
|
+ return Positioned(
|
|
|
+ bottom: 16,
|
|
|
+ right: 16,
|
|
|
+ child: Container(
|
|
|
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
|
+ decoration: BoxDecoration(
|
|
|
+ color: Colors.black.withOpacity(0.7),
|
|
|
+ borderRadius: BorderRadius.circular(20),
|
|
|
+ ),
|
|
|
+ child: Row(
|
|
|
+ children: [
|
|
|
+ const Icon(Icons.download, color: Colors.white, size: 20),
|
|
|
+ const SizedBox(width: 8),
|
|
|
+ Text(
|
|
|
+ '${(_downloadProgress * 100).toStringAsFixed(0)}%',
|
|
|
+ style: const TextStyle(color: Colors.white),
|
|
|
+ ),
|
|
|
+ ],
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ Widget _buildErrorState() {
|
|
|
+ return Container(
|
|
|
+ color: Colors.black,
|
|
|
+ child: Center(
|
|
|
+ child: Column(
|
|
|
+ mainAxisSize: MainAxisSize.min,
|
|
|
+ children: [
|
|
|
+ const Icon(Icons.videocam_off, size: 64, color: Colors.white54),
|
|
|
+ const SizedBox(height: 16),
|
|
|
+ const Text('Playback Failed',
|
|
|
+ style: TextStyle(color: Colors.white, fontSize: 18)),
|
|
|
+ const SizedBox(height: 8),
|
|
|
+ Text(
|
|
|
+ _usingCache ? 'Cached file may be corrupted' : 'Network issue',
|
|
|
+ style: const TextStyle(color: Colors.white70),
|
|
|
+ ),
|
|
|
+ const SizedBox(height: 16),
|
|
|
+ Row(
|
|
|
+ mainAxisAlignment: MainAxisAlignment.center,
|
|
|
+ children: [
|
|
|
+ ElevatedButton.icon(
|
|
|
+ icon: const Icon(Icons.refresh),
|
|
|
+ label: const Text('Retry'),
|
|
|
+ onPressed: _retryPlayback,
|
|
|
+ ),
|
|
|
+ const SizedBox(width: 16),
|
|
|
+ if (!_usingCache)
|
|
|
+ ElevatedButton.icon(
|
|
|
+ icon: const Icon(Icons.download),
|
|
|
+ label: const Text('Download'),
|
|
|
+ onPressed: _cacheVideo,
|
|
|
+ ),
|
|
|
+ ],
|
|
|
+ )
|
|
|
+ ],
|
|
|
+ ),
|
|
|
+ ),
|
|
|
);
|
|
|
}
|
|
|
|
|
|
@override
|
|
|
void dispose() {
|
|
|
- player.dispose();
|
|
|
+ _player.dispose();
|
|
|
+ _cacheSubscription?.cancel();
|
|
|
super.dispose();
|
|
|
}
|
|
|
}
|