diff --git a/lib/mobile/features/iptv/screens/lite_player_screen.dart b/lib/mobile/features/iptv/screens/lite_player_screen.dart index aa6a20a..de92118 100644 --- a/lib/mobile/features/iptv/screens/lite_player_screen.dart +++ b/lib/mobile/features/iptv/screens/lite_player_screen.dart @@ -13,7 +13,8 @@ import 'package:xtremflow/features/iptv/services/xtream_service_mobile.dart'; import 'package:xtremflow/mobile/providers/mobile_settings_providers.dart'; import 'package:xtremflow/core/theme/app_colors.dart'; import 'package:xtremflow/mobile/providers/mobile_xtream_providers.dart'; -import 'package:xtremflow/mobile/features/iptv/screens/native_player_screen.dart' show StreamType; +import 'package:xtremflow/mobile/features/iptv/screens/native_player_screen.dart' + show StreamType; import 'package:xtremflow/features/iptv/models/xtream_models.dart' as xm; /// Lite version of the player using standard video_player (ExoPlayer on Android) @@ -27,7 +28,7 @@ class LitePlayerScreen extends ConsumerStatefulWidget { final PlaylistConfig playlist; final List? channels; final int initialIndex; - + // For series episodes final dynamic seriesId; final int? season; @@ -51,25 +52,26 @@ class LitePlayerScreen extends ConsumerStatefulWidget { ConsumerState createState() => _LitePlayerScreenState(); } -class _LitePlayerScreenState extends ConsumerState with WidgetsBindingObserver { +class _LitePlayerScreenState extends ConsumerState + with WidgetsBindingObserver { VideoPlayerController? _controller; XtreamServiceMobile? _xtreamService; - + bool _isLoading = true; String? _errorMessage; late int _currentIndex; bool _showControls = true; bool _isPlaying = false; - + // EPG xm.ShortEPG? _epg; Timer? _epgTimer; - + // Progress & Seeking Duration _position = Duration.zero; Duration _duration = Duration.zero; bool _isSeeking = false; - + // Focus Nodes final FocusNode _playPauseFocusNode = FocusNode(); final FocusNode _backFocusNode = FocusNode(); @@ -83,13 +85,13 @@ class _LitePlayerScreenState extends ConsumerState with Widget super.initState(); WidgetsBinding.instance.addObserver(this); HardwareKeyboard.instance.addHandler(_handleKeyEvent); - + // Enable wakelock WakelockPlus.enable(); - + _currentIndex = widget.initialIndex; _startClock(); - + // Lock orientation SystemChrome.setPreferredOrientations([ DeviceOrientation.landscapeLeft, @@ -107,21 +109,23 @@ class _LitePlayerScreenState extends ConsumerState with Widget await _xtreamService!.setPlaylistAsync(widget.playlist); _loadStream(); _updateEPG(); // Initial Load - + // Update EPG periodically for Live TV if (widget.streamType == StreamType.live) { - _epgTimer = Timer.periodic(const Duration(minutes: 5), (_) => _updateEPG()); + _epgTimer = + Timer.periodic(const Duration(minutes: 5), (_) => _updateEPG()); } } catch (e) { if (mounted) setState(() => _errorMessage = "Service Error: $e"); } } - + Future _updateEPG() async { if (widget.streamType != StreamType.live || _xtreamService == null) return; - + try { - final currentChannelId = widget.channels?[_currentIndex].streamId ?? widget.streamId; + final currentChannelId = + widget.channels?[_currentIndex].streamId ?? widget.streamId; // Get EPG for current channel final epgData = await _xtreamService!.getShortEPG(currentChannelId); if (mounted) { @@ -144,22 +148,25 @@ class _LitePlayerScreenState extends ConsumerState with Widget }); try { - final currentStreamId = widget.channels != null && widget.channels!.isNotEmpty - ? widget.channels![_currentIndex].streamId - : widget.streamId; - + final currentStreamId = + widget.channels != null && widget.channels!.isNotEmpty + ? widget.channels![_currentIndex].streamId + : widget.streamId; + // Update EPG when changing channels if (widget.streamType == StreamType.live) { - _updateEPG(); + _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'); + streamUrl = _xtreamService!.getVodStreamUrl( + currentStreamId, widget.containerExtension ?? 'mp4'); } else { - streamUrl = _xtreamService!.getSeriesStreamUrl(currentStreamId, widget.containerExtension ?? 'mp4'); + streamUrl = _xtreamService!.getSeriesStreamUrl( + currentStreamId, widget.containerExtension ?? 'mp4'); } final controller = VideoPlayerController.networkUrl( @@ -169,7 +176,7 @@ class _LitePlayerScreenState extends ConsumerState with Widget _controller = controller; await controller.initialize(); - + if (!mounted) { await controller.dispose(); return; @@ -182,10 +189,9 @@ class _LitePlayerScreenState extends ConsumerState with Widget _duration = controller.value.duration; }); _resetControlsTimer(); - + // Listen for errors and completion controller.addListener(_videoListener); - } catch (e) { if (mounted) { setState(() { @@ -198,23 +204,23 @@ class _LitePlayerScreenState extends ConsumerState with Widget void _videoListener() { if (_controller == null || !mounted) return; - + final isPlaying = _controller!.value.isPlaying; if (isPlaying != _isPlaying) { setState(() => _isPlaying = isPlaying); } - + if (!_isSeeking) { - final pos = _controller!.value.position; - final dur = _controller!.value.duration; - if (pos != _position || dur != _duration) { - setState(() { - _position = pos; - _duration = dur; - }); - } + final pos = _controller!.value.position; + final dur = _controller!.value.duration; + if (pos != _position || dur != _duration) { + setState(() { + _position = pos; + _duration = dur; + }); + } } - + if (_controller!.value.hasError) { setState(() => _errorMessage = _controller!.value.errorDescription); } @@ -243,7 +249,7 @@ class _LitePlayerScreenState extends ConsumerState with Widget } _onUserInteraction(); } - + void _playNext() { if (widget.channels == null || widget.channels!.isEmpty) return; final nextIndex = (_currentIndex + 1) % widget.channels!.length; @@ -252,7 +258,8 @@ class _LitePlayerScreenState extends ConsumerState with Widget void _playPrevious() { if (widget.channels == null || widget.channels!.isEmpty) return; - final prevIndex = (_currentIndex - 1 + widget.channels!.length) % widget.channels!.length; + final prevIndex = + (_currentIndex - 1 + widget.channels!.length) % widget.channels!.length; _switchChannel(prevIndex); } @@ -260,13 +267,13 @@ class _LitePlayerScreenState extends ConsumerState with Widget 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) - ); + milliseconds: newPos.inMilliseconds + .clamp(0, _controller!.value.duration.inMilliseconds)); await _controller!.seekTo(clamped); _onUserInteraction(); } @@ -275,50 +282,56 @@ class _LitePlayerScreenState extends ConsumerState with Widget 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.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; + if (key == LogicalKeyboardKey.select || + key == LogicalKeyboardKey.enter || + key == LogicalKeyboardKey.space) { + if (!_showControls) { + _onUserInteraction(); + return true; + } + return false; } - - if (key == LogicalKeyboardKey.arrowDown || key == LogicalKeyboardKey.channelDown) { + + if (key == LogicalKeyboardKey.arrowDown || + key == LogicalKeyboardKey.channelDown) { if (widget.streamType == StreamType.live) { - _playPrevious(); - _onUserInteraction(); + _playPrevious(); + _onUserInteraction(); } return true; } - if (key == LogicalKeyboardKey.arrowUp || key == LogicalKeyboardKey.channelUp) { - if (widget.streamType == StreamType.live) { - _playNext(); - _onUserInteraction(); - } - return true; + if (key == LogicalKeyboardKey.arrowUp || + key == LogicalKeyboardKey.channelUp) { + if (widget.streamType == StreamType.live) { + _playNext(); + _onUserInteraction(); + } + return true; } - + if (widget.streamType != StreamType.live) { if (key == LogicalKeyboardKey.arrowLeft) { - _seek(const Duration(seconds: -10)); - return true; + _seek(const Duration(seconds: -10)); + return true; } if (key == LogicalKeyboardKey.arrowRight) { - _seek(const Duration(seconds: 10)); - return true; + _seek(const Duration(seconds: 10)); + return true; } } - + if (key == LogicalKeyboardKey.escape || key == LogicalKeyboardKey.goBack) { if (_showControls) { - setState(() => _showControls = false); + setState(() => _showControls = false); } else { Navigator.pop(context); } @@ -327,17 +340,19 @@ class _LitePlayerScreenState extends ConsumerState with Widget return false; } - + void _startClock() { _updateTime(); - _clockTimer = Timer.periodic(const Duration(seconds: 1), (_) => _updateTime()); + _clockTimer = + Timer.periodic(const Duration(seconds: 1), (_) => _updateTime()); } - + void _updateTime() { if (mounted) { final now = DateTime.now(); setState(() { - _currentTime = "${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}"; + _currentTime = + "${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}"; }); } } @@ -347,7 +362,7 @@ class _LitePlayerScreenState extends ConsumerState with Widget WidgetsBinding.instance.removeObserver(this); HardwareKeyboard.instance.removeHandler(_handleKeyEvent); WakelockPlus.disable(); - + _controlsTimer?.cancel(); _clockTimer?.cancel(); _epgTimer?.cancel(); @@ -355,7 +370,7 @@ class _LitePlayerScreenState extends ConsumerState with Widget _xtreamService?.dispose(); _playPauseFocusNode.dispose(); _backFocusNode.dispose(); - + SystemChrome.setPreferredOrientations([ DeviceOrientation.portraitUp, DeviceOrientation.landscapeLeft, @@ -374,8 +389,8 @@ class _LitePlayerScreenState extends ConsumerState with Widget @override Widget build(BuildContext context) { - final title = widget.channels != null && widget.channels!.isNotEmpty - ? widget.channels![_currentIndex].name + final title = widget.channels != null && widget.channels!.isNotEmpty + ? widget.channels![_currentIndex].name : widget.title; return Scaffold( @@ -387,86 +402,107 @@ class _LitePlayerScreenState extends ConsumerState with Widget children: [ // Video Output - Standard AspectRatio Center( - child: _controller != null && _controller!.value.isInitialized + child: _controller != null && _controller!.value.isInitialized ? AspectRatio( aspectRatio: _controller!.value.aspectRatio, child: VideoPlayer(_controller!), ) : const CircularProgressIndicator(color: AppColors.primary), ), - - if (_errorMessage != null) - Center(child: Text(_errorMessage!, style: const TextStyle(color: Colors.red))), - if (_showControls) - _buildControls(title), + if (_errorMessage != null) + Center( + child: Text(_errorMessage!, + style: const TextStyle(color: Colors.red))), + + if (_showControls) _buildControls(title), ], ), ), ); } - + Widget _buildControls(String title) { return Stack( children: [ // Top Bar Positioned( - top: 0, left: 0, right: 0, + 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, style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold)), - const Spacer(), - // NO RATIO TOGGLE HERE - const SizedBox(width: 16), - Text(_currentTime, style: const TextStyle(color: Colors.white70)), + 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, + style: const TextStyle( + color: Colors.white, fontWeight: FontWeight.bold)), + const Spacer(), + // NO RATIO TOGGLE HERE + const SizedBox(width: 16), + Text(_currentTime, + style: const TextStyle(color: Colors.white70)), ], ), ), ), - - // Center Controls + + // Bottom Controls (Aligned with EPG) Positioned( - bottom: 100, + 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), + 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, + scale: 1.0, borderWidth: 0, child: Container( width: 72, @@ -476,112 +512,147 @@ class _LitePlayerScreenState extends ConsumerState with Widget shape: BoxShape.circle, ), child: Icon( - _isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded, + _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)), - ), + 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, + ), + ), ], ), ), ), - + // EPG Content - if (widget.streamType == StreamType.live && _epg != null && _epg!.nowPlaying != null) - Positioned( - bottom: 80, - left: 24, - width: 350, - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.black.withOpacity(0.7), - 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)), - ), - ], - ), - ), + if (widget.streamType == StreamType.live && + _epg != null && + _epg!.nowPlaying != null) + Positioned( + bottom: 80, + left: 24, + width: 350, + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.7), + 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: Slider( - value: _position.inSeconds.toDouble().clamp(0, _duration.inSeconds.toDouble()), - min: 0, - max: _duration.inSeconds.toDouble() > 0 ? _duration.inSeconds.toDouble() : 1.0, - 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)), - ], + 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: Slider( + value: _position.inSeconds + .toDouble() + .clamp(0, _duration.inSeconds.toDouble()), + min: 0, + max: _duration.inSeconds.toDouble() > 0 + ? _duration.inSeconds.toDouble() + : 1.0, + 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)), + ], ), + ), ), ], ); diff --git a/lib/mobile/features/iptv/widgets/mobile_live_tv_tab.dart b/lib/mobile/features/iptv/widgets/mobile_live_tv_tab.dart index 424df4c..3aa1452 100644 --- a/lib/mobile/features/iptv/widgets/mobile_live_tv_tab.dart +++ b/lib/mobile/features/iptv/widgets/mobile_live_tv_tab.dart @@ -406,11 +406,11 @@ class _MobileLiveTVTabState extends ConsumerState void _playChannel(BuildContext context, Channel channel, List channels, PlaylistConfig playlist, int index) async { - // Unified Player Strategy: Use NativePlayer (Ultra) for everything + // Unified Player Strategy: Use LitePlayer (ExoPlayer) for Live TV (Stability) await Navigator.push( context, MaterialPageRoute( - builder: (context) => NativePlayerScreen( + builder: (context) => LitePlayerScreen( streamId: channel.streamId, title: channel.name, playlist: playlist,