import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'dart:html' as html; import 'dart:ui_web' as ui_web; import 'dart:async'; import 'package:google_fonts/google_fonts.dart'; import 'package:pointer_interceptor/pointer_interceptor.dart'; import '../providers/xtream_provider.dart'; import '../providers/playback_positions_provider.dart'; import '../widgets/quality_selector_widget.dart'; // ... (existing imports) // ... (existing imports) import '../../../core/models/playlist_config.dart'; import '../../../core/widgets/glass_container.dart'; import '../../../core/theme/app_colors.dart'; enum StreamType { live, vod, series, recording } class PlayerScreen extends ConsumerStatefulWidget { final String streamId; final String title; final PlaylistConfig playlist; final StreamType streamType; final String containerExtension; final List? channels; final double? startTime; final Duration? duration; const PlayerScreen({ super.key, required this.streamId, required this.title, required this.playlist, this.streamType = StreamType.live, this.containerExtension = 'mp4', this.channels, this.startTime, this.duration, }); @override ConsumerState createState() => _PlayerScreenState(); } class _PlayerScreenState extends ConsumerState { bool _isPlaying = true; bool _showControls = false; Timer? _controlsTimer; double _currentPosition = 0; double _totalDuration = 1; int _currentIndex = 0; bool _isInitialized = false; bool _isLoading = true; StreamSubscription? _messageSubscription; bool _isSeeking = false; late final String _viewIdPrefix = DateTime.now().millisecondsSinceEpoch.toString(); String _viewId = 'iptv-player'; bool _isMuted = false; bool _ignoreStatusUpdates = false; /// Server-side transcoding quality. Live TV defaults to `source` /// (direct MPEG-TS proxy, zero transcoding); VOD defaults to `high`. late StreamQuality _quality = widget.streamType == StreamType.live ? StreamQuality.source : StreamQuality.high; @override void initState() { super.initState(); if (widget.channels != null) { _currentIndex = widget.channels!.indexWhere((c) { try { return (c.streamId ?? c.id ?? '').toString() == widget.streamId; } catch (_) { return false; } }); if (_currentIndex == -1) _currentIndex = 0; } // Show controls at start, then auto-hide after timeout _showControls = true; _onHover(); // Start the auto-hide timer _initializePlayer(startTimeOverride: widget.startTime); _setupMessageListener(); } @override void dispose() { _messageSubscription?.cancel(); _controlsTimer?.cancel(); super.dispose(); } Future _initializePlayer({ double? startTimeOverride, bool isChannelSwitch = false, }) async { setState(() { _isLoading = true; }); try { // Determine Stream ID (Initial or Current Index) String currentStreamId = widget.streamId; if (isChannelSwitch && widget.channels != null) { try { final item = widget.channels![_currentIndex]; currentStreamId = (item.streamId ?? item.id ?? '').toString(); } catch (_) {} } // Stable view ID for the instance to prevent iframe reload on rebuild // (orientation change). Includes quality so a quality switch forces a // fresh iframe. _viewId = 'iptv-player-$currentStreamId-${_quality.value}-$_viewIdPrefix'; final service = ref.read(xtreamServiceProvider(widget.playlist)); String streamUrl = ''; // Generate Stream URL based on type // NOTE: Logic is now handled by the backend routes /api/live/ and /api/vod/ if (widget.streamType == StreamType.live) { streamUrl = service.getLiveStreamUrl( currentStreamId, quality: _quality.value, ); } else if (widget.streamType == StreamType.vod) { streamUrl = service.getVodStreamUrl( currentStreamId, widget.containerExtension, quality: _quality.value, ); } else if (widget.streamType == StreamType.series) { streamUrl = service.getSeriesStreamUrl( currentStreamId, widget.containerExtension, quality: _quality.value, ); } else if (widget.streamType == StreamType.recording) { streamUrl = '${service.backendBaseUrl}/api/recordings/stream/$currentStreamId/playlist.m3u8'; } // Store URL for Lite Player final isMobile = MediaQuery.of(context).size.width < 600; final isLiveTV = widget.streamType == StreamType.live; // TURBO-START: in `source` quality, Live TV on desktop uses the direct // MPEG-TS proxy (mpegts.js) for instant zapping with zero transcoding. if (isLiveTV && !isMobile && _quality == StreamQuality.source) { streamUrl = service.getLiveStreamUrlTs(currentStreamId); } // STABILIZATION: Lock background requests during stream init service.isPlaybackLoading = true; Future.delayed(const Duration(seconds: 10), () { service.isPlaybackLoading = false; }); final encodedUrl = Uri.encodeComponent(streamUrl); // Force player choice based on stream type: // - Live TV: Player Lite (simple TS playback with mpegts.js) // - VOD/Series: Player Standard (fuller controls, HLS support) // Cache buster to force reload of updated player.html final cacheBuster = DateTime.now().millisecondsSinceEpoch; final streamTypeParam = widget.streamType == StreamType.live ? 'live' : 'vod'; final playerFile = isMobile ? 'player_mobile.html' : (isLiveTV ? 'player_lite.html' : 'player.html'); // Inject Turbo-Start flag var playerSrc = '$playerFile?url=$encodedUrl&type=$streamTypeParam&turbo=true&v=$cacheBuster'; if (widget.streamType == StreamType.recording) { playerSrc += '&is_recording=true'; } if (startTimeOverride != null) { playerSrc += '&t=$startTimeOverride'; } if (widget.duration != null) { playerSrc += '&duration=${widget.duration!.inSeconds}'; } // 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 ..src = playerSrc ..style.border = 'none' ..style.width = '100%' ..style.height = '100%' ..allow = 'autoplay; fullscreen; picture-in-picture; encrypted-media'; return iframe; }); if (mounted) { setState(() { _isInitialized = true; _isLoading = false; }); // Force unmute on load (after a short delay to ensure iframe is ready) Future.delayed(const Duration(milliseconds: 500), () { if (mounted) { _sendMessage({'type': 'set_volume', 'value': 1.0}); } }); } } catch (e) { print('[PlayerScreen] Failed to load stream: $e'); if (mounted) { setState(() { _isLoading = false; }); } } } void _setupMessageListener() { _messageSubscription = html.window.onMessage.listen((event) { // The player iframes are same-origin; drop messages from anywhere else. if (event.origin != html.window.location.origin) return; 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.streamId, currentTime, duration, ); } } else if (type == 'playback_status') { if (!_ignoreStatusUpdates) { setState(() => _isPlaying = data['status'] == 'playing'); } } else if (type == 'user_activity') { _onHover(); } }); } void _sendMessage(Map message) { if (!_isInitialized) return; // Try finding by ID first var iframe = html.document.getElementById(_viewId) as html.IFrameElement?; // If not found, try finding by src pattern (fallback for Shadow DOM or ID issues) if (iframe == null) { final iframes = html.document.getElementsByTagName('iframe'); for (final frame in iframes) { if (frame is html.IFrameElement && (frame.src?.contains('player_lite.html') == true || frame.src?.contains('player.html') == true || frame.src?.contains('player_mobile.html') == true)) { iframe = frame; break; } } } if (iframe != null) { iframe.contentWindow ?.postMessage(message, html.window.location.origin); } else { print( '[PlayerScreen] Error: Could not find iframe to send message: $message', ); } } void _onHover() { setState(() => _showControls = true); _controlsTimer?.cancel(); _controlsTimer = Timer(const Duration(seconds: 4), () { if (mounted && _isPlaying) setState(() => _showControls = false); }); } void _togglePlayPause() { // Optimistic update to make UI responsive immediately setState(() => _isPlaying = !_isPlaying); // Ignore incoming status updates for 2 seconds to prevent fighting _ignoreStatusUpdates = true; Future.delayed(const Duration(seconds: 2), () { if (mounted) _ignoreStatusUpdates = false; }); if (_isPlaying) { _sendMessage({'type': 'play'}); } else { _sendMessage({'type': 'pause'}); } } void _toggleFullscreen() { try { final doc = html.document; final isFullscreen = doc.fullscreenElement != null || (doc as dynamic).webkitFullscreenElement != null || (doc as dynamic).mozFullScreenElement != null; if (isFullscreen) { doc.exitFullscreen(); } else { final element = doc.documentElement; if (element != null) { element.requestFullscreen(); } } } catch (e) { print('[PlayerScreen] Fullscreen error: $e'); _sendMessage({'type': 'request_fullscreen'}); } } void _toggleMute() { setState(() => _isMuted = !_isMuted); _sendMessage({'type': 'set_volume', 'value': _isMuted ? 0.0 : 1.0}); } void _changeQuality(StreamQuality quality) { if (quality == _quality) return; setState(() => _quality = quality); // Rebuild the iframe with the new quality; VOD resumes at the // current position, live restarts at the live edge. final resumeAt = widget.streamType == StreamType.live ? null : _currentPosition; _initializePlayer(startTimeOverride: resumeAt, isChannelSwitch: true); } 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); } } /// Keyboard / TV remote controls: /// space-enter = play/pause, ←/→ = seek ±10s (VOD) or zap (live), /// ↑/↓ = zap (live), M = mute, Esc = exit player. KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) { if (event is! KeyDownEvent) return KeyEventResult.ignored; final isLive = widget.streamType == StreamType.live; final key = event.logicalKey; if (key == LogicalKeyboardKey.space || key == LogicalKeyboardKey.enter || key == LogicalKeyboardKey.select || key == LogicalKeyboardKey.mediaPlayPause) { _togglePlayPause(); _onHover(); return KeyEventResult.handled; } if (key == LogicalKeyboardKey.escape) { Navigator.pop(context); return KeyEventResult.handled; } if (key == LogicalKeyboardKey.keyM) { _toggleMute(); _onHover(); return KeyEventResult.handled; } if (key == LogicalKeyboardKey.arrowLeft) { if (isLive) { _previousChannel(); } else { _sendMessage({ 'type': 'seek', 'value': (_currentPosition - 10).clamp(0, _totalDuration), }); } _onHover(); return KeyEventResult.handled; } if (key == LogicalKeyboardKey.arrowRight) { if (isLive) { _nextChannel(); } else { _sendMessage({ 'type': 'seek', 'value': (_currentPosition + 10).clamp(0, _totalDuration), }); } _onHover(); return KeyEventResult.handled; } if (isLive && key == LogicalKeyboardKey.arrowUp) { _nextChannel(); _onHover(); return KeyEventResult.handled; } if (isLive && key == LogicalKeyboardKey.arrowDown) { _previousChannel(); _onHover(); return KeyEventResult.handled; } return KeyEventResult.ignored; } 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 isLiveTV = widget.streamType == StreamType.live; // LITE PLAYER MODE for Live TV - Simplified UI (no BackdropFilter) if (isLiveTV) { return Focus( autofocus: true, onKeyEvent: _onKeyEvent, child: Scaffold( backgroundColor: AppColors.background, body: Stack( fit: StackFit.expand, children: [ // Video Player if (_isInitialized) HtmlElementView(viewType: _viewId), // Control Overlay (sits on top of video, catches all pointer events) Positioned.fill( child: PointerInterceptor( child: MouseRegion( onHover: (_) => _onHover(), child: GestureDetector( behavior: HitTestBehavior.translucent, onTap: () { if (_showControls) { setState(() => _showControls = false); } else { _onHover(); } }, child: Container( color: Colors.transparent, child: Stack( children: [ // Top Bar AnimatedPositioned( duration: const Duration(milliseconds: 200), top: _showControls ? 0 : -100, left: 0, right: 0, child: Container( padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 12, ), decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ AppColors.background.withOpacity(0.8), Colors.transparent, ], ), ), child: SafeArea( bottom: false, child: Row( children: [ _buildSimpleIconButton( icon: Icons.arrow_back_rounded, onTap: () => Navigator.pop(context), tooltip: 'Retour', ), const SizedBox(width: 16), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( widget.channels != null ? (widget .channels![ _currentIndex] .name ?? widget .channels![ _currentIndex] .title ?? 'Unknown') .toString() : widget.title, style: GoogleFonts.outfit( color: AppColors.textPrimary, fontSize: 18, fontWeight: FontWeight.w600, ), overflow: TextOverflow.ellipsis, ), Text( 'Live TV', style: GoogleFonts.inter( color: AppColors.primary, fontSize: 12, fontWeight: FontWeight.bold, ), ), ], ), ), // Top right mute button removed ], ), ), ), ), // Unified Bottom Bar - EPG + Controls in one bar AnimatedPositioned( duration: const Duration(milliseconds: 200), bottom: _showControls ? 24 : -150, // More offset for stacked layout left: 16, right: 16, child: LayoutBuilder( builder: (context, constraints) { final isSmallScreen = constraints.maxWidth < 600; return Container( padding: EdgeInsets.symmetric( horizontal: isSmallScreen ? 16 : 20, vertical: isSmallScreen ? 12 : 16, ), decoration: BoxDecoration( color: AppColors.surface.withOpacity(0.95), borderRadius: BorderRadius.circular(24), boxShadow: [ BoxShadow( color: AppColors.background .withOpacity(0.3), blurRadius: 10, spreadRadius: 2, ), ], border: Border.all( color: AppColors.textPrimary .withOpacity(0.15), ), ), child: isSmallScreen ? Column( mainAxisSize: MainAxisSize.min, children: [ // EPG Info (top in mobile) _buildInlineEpg(), const SizedBox(height: 12), const Divider( color: Colors.white10, ), const SizedBox(height: 8), // Control Buttons Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: _buildControlButtons( isSmallScreen, ), ), ], ) : Row( children: [ // EPG Info (left side) Expanded( child: _buildInlineEpg(), ), const SizedBox(width: 24), // Control Buttons (right side) Row( mainAxisSize: MainAxisSize.min, children: _buildControlButtons( isSmallScreen, ), ), ], ), ); }, ), ), ], ), ), ), ), ), ), ], ), ), ); } // STANDARD PLAYER MODE (VOD/Series) return Focus( autofocus: true, onKeyEvent: _onKeyEvent, child: Scaffold( backgroundColor: Colors.black, body: Stack( fit: StackFit.expand, children: [ if (_isInitialized) HtmlElementView(viewType: _viewId), // User interaction area Positioned.fill( child: PointerInterceptor( child: MouseRegion( onHover: (_) => _onHover(), child: GestureDetector( onTap: () { if (_showControls) { setState(() => _showControls = false); } else { _onHover(); } }, behavior: HitTestBehavior.translucent, child: Container(color: Colors.transparent), ), ), ), ), // Controls Overlay if (_showControls || _isLoading) Positioned.fill( child: PointerInterceptor( child: Container( color: Colors.black.withOpacity(0.4), child: Stack( children: [ // Top Bar Positioned( top: 24, left: 24, right: 24, child: GlassContainer.glass( height: 72, borderRadius: 24, padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( children: [ _buildGlassIconButton( icon: Icons.arrow_back_rounded, onTap: () => Navigator.pop(context), tooltip: 'Retour', ), const SizedBox(width: 16), Expanded( child: Text( widget.title, style: GoogleFonts.outfit( fontSize: 20, fontWeight: FontWeight.w600, color: Colors.white, ), ), ), ], ), ), ), // Center Loading Indicator (only show when loading) if (_isLoading) const Center( child: CircularProgressIndicator( color: AppColors.primary, ), ), // Bottom Controls Positioned( bottom: 40, left: 40, right: 40, child: GlassContainer.glass( borderRadius: 24, borderColor: Colors.white.withOpacity(0.1), padding: const EdgeInsets.all(24), child: Column( mainAxisSize: MainAxisSize.min, children: [ // Progress Row( children: [ Text( _formatDuration( Duration( seconds: _currentPosition.toInt(), ), ), style: GoogleFonts.inter( color: Colors.white70, ), ), const SizedBox(width: 16), Expanded( child: SliderTheme( data: SliderThemeData( trackHeight: 4, activeTrackColor: AppColors.primary, inactiveTrackColor: Colors.white.withOpacity(0.2), thumbColor: Colors.white, thumbShape: const RoundSliderThumbShape( enabledThumbRadius: 8, ), overlayColor: AppColors.primary.withOpacity(0.2), ), child: Slider( value: _currentPosition, min: 0, max: _totalDuration, onChanged: (val) => setState( () => _currentPosition = val, ), onChangeStart: (_) => setState(() => _isSeeking = true), onChangeEnd: (val) { _sendMessage( {'type': 'seek', 'value': val}, ); Future.delayed( const Duration(milliseconds: 500), () { if (mounted) { setState( () => _isSeeking = false, ); } }); }, ), ), ), const SizedBox(width: 16), Text( _formatDuration( Duration( seconds: _totalDuration.toInt(), ), ), style: GoogleFonts.inter( color: Colors.white70, ), ), ], ), const SizedBox(height: 12), // Buttons Row( mainAxisAlignment: MainAxisAlignment.center, children: [ _buildGlassIconButton( icon: Icons.replay_10_rounded, onTap: () => _sendMessage({ 'type': 'seek', 'value': (_currentPosition - 10) .clamp(0, _totalDuration), }), transparent: true, tooltip: 'Reculer de 10 s', ), const SizedBox(width: 24), _buildGlassIconButton( icon: _isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded, onTap: _togglePlayPause, size: 56, iconSize: 32, tooltip: _isPlaying ? 'Pause' : 'Lecture', ), const SizedBox(width: 24), _buildGlassIconButton( icon: Icons.forward_10_rounded, onTap: () => _sendMessage({ 'type': 'seek', 'value': (_currentPosition + 10) .clamp(0, _totalDuration), }), transparent: true, tooltip: 'Avancer de 10 s', ), const Spacer(), QualitySelectorButton( current: _quality, onSelected: _changeQuality, ), const SizedBox(width: 16), _buildGlassIconButton( icon: _isMuted ? Icons.volume_off_rounded : Icons.volume_up_rounded, onTap: _toggleMute, transparent: true, tooltip: _isMuted ? 'Activer le son' : 'Couper le son', ), const SizedBox(width: 16), _buildGlassIconButton( icon: Icons.aspect_ratio_rounded, onTap: _toggleFullscreen, transparent: true, tooltip: 'Plein écran', ), ], ), ], ), ), ), ], ), ), ), ), ], ), ), ); } Widget _buildGlassIconButton({ required IconData icon, required VoidCallback onTap, bool transparent = false, double size = 48, double iconSize = 24, String? tooltip, }) { // Button with direct Material/InkWell - PointerInterceptor is on outer container final button = Material( color: transparent ? Colors.transparent : Colors.white.withOpacity(0.1), shape: const CircleBorder(), child: InkWell( onTap: onTap, customBorder: const CircleBorder(), child: Container( width: size, height: size, alignment: Alignment.center, decoration: BoxDecoration( shape: BoxShape.circle, border: transparent ? null : Border.all(color: Colors.white.withOpacity(0.1)), ), child: Icon(icon, color: Colors.white, size: iconSize), ), ), ); return tooltip != null ? Tooltip(message: tooltip, child: button) : button; } List _buildControlButtons(bool isSmallScreen) { final buttonSize = isSmallScreen ? 40.0 : 44.0; final playButtonSize = isSmallScreen ? 48.0 : 52.0; final spacing = isSmallScreen ? 8.0 : 16.0; return [ // Previous Channel if (widget.channels != null && widget.channels!.length > 1) _buildSimpleIconButton( icon: Icons.skip_previous_rounded, onTap: _previousChannel, size: buttonSize, tooltip: 'Chaîne précédente', ), if (widget.channels != null && widget.channels!.length > 1) SizedBox(width: spacing), // Play/Pause (Large) _buildSimpleIconButton( icon: _isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded, onTap: _togglePlayPause, size: playButtonSize, iconSize: isSmallScreen ? 24 : 28, highlighted: true, tooltip: _isPlaying ? 'Pause' : 'Lecture', ), if (widget.channels != null && widget.channels!.length > 1) SizedBox(width: spacing), // Next Channel if (widget.channels != null && widget.channels!.length > 1) _buildSimpleIconButton( icon: Icons.skip_next_rounded, onTap: _nextChannel, size: buttonSize, tooltip: 'Chaîne suivante', ), SizedBox(width: spacing), // Mute button _buildSimpleIconButton( icon: _isMuted ? Icons.volume_off_rounded : Icons.volume_up_rounded, onTap: _toggleMute, size: buttonSize, tooltip: _isMuted ? 'Activer le son' : 'Couper le son', ), SizedBox(width: spacing), // Quality selector QualitySelectorButton( current: _quality, onSelected: _changeQuality, size: buttonSize, ), ]; } // Simple Icon Button for Live TV player (no BackdropFilter issues) Widget _buildSimpleIconButton({ required IconData icon, required VoidCallback onTap, double size = 48, double iconSize = 24, bool highlighted = false, String? tooltip, }) { final button = Material( color: highlighted ? AppColors.primary.withOpacity(0.2) : Colors.white.withOpacity(0.1), shape: const CircleBorder(), child: InkWell( onTap: onTap, customBorder: const CircleBorder(), splashColor: AppColors.primary.withOpacity(0.3), highlightColor: AppColors.primary.withOpacity(0.1), child: Container( width: size, height: size, alignment: Alignment.center, decoration: BoxDecoration( shape: BoxShape.circle, border: Border.all( color: highlighted ? AppColors.primary.withOpacity(0.5) : Colors.white.withOpacity(0.2), width: highlighted ? 2 : 1, ), ), child: Icon( icon, color: highlighted ? AppColors.primary : Colors.white, size: iconSize, ), ), ), ); return tooltip != null ? Tooltip(message: tooltip, child: button) : button; } /// Build inline EPG info for the unified control bar Widget _buildInlineEpg() { final currentStreamId = widget.channels != null ? widget.channels![_currentIndex].streamId : widget.streamId; return Consumer( builder: (context, ref, child) { final epgAsync = ref.watch( epgByPlaylistProvider( EpgRequestKey(playlist: widget.playlist, streamId: currentStreamId), ), ); return epgAsync.when( data: (epgEntries) { if (epgEntries.isEmpty) { // No EPG data - show channel name only return Row( children: [ Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: Colors.red, borderRadius: BorderRadius.circular(4), ), child: const Text( 'LIVE', style: TextStyle( color: Colors.white, fontSize: 11, fontWeight: FontWeight.bold, ), ), ), const SizedBox(width: 12), Expanded( child: Text( widget.channels != null ? widget.channels![_currentIndex].name : widget.title, style: GoogleFonts.outfit( color: Colors.white, fontSize: 16, fontWeight: FontWeight.w600, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ), ], ); } // Get current program final now = DateTime.now(); final currentProgram = epgEntries.firstWhere( (entry) { final start = DateTime.parse(entry.start); final end = DateTime.parse(entry.end); return now.isAfter(start) && now.isBefore(end); }, orElse: () => epgEntries.first, ); return Row( children: [ Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: Colors.red, borderRadius: BorderRadius.circular(4), ), child: const Text( 'LIVE', style: TextStyle( color: Colors.white, fontSize: 11, fontWeight: FontWeight.bold, ), ), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( currentProgram.title, style: GoogleFonts.outfit( color: Colors.white, fontSize: 15, fontWeight: FontWeight.w600, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), if (currentProgram.description.isNotEmpty) Text( currentProgram.description, style: GoogleFonts.inter( color: Colors.white60, fontSize: 12, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ], ), ), ], ); }, loading: () => Row( children: [ Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: Colors.red, borderRadius: BorderRadius.circular(4), ), child: const Text( 'LIVE', style: TextStyle( color: Colors.white, fontSize: 11, fontWeight: FontWeight.bold, ), ), ), const SizedBox(width: 12), Text( widget.channels != null ? widget.channels![_currentIndex].name : widget.title, style: GoogleFonts.outfit( color: Colors.white, fontSize: 16, fontWeight: FontWeight.w600, ), ), ], ), error: (_, __) => Text( widget.channels != null ? widget.channels![_currentIndex].name : widget.title, style: GoogleFonts.outfit( color: Colors.white, fontSize: 16, fontWeight: FontWeight.w600, ), ), ); }, ); } }