From d79d130126a42c55e1d113fe8859eefee99e7e01 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Sun, 21 Dec 2025 18:05:29 +0100 Subject: [PATCH] feat: Add native video player screen using media_kit for IPTV playback with extensive performance and audio optimizations. --- .agent/rules/general.md | 2 + .../iptv/screens/lite_player_screen.dart | 887 +++++++----------- .../iptv/screens/native_player_screen.dart | 17 +- pubspec.yaml | 2 +- 4 files changed, 341 insertions(+), 567 deletions(-) diff --git a/.agent/rules/general.md b/.agent/rules/general.md index e146353..c00eab1 100644 --- a/.agent/rules/general.md +++ b/.agent/rules/general.md @@ -51,6 +51,8 @@ Structure toujours ta réponse ainsi : - Commentaires explicatifs *dans* le code pour les parties complexes. 4. **Sécurité & Limitations :** Une note sur ce qui doit être configuré pour sécuriser ce code (ex: CORS, Rate Limiting). + + --- **EXEMPLE D'INTERACTION :** diff --git a/lib/mobile/features/iptv/screens/lite_player_screen.dart b/lib/mobile/features/iptv/screens/lite_player_screen.dart index 2f712b9..2fd5615 100644 --- a/lib/mobile/features/iptv/screens/lite_player_screen.dart +++ b/lib/mobile/features/iptv/screens/lite_player_screen.dart @@ -5,7 +5,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:video_player/video_player.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; -import 'package:xtremflow/mobile/widgets/tv_focusable.dart'; import 'package:path_provider/path_provider.dart'; import 'package:xtremflow/core/models/iptv_models.dart'; import 'package:xtremflow/core/models/playlist_config.dart'; @@ -15,10 +14,8 @@ import 'package:xtremflow/core/theme/app_colors.dart'; import 'package:xtremflow/mobile/features/iptv/screens/native_player_screen.dart' show StreamType; import 'package:xtremflow/features/iptv/models/xtream_models.dart' as xm; +import 'package:xtremflow/mobile/widgets/tv_focusable.dart'; -/// Lite version of the player using standard video_player (ExoPlayer on Android) -/// Targeted for 1GB RAM devices with Light engine. -/// Standard AspectRatio implementation (No manual toggle). class LitePlayerScreen extends ConsumerStatefulWidget { final String streamId; final String title; @@ -62,81 +59,101 @@ class _LitePlayerScreenState extends ConsumerState bool _showControls = true; bool _isPlaying = false; - // EPG + // EPG & Clock xm.ShortEPG? _epg; Timer? _epgTimer; + String _currentTime = ""; + Timer? _clockTimer; - // Progress & Seeking + // Progress & Buffering Duration _position = Duration.zero; Duration _duration = Duration.zero; bool _isSeeking = false; - bool _isStabilizing = false; // Flag to mask initial glitches + bool _isStabilizing = false; Timer? _stabilizationTimer; - - // Focus Nodes - final FocusNode _playPauseFocusNode = FocusNode(); - final FocusNode _backFocusNode = FocusNode(); - final FocusNode _deinterlaceFocusNode = FocusNode(); - Timer? _controlsTimer; + // Focus + final FocusNode _playPauseFocusNode = FocusNode(); + final FocusNode _prevFocusNode = FocusNode(); + final FocusNode _nextFocusNode = FocusNode(); + final FocusNode _aspectFocusNode = FocusNode(); + @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); HardwareKeyboard.instance.addHandler(_handleKeyEvent); - - // Enable wakelock WakelockPlus.enable(); _currentIndex = widget.initialIndex; + _startClock(); - // Lock orientation SystemChrome.setPreferredOrientations([ DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight, ]); SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); - _initializeService(); + _initializePlayback(); } - Future _initializeService() async { + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (_controller == null) return; + if (state == AppLifecycleState.paused || + state == AppLifecycleState.inactive || + state == AppLifecycleState.detached) { + _controller?.pause(); + } + } + + void _startClock() { + _clockTimer?.cancel(); + _updateTime(); + _clockTimer = + Timer.periodic(const Duration(seconds: 1), (_) => _updateTime()); + } + + void _updateTime() { + final now = DateTime.now(); + final timeStr = + "${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}"; + if (mounted && _currentTime != timeStr) { + setState(() => _currentTime = timeStr); + } + } + + Future _initializePlayback() async { try { final dir = await getApplicationDocumentsDirectory(); _xtreamService = XtreamServiceMobile(dir.path); await _xtreamService!.setPlaylistAsync(widget.playlist); _loadStream(); - _updateEPG(); // Initial Load - // Update EPG periodically for Live TV if (widget.streamType == StreamType.live) { + _updateEPG(); _epgTimer = Timer.periodic(const Duration(minutes: 5), (_) => _updateEPG()); } } catch (e) { - if (mounted) setState(() => _errorMessage = "Service Error: $e"); + if (mounted) setState(() => _errorMessage = "Initialization Error: $e"); } } Future _updateEPG() async { if (widget.streamType != StreamType.live || _xtreamService == null) return; - try { final currentChannelId = widget.channels?[_currentIndex].streamId ?? widget.streamId; - // Get EPG for current channel final epgData = await _xtreamService!.getShortEPG(currentChannelId); - if (mounted) { - setState(() => _epg = epgData); - } + if (mounted) setState(() => _epg = epgData); } catch (_) {} } Future _loadStream() async { if (_xtreamService == null) return; - // cleanup previous await _controller?.dispose(); _controller = null; @@ -144,6 +161,7 @@ class _LitePlayerScreenState extends ConsumerState setState(() { _isLoading = true; _errorMessage = null; + _isStabilizing = true; }); try { @@ -152,25 +170,15 @@ class _LitePlayerScreenState extends ConsumerState ? widget.channels![_currentIndex].streamId : widget.streamId; - // Update EPG when changing channels - if (widget.streamType == StreamType.live) { - _updateEPG(); - } + if (widget.streamType == StreamType.live) _updateEPG(); - String streamUrl; - if (widget.streamType == StreamType.live) { - streamUrl = _xtreamService!.getLiveStreamUrl(currentStreamId); - } else if (widget.streamType == StreamType.vod) { - streamUrl = _xtreamService!.getVodStreamUrl( - currentStreamId, - widget.containerExtension ?? 'mp4', - ); - } else { - streamUrl = _xtreamService!.getSeriesStreamUrl( - currentStreamId, - widget.containerExtension ?? 'mp4', - ); - } + final streamUrl = widget.streamType == StreamType.live + ? _xtreamService!.getLiveStreamUrl(currentStreamId) + : (widget.streamType == StreamType.vod + ? _xtreamService!.getVodStreamUrl( + currentStreamId, widget.containerExtension ?? 'mp4') + : _xtreamService!.getSeriesStreamUrl( + currentStreamId, widget.containerExtension ?? 'mp4')); final controller = VideoPlayerController.networkUrl( Uri.parse(streamUrl), @@ -185,86 +193,62 @@ class _LitePlayerScreenState extends ConsumerState return; } - // Buffer Optimization: Wait for a pre-roll delay for Live TV - // to let the internal buffer fill before playback starts. + // Buffer Optimization: Pre-roll delay for TS streams if (widget.streamType == StreamType.live) { - debugPrint('[LitePlayer] Pre-roll buffer delay (3.5s)...'); - await Future.delayed(const Duration(milliseconds: 3500)); + await Future.delayed(const Duration(milliseconds: 3000)); } await controller.play(); setState(() { - _isStabilizing = true; _isPlaying = true; _duration = controller.value.duration; }); _resetControlsTimer(); - - // Listen for errors and completion controller.addListener(_videoListener); } catch (e) { - if (mounted) { + if (mounted) setState(() { _isLoading = false; _errorMessage = "Playback Error: $e"; }); - } } } void _videoListener() { if (_controller == null || !mounted) return; - final value = _controller!.value; - final isPlaying = value.isPlaying; - if (isPlaying != _isPlaying) { - setState(() => _isPlaying = isPlaying); - } + if (value.isPlaying != _isPlaying) + setState(() => _isPlaying = value.isPlaying); - // --- STABILIZATION MASKING LOGIC --- - // Show spinner if buffering OR if we are in the initial stabilization phase - final isBuffering = value.isBuffering; - final shouldShowLoading = isBuffering || _isStabilizing; - - if (shouldShowLoading != _isLoading) { + // Stabilization Masking: spinner stays until 1.5s of smooth play + final shouldShowLoading = value.isBuffering || _isStabilizing; + if (shouldShowLoading != _isLoading) setState(() => _isLoading = shouldShowLoading); - } - // If we are playing smoothly and stabilizing, start/restart the timer - if (isPlaying && !isBuffering && _isStabilizing) { + if (value.isPlaying && !value.isBuffering && _isStabilizing) { _stabilizationTimer?.cancel(); _stabilizationTimer = Timer(const Duration(milliseconds: 1500), () { - if (mounted) { + if (mounted) setState(() { _isStabilizing = false; _isLoading = false; }); - } }); - } else if (isBuffering && _isStabilizing) { - // If we hit a buffer during stabilization, reset the timer - _stabilizationTimer?.cancel(); } - // ------------------------------------ if (!_isSeeking) { - final pos = value.position; - final dur = value.duration; - if (pos != _position || dur != _duration) { - setState(() { - _position = pos; - _duration = dur; - }); - } + setState(() { + _position = value.position; + _duration = value.duration; + }); } - if (value.hasError) { - setState(() => _errorMessage = value.errorDescription); - } + if (value.hasError) setState(() => _errorMessage = value.errorDescription); } void _onUserInteraction() { + if (!mounted) return; setState(() => _showControls = true); _resetControlsTimer(); } @@ -272,7 +256,7 @@ class _LitePlayerScreenState extends ConsumerState void _resetControlsTimer() { _controlsTimer?.cancel(); if (_showControls && _isPlaying) { - _controlsTimer = Timer(const Duration(seconds: 5), () { + _controlsTimer = Timer(const Duration(seconds: 7), () { if (mounted) setState(() => _showControls = false); }); } @@ -280,124 +264,50 @@ class _LitePlayerScreenState extends ConsumerState void _togglePlayPause() { if (_controller == null) return; - if (_controller!.value.isPlaying) { - _controller!.pause(); - } else { - _controller!.play(); - } + _controller!.value.isPlaying ? _controller!.pause() : _controller!.play(); _onUserInteraction(); } - void _playNext() { - if (widget.channels == null || widget.channels!.isEmpty) return; - final nextIndex = (_currentIndex + 1) % widget.channels!.length; - _switchChannel(nextIndex); - } - - void _playPrevious() { - if (widget.channels == null || widget.channels!.isEmpty) return; - final prevIndex = - (_currentIndex - 1 + widget.channels!.length) % widget.channels!.length; - _switchChannel(prevIndex); - } - void _switchChannel(int index) { + if (widget.channels == null) return; setState(() => _currentIndex = index); _loadStream(); } - Future _seek(Duration offset) async { - if (_controller == null || !_controller!.value.isInitialized) return; - final newPos = _controller!.value.position + offset; - final clamped = Duration( - milliseconds: newPos.inMilliseconds - .clamp(0, _controller!.value.duration.inMilliseconds), - ); - await _controller!.seekTo(clamped); - _onUserInteraction(); - } - - // Simplified Key Handler bool _handleKeyEvent(KeyEvent event) { if (event is! KeyDownEvent) return false; final key = event.logicalKey; - if (key == LogicalKeyboardKey.mediaPlay || - key == LogicalKeyboardKey.mediaPause || - key == LogicalKeyboardKey.mediaPlayPause) { - _togglePlayPause(); - return true; - } - - if (key == LogicalKeyboardKey.select || - key == LogicalKeyboardKey.enter || - key == LogicalKeyboardKey.space) { - if (!_showControls) { - _onUserInteraction(); - return true; - } - return false; - } - - // Arrow keys - Show controls or let focus navigate, NO zapping - if (key == LogicalKeyboardKey.arrowDown || - key == LogicalKeyboardKey.arrowUp || - key == LogicalKeyboardKey.arrowLeft || - key == LogicalKeyboardKey.arrowRight) { - if (!_showControls) { - _onUserInteraction(); - return true; - } - return false; // Let Focus system handle navigation - } - - // Channel Down = Previous Channel (inverted as requested) - if (key == LogicalKeyboardKey.channelDown) { - if (widget.streamType == StreamType.live) { - _playPrevious(); - _onUserInteraction(); - return true; - } - } - - // Channel Up = Next Channel (inverted as requested) if (key == LogicalKeyboardKey.channelUp) { - if (widget.streamType == StreamType.live) { - _playNext(); + if (widget.channels != null) + _switchChannel((_currentIndex + 1) % widget.channels!.length); + return true; + } + if (key == LogicalKeyboardKey.channelDown) { + if (widget.channels != null) + _switchChannel((_currentIndex - 1 + widget.channels!.length) % + widget.channels!.length); + return true; + } + + if (!_showControls) { + if (key == LogicalKeyboardKey.select || + key == LogicalKeyboardKey.enter || + key == LogicalKeyboardKey.arrowUp || + key == LogicalKeyboardKey.arrowDown || + key == LogicalKeyboardKey.arrowLeft || + key == LogicalKeyboardKey.arrowRight) { _onUserInteraction(); return true; } } - if (widget.streamType != StreamType.live) { - if (key == LogicalKeyboardKey.arrowLeft) { - _seek(const Duration(seconds: -10)); - return true; - } - if (key == LogicalKeyboardKey.arrowRight) { - _seek(const Duration(seconds: 10)); - return true; - } - } - - // Cycle Aspect Ratio - secret key 'A' or digit 1 - if (key == LogicalKeyboardKey.keyA || key == LogicalKeyboardKey.digit1) { - _cycleAspectRatio(); - return true; - } - - // Toggle Deinterlace - secret key 'D' or digit 2 - if (key == LogicalKeyboardKey.keyD || key == LogicalKeyboardKey.digit2) { - _togglePerChannelDeinterlace(); - return true; - } - if (key == LogicalKeyboardKey.escape || key == LogicalKeyboardKey.goBack) { if (_showControls) { setState(() => _showControls = false); - } else { - Navigator.pop(context); + return true; } + Navigator.pop(context); return true; } @@ -409,422 +319,275 @@ class _LitePlayerScreenState extends ConsumerState WidgetsBinding.instance.removeObserver(this); HardwareKeyboard.instance.removeHandler(_handleKeyEvent); WakelockPlus.disable(); - - _controlsTimer?.cancel(); + _clockTimer?.cancel(); _epgTimer?.cancel(); + _controlsTimer?.cancel(); + _stabilizationTimer?.cancel(); _controller?.dispose(); _xtreamService?.dispose(); - _playPauseFocusNode.dispose(); - _backFocusNode.dispose(); - _deinterlaceFocusNode.dispose(); - - SystemChrome.setPreferredOrientations([ - DeviceOrientation.portraitUp, - DeviceOrientation.landscapeLeft, - DeviceOrientation.landscapeRight, - ]); - SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); super.dispose(); } - String _formatDuration(Duration duration) { - String twoDigits(int n) => n.toString().padLeft(2, '0'); - String twoDigitMinutes = twoDigits(duration.inMinutes.remainder(60)); - String twoDigitSeconds = twoDigits(duration.inSeconds.remainder(60)); - return "${twoDigits(duration.inHours)}:$twoDigitMinutes:$twoDigitSeconds"; - } - - Widget _buildVideoOutput() { - final mode = ref.watch(mobileSettingsProvider).aspectRatioMode; - - switch (mode) { - case 'cover': - return SizedBox.expand( - child: FittedBox( - fit: BoxFit.cover, - child: SizedBox( - width: _controller!.value.size.width, - height: _controller!.value.size.height, - child: VideoPlayer(_controller!), - ), - ), - ); - case 'fill': - return SizedBox.expand( - child: VideoPlayer(_controller!), - ); - case 'contain': - default: - return AspectRatio( - aspectRatio: _controller!.value.aspectRatio, - child: VideoPlayer(_controller!), - ); - } - } - - void _cycleAspectRatio() { - final current = ref.read(mobileSettingsProvider).aspectRatioMode; - String next; - String label; - if (current == 'contain') { - next = 'cover'; - label = 'Zoom (Cover)'; - } else if (current == 'cover') { - next = 'fill'; - label = 'Étirer (Fill)'; - } else { - next = 'contain'; - label = 'Original (Contain)'; - } - - ref.read(mobileSettingsProvider.notifier).setAspectRatioMode(next); - - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Format: $label'), - duration: const Duration(seconds: 1), - backgroundColor: Colors.black87, - ), - ); - } - } - - void _togglePerChannelDeinterlace() { - final currentStreamId = - widget.channels != null && widget.channels!.isNotEmpty - ? widget.channels![_currentIndex].streamId - : widget.streamId; - - final notifier = ref.read(deinterlaceSettingsProvider.notifier); - notifier.toggle(currentStreamId); - - final newState = notifier.isEnabled(currentStreamId); - - if (mounted) { - setState(() {}); // Refresh UI - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: - Text('Désentrelacement: ${newState ? "ACTIVER" : "DÉSACTIVER"}'), - duration: const Duration(seconds: 2), - backgroundColor: newState ? Colors.green : Colors.grey[800], - ), - ); - } - } - @override Widget build(BuildContext context) { - final title = widget.channels != null && widget.channels!.isNotEmpty + final settings = ref.watch(mobileSettingsProvider); + final title = widget.channels != null ? widget.channels![_currentIndex].name : widget.title; return Scaffold( backgroundColor: Colors.black, body: GestureDetector( - behavior: HitTestBehavior.opaque, onTap: _onUserInteraction, child: Stack( children: [ - // Video Output - Standard AspectRatio + // Video Surface Center( child: _controller != null && _controller!.value.isInitialized - ? _buildVideoOutput() + ? _buildAspectRatioWrapper() : const CircularProgressIndicator(color: AppColors.primary), ), - if (_errorMessage != null) - Center( - child: Text( - _errorMessage!, - style: const TextStyle(color: Colors.red), - ), + // Top Overlay (Clock) + if (_showControls && settings.showClock) + Positioned( + top: 20, + right: 30, + child: Text(_currentTime, + style: const TextStyle( + color: Colors.white, + fontSize: 24, + fontWeight: FontWeight.bold, + shadows: [ + Shadow(blurRadius: 10, color: Colors.black) + ])), ), - if (_showControls) _buildControls(title), + // Error Overlay + if (_errorMessage != null) + Center( + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.black87, + borderRadius: BorderRadius.circular(8)), + child: Text(_errorMessage!, + style: const TextStyle(color: Colors.red)))), + + // Loading Spinner + if (_isLoading) + const Center( + child: CircularProgressIndicator(color: AppColors.primary)), + + // OSD (EPG + Controls) + if (_showControls) _buildOSD(title), ], ), ), ); } - Widget _buildControls(String title) { - return Stack( + Widget _buildAspectRatioWrapper() { + final mode = ref.read(mobileSettingsProvider).aspectRatioMode; + if (mode == 'fill') + return SizedBox.expand(child: VideoPlayer(_controller!)); + if (mode == 'cover') + return SizedBox.expand( + child: FittedBox( + fit: BoxFit.cover, + child: SizedBox( + width: _controller!.value.size.width, + height: _controller!.value.size.height, + child: VideoPlayer(_controller!)))); + return AspectRatio( + aspectRatio: _controller!.value.aspectRatio, + child: VideoPlayer(_controller!)); + } + + Widget _buildOSD(String title) { + return Positioned( + bottom: 20, + left: 20, + right: 20, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Seeker (VOD) + if (widget.streamType != StreamType.live) _buildSeeker(), + + // Unified Box: EPG & Controls + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.7), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.white10), + ), + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // EPG Info (Left) + Expanded(child: _buildEPGBox(title)), + const SizedBox(width: 24), + // Controls Continuity (Right) + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (widget.streamType == StreamType.live) + TVFocusable( + focusNode: _prevFocusNode, + onPressed: () => _switchChannel((_currentIndex - + 1 + + (widget.channels?.length ?? 0)) % + (widget.channels?.length ?? 1)), + child: const Icon(Icons.skip_previous, + color: Colors.white, size: 36)), + const SizedBox(width: 16), + TVFocusable( + focusNode: _playPauseFocusNode, + onPressed: _togglePlayPause, + child: Icon( + _isPlaying + ? Icons.pause_circle_filled + : Icons.play_circle_filled, + color: Colors.white, + size: 56)), + const SizedBox(width: 16), + if (widget.streamType == StreamType.live) + TVFocusable( + focusNode: _nextFocusNode, + onPressed: () => _switchChannel( + (_currentIndex + 1) % + (widget.channels?.length ?? 1)), + child: const Icon(Icons.skip_next, + color: Colors.white, size: 36)), + const SizedBox(width: 16), + TVFocusable( + focusNode: _aspectFocusNode, + onPressed: _cycleAspectRatio, + child: const Icon(Icons.aspect_ratio, + color: Colors.white70, size: 28)), + ], + ), + ], + ), + ), + ), + ], + ), + ); + } + + Widget _buildEPGBox(String title) { + String program = widget.streamType == StreamType.live + ? (_epg?.nowPlaying ?? "Pas d'infos EPG") + : title; + String next = + _epg?.nextPlaying != null ? "Suivant: ${_epg!.nextPlaying}" : ""; + double progress = _epg?.progress ?? 0.0; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Top Bar - Positioned( - top: 0, - left: 0, - right: 0, - child: Container( - color: Colors.black54, - padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), - child: Row( - children: [ - TVFocusable( - focusNode: _backFocusNode, - onPressed: () => Navigator.pop(context), - child: const Icon(Icons.arrow_back, color: Colors.white), - ), - const SizedBox(width: 16), - if (widget.channels != null && - widget.channels![_currentIndex].streamIcon.isNotEmpty) - Padding( - padding: const EdgeInsets.only(right: 12), - child: SizedBox( - width: 32, - height: 32, - child: CachedNetworkImage( - imageUrl: widget.channels![_currentIndex].streamIcon, - errorWidget: (_, __, ___) => const SizedBox.shrink(), - ), - ), - ), - Text(title, + Row( + children: [ + if (widget.channels != null && + widget.channels![_currentIndex].streamIcon.isNotEmpty) + Container( + margin: const EdgeInsets.only(right: 12), + width: 40, + height: 40, + child: CachedNetworkImage( + imageUrl: widget.channels![_currentIndex].streamIcon)), + Expanded( + child: Text(title, style: const TextStyle( - color: Colors.white, fontWeight: FontWeight.bold)), - const Spacer(), - ], - ), - ), - ), - - // Bottom Controls (Aligned with EPG) - Positioned( - bottom: 80, - left: 0, - right: 0, - child: Center( - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - // Live TV: Prev Channel - if (widget.streamType == StreamType.live && - widget.channels != null) - Padding( - padding: const EdgeInsets.only(right: 24), - child: IconButton( - icon: const Icon( - Icons.skip_previous, color: Colors.white, - size: 48, - ), - onPressed: _playPrevious, - ), - ), - - // VOD: Replay 10s - if (widget.streamType != StreamType.live) ...[ - IconButton( - icon: const Icon( - Icons.replay_10, - color: Colors.white70, - size: 36, - ), - onPressed: () => _seek(const Duration(seconds: -10)), - ), - const SizedBox(width: 24), - ], - - // Play/Pause Button - TVFocusable( - focusNode: _playPauseFocusNode, - onPressed: _togglePlayPause, - scale: 1.0, - borderWidth: 0, - child: Container( - width: 72, - height: 72, - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.2), - shape: BoxShape.circle, - ), - child: Icon( - _isPlaying - ? Icons.pause_rounded - : Icons.play_arrow_rounded, - color: Colors.white, - size: 48, - ), - ), - ), - - // VOD: Forward 10s - if (widget.streamType != StreamType.live) ...[ - const SizedBox(width: 24), - IconButton( - icon: const Icon( - Icons.forward_10, - color: Colors.white70, - size: 36, - ), - onPressed: () => _seek(const Duration(seconds: 10)), - ), - ], - - // Live TV: Next Channel - if (widget.streamType == StreamType.live && - widget.channels != null) - Padding( - padding: const EdgeInsets.only(left: 24), - child: IconButton( - icon: const Icon( - Icons.skip_next, - color: Colors.white, - size: 48, - ), - onPressed: _playNext, - ), - ), - ], - ), - ), + fontSize: 18, + fontWeight: FontWeight.bold))), + ], ), - - // EPG Content + const SizedBox(height: 8), + Text(program, + style: const TextStyle( + color: AppColors.primary, + fontSize: 16, + fontWeight: FontWeight.w500), + maxLines: 1, + overflow: TextOverflow.ellipsis), if (widget.streamType == StreamType.live && - _epg != null && - _epg!.nowPlaying != null) - Positioned( - bottom: 180, // Moved up to avoid button overlap - left: 24, - width: 350, - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.black - .withOpacity(0.85), // Higher opacity for visibility - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.white24), - ), - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - _epg!.nowPlaying!, - style: const TextStyle( - color: Colors.white, - fontSize: 16, - fontWeight: FontWeight.bold, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - if (_epg!.nextPlaying != null) - Padding( - padding: const EdgeInsets.only(top: 4), - child: Text( - "A suivre: ${_epg!.nextPlaying}", - style: const TextStyle( - color: Colors.white70, - fontSize: 14, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ), - Container( - padding: - const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - margin: const EdgeInsets.only(left: 8), - decoration: BoxDecoration( - color: Colors.red, - borderRadius: BorderRadius.circular(4), - ), - child: const Text( - 'LIVE', - style: TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - fontSize: 12, - ), - ), - ), - ], - ), - ), - ), - - // Progress Bar (VOD) - if (widget.streamType != StreamType.live) - Positioned( - bottom: 0, - left: 0, - right: 0, - child: Container( - color: Colors.black54, - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), - child: Row( - children: [ - Text( - _formatDuration(_position), - style: const TextStyle(color: Colors.white), - ), - Expanded( - child: Actions( - actions: >{ - DirectionalFocusIntent: - CallbackAction( - onInvoke: (intent) { - if (intent.direction == TraversalDirection.up || - intent.direction == TraversalDirection.down) { - FocusScope.of(context) - .focusInDirection(intent.direction); - return null; - } - return null; - }, - ), - }, - child: Shortcuts( - shortcuts: { - LogicalKeySet(LogicalKeyboardKey.arrowUp): - const DirectionalFocusIntent( - TraversalDirection.up), - LogicalKeySet(LogicalKeyboardKey.arrowDown): - const DirectionalFocusIntent( - TraversalDirection.down), - }, - child: Slider( - value: _position.inSeconds - .toDouble() - .clamp(0, _duration.inSeconds.toDouble()), - min: 0, - max: _duration.inSeconds.toDouble(), - activeColor: AppColors.primary, - inactiveColor: Colors.white24, - onChanged: (val) { - setState(() { - _position = Duration(seconds: val.toInt()); - _isSeeking = true; - }); - }, - onChangeEnd: (val) { - _controller?.seekTo(Duration(seconds: val.toInt())); - _isSeeking = false; - _onUserInteraction(); - }, - ), - ), - ), - ), - Text( - _formatDuration(_duration), - style: const TextStyle(color: Colors.white), - ), - ], - ), - ), - ), + _epg?.nowPlaying != null) ...[ + const SizedBox(height: 6), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: progress, + backgroundColor: Colors.white10, + color: AppColors.primary, + minHeight: 4)), + const SizedBox(height: 6), + Text(next, + style: const TextStyle(color: Colors.white54, fontSize: 12), + maxLines: 1, + overflow: TextOverflow.ellipsis), + ], ], ); } + + Widget _buildSeeker() { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Actions( + actions: { + DirectionalFocusIntent: + CallbackAction(onInvoke: (intent) { + if (intent.direction == TraversalDirection.up || + intent.direction == TraversalDirection.down) { + FocusScope.of(context).focusInDirection(intent.direction); + return null; + } + return null; + }) + }, + child: Shortcuts( + shortcuts: { + LogicalKeySet(LogicalKeyboardKey.arrowUp): + const DirectionalFocusIntent(TraversalDirection.up), + LogicalKeySet(LogicalKeyboardKey.arrowDown): + const DirectionalFocusIntent(TraversalDirection.down) + }, + child: Slider( + value: _position.inSeconds + .toDouble() + .clamp(0, _duration.inSeconds.toDouble()), + min: 0, + max: _duration.inSeconds.toDouble(), + activeColor: AppColors.primary, + inactiveColor: Colors.white24, + onChanged: (v) => setState(() { + _position = Duration(seconds: v.toInt()); + _isSeeking = true; + }), + onChangeEnd: (v) { + _controller?.seekTo(Duration(seconds: v.toInt())); + _isSeeking = false; + _onUserInteraction(); + }, + ), + ), + ), + ); + } + + void _cycleAspectRatio() { + final current = ref.read(mobileSettingsProvider).aspectRatioMode; + final next = current == 'contain' + ? 'cover' + : (current == 'cover' ? 'fill' : 'contain'); + ref.read(mobileSettingsProvider.notifier).setAspectRatioMode(next); + _onUserInteraction(); + } } diff --git a/lib/mobile/features/iptv/screens/native_player_screen.dart b/lib/mobile/features/iptv/screens/native_player_screen.dart index 95f5984..da45911 100644 --- a/lib/mobile/features/iptv/screens/native_player_screen.dart +++ b/lib/mobile/features/iptv/screens/native_player_screen.dart @@ -196,15 +196,15 @@ class _NativePlayerScreenState extends ConsumerState (_player.platform as dynamic) ?.setProperty('http-header-fields', 'User-Agent: XtremFlow/1.0'); - // Live Stream optimizations - aggressive reconnection + // Live Stream optimizations - reconnection only (_player.platform as dynamic)?.setProperty( 'stream-lavf-o', - 'reconnect=1,reconnect_streamed=1,reconnect_on_network_error=1,reconnect_delay_max=10,reconnect_on_http_error=4xx,5xx', + 'reconnect=1,reconnect_streamed=1,reconnect_on_network_error=1,reconnect_delay_max=5', ); - (_player.platform as dynamic)?.setProperty('force-seekable', 'yes'); + // Removed: force-seekable and live_start_index=-1 which caused rollbacks (_player.platform as dynamic)?.setProperty( 'demuxer-lavf-o', - 'live_start_index=-1,analyzeduration=10000000,probesize=5000000', + 'analyzeduration=5000000,probesize=2000000', ); // Optimized for performance and stability on progressive & interlaced streams @@ -384,6 +384,15 @@ class _NativePlayerScreenState extends ConsumerState super.deactivate(); } + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.paused || + state == AppLifecycleState.inactive || + state == AppLifecycleState.detached) { + _player.pause(); + } + } + @override void dispose() { // Remove lifecycle observer diff --git a/pubspec.yaml b/pubspec.yaml index c0ed7ae..b86bc1b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: xtremflow description: High-performance IPTV Web Application with Xtream Codes API publish_to: 'none' -version: 1.3.9+4 +version: 1.5.0+10 environment: sdk: '>=3.0.0 <4.0.0'