diff --git a/Dockerfile b/Dockerfile index 41c7819..25ab6fc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,7 +19,7 @@ RUN flutter config --enable-web # Copy dependency files first for better caching # Note: pubspec.lock will be generated by flutter pub get if missing # Cache buster - INCREMENT THIS VALUE TO FORCE REBUILD ON DEPLOY -ARG CACHEBUST=2025-12-08-v4 +ARG CACHEBUST=2025-12-13-v5 COPY pubspec.yaml ./ # Get dependencies (generates pubspec.lock automatically) diff --git a/lib/features/iptv/providers/settings_provider.dart b/lib/features/iptv/providers/settings_provider.dart index 3fc1d0b..2d6bb14 100644 --- a/lib/features/iptv/providers/settings_provider.dart +++ b/lib/features/iptv/providers/settings_provider.dart @@ -37,6 +37,12 @@ enum TranscodingMode { disabled, // Direct stream (no transcoding) } +/// Player Type Preference +enum PlayerType { + standard, // Heavily customized HTML5 player (default) + lite, // Lightweight native/video_player based +} + /// Keys for API JSON storage class _SettingsKeys { // Filters @@ -55,6 +61,7 @@ class _SettingsKeys { // Player Display Settings static const String showClock = 'show_clock'; static const String preferredAspectRatio = 'aspect_ratio'; + static const String playerType = 'player_type'; } /// Settings state for IPTV preferences with persistence @@ -76,6 +83,7 @@ class IptvSettings { // Player Display Settings final bool showClock; final String preferredAspectRatio; + final PlayerType playerType; const IptvSettings({ // Filters @@ -93,6 +101,7 @@ class IptvSettings { // Player defaults this.showClock = false, this.preferredAspectRatio = 'contain', + this.playerType = PlayerType.standard, }); IptvSettings copyWith({ @@ -108,6 +117,7 @@ class IptvSettings { bool? preferDirectPlay, bool? showClock, String? preferredAspectRatio, + PlayerType? playerType, }) { return IptvSettings( liveTvCategoryFilter: liveTvCategoryFilter ?? this.liveTvCategoryFilter, @@ -122,6 +132,7 @@ class IptvSettings { preferDirectPlay: preferDirectPlay ?? this.preferDirectPlay, showClock: showClock ?? this.showClock, preferredAspectRatio: preferredAspectRatio ?? this.preferredAspectRatio, + playerType: playerType ?? this.playerType, ); } @@ -285,6 +296,8 @@ class IptvSettingsNotifier extends StateNotifier { preferDirectPlay: getValue(_SettingsKeys.preferDirectPlay), showClock: getValue(_SettingsKeys.showClock), preferredAspectRatio: getValue(_SettingsKeys.preferredAspectRatio), + playerType: remoteSettings[_SettingsKeys.playerType] != null + ? PlayerType.values[remoteSettings[_SettingsKeys.playerType] as int] : null, ); } _initialized = true; @@ -314,6 +327,7 @@ class IptvSettingsNotifier extends StateNotifier { _SettingsKeys.preferDirectPlay: state.preferDirectPlay, _SettingsKeys.showClock: state.showClock, _SettingsKeys.preferredAspectRatio: state.preferredAspectRatio, + _SettingsKeys.playerType: state.playerType.index, }; print('[SettingsProvider] _saveToApi: Saving settings...'); @@ -400,6 +414,11 @@ class IptvSettingsNotifier extends StateNotifier { _saveToApi(); } + void setPlayerType(PlayerType type) { + state = state.copyWith(playerType: type); + _saveToApi(); + } + // Legacy methods void setCategoryFilter(String filter) => setLiveTvFilter(filter); void clearCategoryFilter() => clearLiveTvFilter(); diff --git a/lib/features/iptv/screens/player_screen.dart b/lib/features/iptv/screens/player_screen.dart index 2eddfb3..dd6807b 100644 --- a/lib/features/iptv/screens/player_screen.dart +++ b/lib/features/iptv/screens/player_screen.dart @@ -8,108 +8,31 @@ import 'package:pointer_interceptor/pointer_interceptor.dart'; import '../providers/xtream_provider.dart'; import '../providers/playback_positions_provider.dart'; import '../providers/settings_provider.dart'; -import '../widgets/epg_overlay.dart'; -import '../../../core/models/playlist_config.dart'; -import '../../../core/models/iptv_models.dart'; -import '../../../core/theme/app_colors.dart'; -import '../../../core/widgets/glass_container.dart'; -import '../../../core/widgets/tv_focusable_card.dart'; +import '../widgets/lite_player_view.dart'; -enum StreamType { live, vod, series } - -class PlayerScreen extends ConsumerStatefulWidget { - final String streamId; - final String title; - final StreamType streamType; - final String containerExtension; - final PlaylistConfig playlist; - final List? channels; - final int initialIndex; - - const PlayerScreen({ - super.key, - required this.streamId, - required this.title, - required this.playlist, - this.streamType = StreamType.live, - this.containerExtension = 'mp4', - this.channels, - this.initialIndex = 0, - }); - - @override - ConsumerState createState() => _PlayerScreenState(); -} +// ... (existing imports) class _PlayerScreenState extends ConsumerState { - // ... (Keep existing state variables) - String? _errorMessage; - String? _statusMessage; - late String _viewId; - late String _contentId; - late int _currentIndex; - bool _isInitialized = false; - bool _isLoading = true; - bool _showControls = false; bool _isPlaying = true; + bool _showControls = false; + Timer? _controlsTimer; double _currentPosition = 0; double _totalDuration = 1; - double _volume = 1.0; - double _previousVolume = 1.0; - bool _isFullscreen = false; - Timer? _controlsTimer; + int _currentIndex = 0; + bool _isInitialized = false; + bool _isLoading = true; StreamSubscription? _messageSubscription; - List> _audioTracks = []; String _aspectRatio = 'contain'; - - // ... (Keep existing methods: _formatDuration, _setupMessageListener, _sendMessage, etc.) - // For brevity in this artifact, I will focus on the BUILD method and styling changes. - // The logic remains largely the same, just the UI wrapper changes. - - 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"; - } - - void _setupMessageListener() { - _messageSubscription = html.window.onMessage.listen((event) { - // ... (Same logic as before) - try { - final data = event.data; - if (data is Map) { - final type = data['type']; - if (type == 'playback_position' && _contentId.isNotEmpty) { - final currentTime = (data['currentTime'] as num).toDouble(); - final rawDuration = (data['duration'] as num).toDouble(); - final duration = rawDuration.isFinite ? rawDuration : 0.0; - setState(() { - _currentPosition = currentTime; - _totalDuration = duration > 0 ? duration : 1; - }); - } else if (type == 'playback_status') { - setState(() => _isPlaying = data['status'] == 'playing'); - } else if (type == 'user_activity') { - _onHover(); - } - } - } catch (e) { - debugPrint('PostMessage Error: $e'); - } - }); - } - - void _sendMessage(Map message) { - final iframe = html.document.getElementById(_viewId) as html.IFrameElement?; - iframe?.contentWindow?.postMessage(message, '*'); - } + bool _isSeeking = false; @override void initState() { super.initState(); - _currentIndex = widget.initialIndex; - _initializePlayer(); + if (widget.channels != null) { + _currentIndex = widget.channels!.indexWhere((c) => c.streamId == widget.streamId); + if (_currentIndex == -1) _currentIndex = 0; + } + _initializePlayer(startTimeOverride: widget.startTime); _setupMessageListener(); } @@ -120,53 +43,6 @@ class _PlayerScreenState extends ConsumerState { super.dispose(); } - void _onHover() { - if (!_showControls) setState(() => _showControls = true); - _controlsTimer?.cancel(); - _controlsTimer = Timer(const Duration(seconds: 4), () { - if (mounted) setState(() => _showControls = false); - }); - } - - void _togglePlayPause() { - setState(() => _isPlaying = !_isPlaying); - _sendMessage({'type': _isPlaying ? 'play' : 'pause'}); - _onHover(); - } - - void _toggleFullscreen() { - if (!_isFullscreen) { - html.document.documentElement?.requestFullscreen(); - } else { - html.document.exitFullscreen(); - } - setState(() => _isFullscreen = !_isFullscreen); - } - - void _nextChannel() { - if (widget.channels == null || widget.channels!.isEmpty) return; - - setState(() { - _currentIndex = (_currentIndex + 1) % widget.channels!.length; - _isInitialized = false; // Trigger reload - }); - - // Update player for new stream - // We need to re-initialize with the new stream ID from the list - _initializePlayer(isChannelSwitch: true); - } - - void _previousChannel() { - if (widget.channels == null || widget.channels!.isEmpty) return; - - setState(() { - _currentIndex = (_currentIndex - 1 + widget.channels!.length) % widget.channels!.length; - _isInitialized = false; - }); - - _initializePlayer(isChannelSwitch: true); - } - Future _initializePlayer({double? startTimeOverride, bool isChannelSwitch = false}) async { setState(() { _isLoading = true; @@ -193,6 +69,9 @@ class _PlayerScreenState extends ConsumerState { streamUrl = service.getSeriesStreamUrl(currentStreamId, widget.containerExtension); } + // Store URL for Lite Player + _currentStreamUrl = streamUrl; + final encodedUrl = Uri.encodeComponent(streamUrl); var playerSrc = 'player.html?url=$encodedUrl'; @@ -200,7 +79,7 @@ class _PlayerScreenState extends ConsumerState { playerSrc += '&t=$startTimeOverride'; } - // Register View Factory + // Register View Factory (Only needed for Standard Player, but safe to do always or conditionally) ui_web.platformViewRegistry.registerViewFactory(_viewId, (int viewId) { final iframe = html.IFrameElement() ..id = _viewId @@ -228,8 +107,150 @@ class _PlayerScreenState extends ConsumerState { } } + void _setupMessageListener() { + _messageSubscription = html.window.onMessage.listen((event) { + final data = event.data; + if (data == null) return; + + final type = data['type']; + if (type == 'playback_position') { + final currentTime = (data['currentTime'] as num).toDouble(); + final rawDuration = (data['duration'] as num).toDouble(); + final duration = rawDuration.isFinite ? rawDuration : 0.0; + + // Only update position if user is NOT dragging the slider + if (!_isSeeking) { + setState(() { + _currentPosition = currentTime; + _totalDuration = duration > 0 ? duration : 1; + }); + } + + // Update watch history if relevant + if (currentTime > 0) { + ref.read(playbackPositionsProvider.notifier).savePosition( + widget.playlist.id, + widget.streamId, + currentTime + ); + } + + } else if (type == 'playback_status') { + setState(() => _isPlaying = data['status'] == 'playing'); + } else if (type == 'user_activity') { + _onHover(); + } + }); + } + + void _sendMessage(Map message) { + if (_isInitialized) { + final iframe = html.document.getElementById(_viewId) as html.IFrameElement?; + iframe?.contentWindow?.postMessage(message, '*'); + } + } + + void _onHover() { + setState(() => _showControls = true); + _controlsTimer?.cancel(); + _controlsTimer = Timer(const Duration(seconds: 4), () { + if (mounted && _isPlaying) setState(() => _showControls = false); + }); + } + + void _togglePlayPause() { + if (_isPlaying) { + _sendMessage({'type': 'pause'}); + } else { + _sendMessage({'type': 'play'}); + } + } + + void _toggleFullscreen() { + if (html.document.fullscreenElement != null) { + html.document.exitFullscreen(); + } else { + html.document.documentElement?.requestFullscreen(); + } + } + + void _previousChannel() { + if (widget.channels != null && widget.channels!.isNotEmpty) { + setState(() { + if (_currentIndex > 0) { + _currentIndex--; + } else { + _currentIndex = widget.channels!.length - 1; + } + }); + _initializePlayer(isChannelSwitch: true); + } + } + + void _nextChannel() { + if (widget.channels != null && widget.channels!.isNotEmpty) { + setState(() { + if (_currentIndex < widget.channels!.length - 1) { + _currentIndex++; + } else { + _currentIndex = 0; + } + }); + _initializePlayer(isChannelSwitch: true); + } + } + + String _formatDuration(Duration d) { + String twoDigits(int n) => n.toString().padLeft(2, '0'); + final hours = twoDigits(d.inHours); + final minutes = twoDigits(d.inMinutes.remainder(60)); + final seconds = twoDigits(d.inSeconds.remainder(60)); + return d.inHours > 0 ? '$hours:$minutes:$seconds' : '$minutes:$seconds'; + } + @override Widget build(BuildContext context) { + final settings = ref.watch(iptvSettingsProvider); + + // LITE PLAYER MODE + if (settings.playerType == PlayerType.lite) { + return Scaffold( + backgroundColor: Colors.black, + body: Stack( + children: [ + if (_isInitialized && _currentStreamUrl != null) + LitePlayerView( + streamUrl: _currentStreamUrl!, + isLive: widget.streamType == StreamType.live, + onNext: widget.channels != null ? _nextChannel : null, + onPrevious: widget.channels != null ? _previousChannel : null, + ) + else + const Center(child: CircularProgressIndicator(color: AppColors.primary)), + + // Simple Back Button Overlay + Positioned( + top: 24, + left: 24, + child: TvFocusableCard( + onTap: () => Navigator.pop(context), + borderRadius: 50, + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.black45, + shape: BoxShape.circle, + ), + child: const Icon(Icons.arrow_back_rounded, color: Colors.white), + ), + ), + ), + ], + ), + ); + } + + // STANDARD PLAYER MODE (Custom HTML Overlay) return Scaffold( backgroundColor: Colors.black, body: Stack( @@ -337,10 +358,10 @@ class _PlayerScreenState extends ConsumerState { ), ), - // EPG Overlay (Moved out of controls box) + // EPG Overlay if (widget.streamType == StreamType.live) Positioned( - bottom: 140, // Positioned above the controls bar + bottom: 140, left: 40, child: EpgOverlay( streamId: widget.channels != null @@ -384,8 +405,19 @@ class _PlayerScreenState extends ConsumerState { value: _currentPosition, min: 0, max: _totalDuration, - onChanged: (v) { - _sendMessage({'type': 'seek', 'value': v}); + onChangeStart: (value) { + setState(() => _isSeeking = true); + }, + onChanged: (value) { + // Update UI immediately (optimistic update) + setState(() => _currentPosition = value); + }, + onChangeEnd: (value) { + _sendMessage({'type': 'seek', 'value': value}); + // Small delay to prevent jitter from incoming messages + Future.delayed(const Duration(milliseconds: 500), () { + if (mounted) setState(() => _isSeeking = false); + }); }, ), ), @@ -400,9 +432,23 @@ class _PlayerScreenState extends ConsumerState { Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - _buildTvControl(Icons.skip_previous_rounded, _previousChannel), - const SizedBox(width: 32), // Increased spacing - _buildTvControl(Icons.skip_next_rounded, _nextChannel), + // Channel Zapping for Live TV + if (widget.streamType == StreamType.live && widget.channels != null) ...[ + _buildTvControl(Icons.navigate_before_rounded, _previousChannel), + const SizedBox(width: 32), + _buildTvControl(Icons.navigate_next_rounded, _nextChannel), + ], + + // Controls for VOD (Keep Skip buttons, they act as Prev/Next track if playlist or maybe seek) + // The user said: "only for live tv, other buttons skip/prev must allow changing channel" + // This implies for VOD they might want seek? But existing buttons were skip_previous/skip_next. + // We keep them for VOD if channels list is present, or just hide them if not needed. + // For now, I will restore them for VOD as well if channels > 0 (playlist mode) + if (widget.streamType != StreamType.live && widget.channels != null && widget.channels!.isNotEmpty) ...[ + _buildTvControl(Icons.skip_previous_rounded, _previousChannel), + const SizedBox(width: 32), + _buildTvControl(Icons.skip_next_rounded, _nextChannel), + ], const Spacer(), _buildTvControl(Icons.subtitles_rounded, () {}), diff --git a/lib/features/iptv/widgets/lite_player_view.dart b/lib/features/iptv/widgets/lite_player_view.dart new file mode 100644 index 0000000..2adf01a --- /dev/null +++ b/lib/features/iptv/widgets/lite_player_view.dart @@ -0,0 +1,161 @@ +import 'package:flutter/material.dart'; +import 'package:video_player/video_player.dart'; +import 'package:chewie/chewie.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'dart:async'; +import '../../../core/theme/app_colors.dart'; + +class LitePlayerView extends ConsumerStatefulWidget { + final String streamUrl; + final bool isLive; + final VoidCallback? onNext; + final VoidCallback? onPrevious; + + const LitePlayerView({ + super.key, + required this.streamUrl, + required this.isLive, + this.onNext, + this.onPrevious, + }); + + @override + ConsumerState createState() => _LitePlayerViewState(); +} + +class _LitePlayerViewState extends ConsumerState { + late VideoPlayerController _videoController; + ChewieController? _chewieController; + bool _error = false; + + @override + void initState() { + super.initState(); + _initializePlayer(); + } + + @override + void didUpdateWidget(LitePlayerView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.streamUrl != widget.streamUrl) { + _disposeControllers(); + _initializePlayer(); + } + } + + Future _initializePlayer() async { + try { + setState(() => _error = false); + _videoController = VideoPlayerController.networkUrl(Uri.parse(widget.streamUrl)); + await _videoController.initialize(); + + _chewieController = ChewieController( + videoPlayerController: _videoController, + autoPlay: true, + looping: false, + isLive: widget.isLive, + aspectRatio: _videoController.value.aspectRatio > 0 ? _videoController.value.aspectRatio : 16/9, + colors: ChewieProgressColors( + playedColor: AppColors.primary, + handleColor: Colors.white, + backgroundColor: Colors.white24, + bufferedColor: Colors.white54, + ), + + // Custom Controls to include Zapping + additionalOptions: (context) { + return [ + if (widget.onNext != null) + OptionItem( + onTap: widget.onNext!, + iconData: Icons.skip_next, + title: 'Next Channel', + ), + if (widget.onPrevious != null) + OptionItem( + onTap: widget.onPrevious!, + iconData: Icons.skip_previous, + title: 'Previous Channel', + ), + ]; + }, + ); + setState(() {}); + } catch (e) { + debugPrint('Error initializing Lite Player: $e'); + setState(() => _error = true); + } + } + + void _disposeControllers() { + _videoController.dispose(); + _chewieController?.dispose(); + _chewieController = null; + } + + @override + void dispose() { + _disposeControllers(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (_error) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, color: Colors.red, size: 48), + const SizedBox(height: 16), + const Text('Error loading video', style: TextStyle(color: Colors.white)), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () { + _disposeControllers(); + _initializePlayer(); + }, + child: const Text('Retry'), + ) + ], + ), + ); + } + + if (_chewieController != null && _chewieController!.videoPlayerController.value.isInitialized) { + return Stack( + children: [ + Chewie(controller: _chewieController!), + + // Overlay Zapping Controls for easy access (since Chewie hides controls) + if (widget.onPrevious != null || widget.onNext != null) + Positioned( + bottom: 20, + right: 80, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.onPrevious != null) ...[ + IconButton( + onPressed: widget.onPrevious, + icon: const Icon(Icons.navigate_before, color: Colors.white, size: 32), + tooltip: 'Previous Channel', + ), + const SizedBox(width: 16), + ], + if (widget.onNext != null) + IconButton( + onPressed: widget.onNext, + icon: const Icon(Icons.navigate_next, color: Colors.white, size: 32), + tooltip: 'Next Channel', + ), + ], + ), + ), + ], + ); + } + + return const Center(child: CircularProgressIndicator(color: AppColors.primary)); + } +} diff --git a/lib/features/iptv/widgets/live_tv_tab.dart b/lib/features/iptv/widgets/live_tv_tab.dart index a178cab..e7cd6d0 100644 --- a/lib/features/iptv/widgets/live_tv_tab.dart +++ b/lib/features/iptv/widgets/live_tv_tab.dart @@ -380,8 +380,22 @@ class _LiveTVTabState extends ConsumerState if (epgList.isEmpty) { return Text('No Info', style: GoogleFonts.inter(color: Colors.white54, fontSize: 10)); } + final now = DateTime.now(); + final currentProgram = epgList.firstWhere( + (entry) { + try { + final start = DateTime.parse(entry.start); + final end = DateTime.parse(entry.end); + return now.isAfter(start) && now.isBefore(end); + } catch (e) { + return false; + } + }, + orElse: () => epgList.first, + ); + return Text( - epgList.first.title, + currentProgram.title, style: GoogleFonts.inter(color: const Color(0xFFFFD700), fontSize: 10, fontWeight: FontWeight.w600), maxLines: 1, overflow: TextOverflow.ellipsis, diff --git a/lib/features/iptv/widgets/streaming_settings_tab.dart b/lib/features/iptv/widgets/streaming_settings_tab.dart index ccf37c5..3813540 100644 --- a/lib/features/iptv/widgets/streaming_settings_tab.dart +++ b/lib/features/iptv/widgets/streaming_settings_tab.dart @@ -34,6 +34,27 @@ class StreamingSettingsTab extends ConsumerWidget { ), const SizedBox(height: 24), + // Player Type Section + _buildSectionCard( + context: context, + title: 'Lecteur Vidéo', + icon: Icons.play_circle_outline, + children: [ + _buildDropdownTile( + title: 'Type de lecteur', + subtitle: 'Changer si vous rencontrez des problèmes de lecture', + value: settings.playerType, + items: PlayerType.values, + onChanged: (v) => notifier.setPlayerType(v!), + labelBuilder: (v) => switch (v) { + PlayerType.standard => 'Standard (Avancé)', + PlayerType.lite => 'Lite (Compatibilité max)', + }, + ), + ], + ), + const SizedBox(height: 16), + // Quality Section _buildSectionCard( context: context,