From 2e2bd2b0ad076231d2e1103022a7624cff3b775e Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Sun, 7 Dec 2025 10:15:08 +0100 Subject: [PATCH] feat: add IPTV player screen supporting live, VOD, and series streams via HTML iframe, and update dependencies. --- lib/features/iptv/screens/player_screen.dart | 159 ++++++++++++++++--- pubspec.yaml | 1 + web/player.html | 63 ++++++++ 3 files changed, 201 insertions(+), 22 deletions(-) diff --git a/lib/features/iptv/screens/player_screen.dart b/lib/features/iptv/screens/player_screen.dart index 962f0a1..3f43661 100644 --- a/lib/features/iptv/screens/player_screen.dart +++ b/lib/features/iptv/screens/player_screen.dart @@ -11,7 +11,9 @@ import '../providers/settings_provider.dart'; import '../widgets/epg_overlay.dart'; import '../../../core/models/playlist_config.dart'; import '../../../core/models/iptv_models.dart'; + import '../../../core/widgets/themed_loading_screen.dart'; +import 'package:pointer_interceptor/pointer_interceptor.dart'; /// Stream type enum for player enum StreamType { live, vod, series } @@ -53,6 +55,10 @@ class _PlayerScreenState extends ConsumerState { bool _showControls = false; Timer? _controlsTimer; StreamSubscription? _messageSubscription; + + // Premium Features State + List> _audioTracks = []; + String _aspectRatio = 'contain'; @override void initState() { @@ -78,9 +84,13 @@ class _PlayerScreenState extends ConsumerState { _contentId, currentTime, duration ); } else if (type == 'playback_ended' && _contentId.isNotEmpty) { - ref.read(playbackPositionsProvider.notifier).clearPosition(_contentId); } else if (type == 'user_activity') { _onHover(); + } else if (type == 'audio_tracks') { + final tracks = List>.from(data['tracks']); + setState(() { + _audioTracks = tracks; + }); } } } catch (e) { @@ -89,6 +99,24 @@ class _PlayerScreenState extends ConsumerState { }); } + void _sendMessage(Map message) { + // Helper to send message to iframe + final iframe = html.document.getElementById(_viewId) as html.IFrameElement?; + iframe?.contentWindow?.postMessage(message, '*'); + } + + void _setAspectRatio(String mode) { + setState(() { + _aspectRatio = mode; + }); + _sendMessage({'type': 'set_aspect_ratio', 'value': mode}); + } + + void _setAudioTrack(int index) { + _sendMessage({'type': 'set_audio_track', 'index': index}); + Navigator.pop(context); // Close settings + } + @override void dispose() { _messageSubscription?.cancel(); @@ -262,6 +290,75 @@ class _PlayerScreenState extends ConsumerState { } } + void _showSettingsDialog() { + showDialog( + context: context, + builder: (context) => PointerInterceptor( + child: AlertDialog( + backgroundColor: const Color(0xFF1E1E1E), + title: const Text('Réglages du Lecteur', style: TextStyle(color: Colors.white)), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Format d\'image', style: TextStyle(color: Colors.white70, fontSize: 13, fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: [ + _buildAspectRatioChip('Original', 'contain'), + _buildAspectRatioChip('Remplir', 'cover'), + _buildAspectRatioChip('Étirer', 'fill'), + ], + ), + if (_audioTracks.isNotEmpty) ...[ + const SizedBox(height: 24), + const Text('Pistes Audio', style: TextStyle(color: Colors.white70, fontSize: 13, fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + Container( + constraints: const BoxConstraints(maxHeight: 200), + child: SingleChildScrollView( + child: Column( + children: _audioTracks.map((track) { + return ListTile( + title: Text(track['label'] ?? 'Piste ${track['id']}', style: const TextStyle(color: Colors.white)), + subtitle: Text(track['lang'] ?? '', style: const TextStyle(color: Colors.white54)), + onTap: () => _setAudioTrack(track['id']), + dense: true, + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.audiotrack, color: Colors.white54), + ); + }).toList(), + ), + ), + ), + ], + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Fermer'), + ), + ], + ), + ), + ); + } + + Widget _buildAspectRatioChip(String label, String value) { + final isSelected = _aspectRatio == value; + return ChoiceChip( + label: Text(label), + selected: isSelected, + onSelected: (_) => _setAspectRatio(value), + backgroundColor: Colors.black26, + selectedColor: Theme.of(context).primaryColor, + labelStyle: TextStyle(color: isSelected ? Colors.white : Colors.white70), + side: BorderSide.none, + ); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -272,6 +369,16 @@ class _PlayerScreenState extends ConsumerState { backgroundColor: Colors.transparent, foregroundColor: Colors.white, elevation: 0, + actions: [ + PointerInterceptor( + child: IconButton( + icon: const Icon(Icons.settings), + onPressed: _showSettingsDialog, + tooltip: 'Réglages', + ), + ), + const SizedBox(width: 16), + ], ) : null, extendBodyBehindAppBar: true, backgroundColor: Colors.black, @@ -312,34 +419,40 @@ class _PlayerScreenState extends ConsumerState { // Hover Controls Layer if (_showControls) ...[ - // Previous Channel (Left Zone) + // Previous Channel (Left Top Zone) if (widget.channels != null && widget.channels!.isNotEmpty) Positioned( left: 0, - top: 0, - bottom: 0, + top: 80, // Moved to top + bottom: null, + height: 200, width: 100, - child: InkWell( - onTap: _playPrevious, - hoverColor: Colors.black12, - child: const Center( - child: Icon(Icons.arrow_back_ios, color: Colors.white, size: 48), + child: PointerInterceptor( + child: InkWell( + onTap: _playPrevious, + hoverColor: Colors.black12, + child: const Center( + child: Icon(Icons.arrow_back_ios, color: Colors.white, size: 48), + ), ), ), ), - // Next Channel (Right Zone) + // Next Channel (Right Top Zone) if (widget.channels != null && widget.channels!.isNotEmpty) Positioned( right: 0, - top: 0, - bottom: 0, + top: 80, // Moved to top + bottom: null, + height: 200, width: 100, - child: InkWell( - onTap: _playNext, - hoverColor: Colors.black12, - child: const Center( - child: Icon(Icons.arrow_forward_ios, color: Colors.white, size: 48), + child: PointerInterceptor( + child: InkWell( + onTap: _playNext, + hoverColor: Colors.black12, + child: const Center( + child: Icon(Icons.arrow_forward_ios, color: Colors.white, size: 48), + ), ), ), ), @@ -359,11 +472,13 @@ class _PlayerScreenState extends ConsumerState { colors: [Colors.black87, Colors.transparent], ), ), - child: EpgOverlay( - streamId: widget.channels != null - ? widget.channels![_currentIndex].streamId - : widget.streamId, - playlist: widget.playlist, + child: PointerInterceptor( + child: EpgOverlay( + streamId: widget.channels != null + ? widget.channels![_currentIndex].streamId + : widget.streamId, + playlist: widget.playlist, + ), ), ), ), diff --git a/pubspec.yaml b/pubspec.yaml index cec62f9..5c1d714 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -38,6 +38,7 @@ dependencies: # UI Components google_fonts: ^6.2.1 cached_network_image: ^3.3.1 + pointer_interceptor: ^0.10.1 # Security crypto: ^3.0.3 diff --git a/web/player.html b/web/player.html index fa8321e..460badd 100644 --- a/web/player.html +++ b/web/player.html @@ -166,6 +166,67 @@ }, '*'); }); + // Listen for commands from parent (Flutter) + window.addEventListener('message', (event) => { + const data = event.data; + if (!data) return; + + if (data.type === 'set_aspect_ratio') { + log('Setting aspect ratio: ' + data.value); + // value: 'contain', 'cover', 'fill' (fill = 100% 100%) + if (data.value === 'fill') { + video.style.objectFit = 'fill'; + } else if (data.value === 'cover') { + video.style.objectFit = 'cover'; + } else { + video.style.objectFit = 'contain'; + } + } else if (data.type === 'set_audio_track') { + // Not standard in HTML5 Video API across all browsers universally for MP4 + // But HLS.js and mpegts.js have specific APIs. + // For native "video.audioTracks", it is experimental. + // We will try to handle HLS.js switching if active. + log('Switching audio track index: ' + data.index); + if (window.hlsInstance) { + window.hlsInstance.audioTrack = data.index; + } + } + }); + + // Report available audio tracks + function reportAudioTracks() { + let tracks = []; + if (window.hlsInstance && window.hlsInstance.audioTracks) { + tracks = window.hlsInstance.audioTracks.map((t, i) => ({ + id: i, + label: t.name || t.lang || ('Track ' + (i + 1)), + lang: t.lang || '' + })); + } else if (video.audioTracks) { + // Standard HTML5 (Safari/experimental) + for (let i = 0; i < video.audioTracks.length; i++) { + const t = video.audioTracks[i]; + tracks.push({ + id: i, + label: t.label || t.language || ('Track ' + (i + 1)), + lang: t.language || '' + }); + } + } + + if (tracks.length > 0) { + window.parent.postMessage({ + type: 'audio_tracks', + tracks: tracks + }, '*'); + } + } + + // Hook reporting into playback start + video.addEventListener('loadedmetadata', () => { + setTimeout(reportAudioTracks, 1000); // Wait a bit for tracks to parse + }); + // Report user activity (mouse movement) to parent let lastActivityTime = 0; document.addEventListener('mousemove', () => { @@ -259,6 +320,8 @@ xhrSetup: function (xhr, url) { } }); + window.hlsInstance = hls; // Expose for external control (audio tracks) + hls.loadSource(url); hls.attachMedia(video);