import 'dart:async'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:flutter/services.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import '../../../focus/dpad_navigator.dart'; import '../../../focus/input_mode_tracker.dart'; import '../../../focus/key_event_utils.dart'; import '../../../i18n/strings.g.dart'; import '../../../models/livetv_channel.dart'; import '../../../models/livetv_program.dart'; import '../../../providers/multi_server_provider.dart'; import '../../../media/media_server_client.dart'; import '../../../utils/app_logger.dart'; import '../../../utils/formatters.dart'; import '../../../utils/media_image_helper.dart'; import '../../../utils/live_tv_player_navigation.dart'; import '../../../widgets/app_icon.dart'; import '../../../widgets/overlay_sheet.dart'; import '../../../widgets/optimized_media_image.dart'; import '../program_details_sheet.dart'; class GuideTab extends StatefulWidget { final List channels; final bool Function(LiveTvChannel channel)? isFavoriteChannel; final void Function(LiveTvChannel)? onToggleFavorite; final VoidCallback? onNavigateUp; final VoidCallback? onBack; const GuideTab({ super.key, required this.channels, this.isFavoriteChannel, this.onToggleFavorite, this.onNavigateUp, this.onBack, }); @override State createState() => GuideTabState(); } enum _GuideZone { timeNav, grid } class GuideTabState extends State { static const _slotWidth = 180.0; static const _channelColumnWidth = 100.0; static const _rowHeight = 64.0; static const _timeHeaderHeight = 40.0; static const _minutesPerSlot = 30; List _programs = []; bool _isLoading = true; late DateTime _gridStart; late DateTime _gridEnd; final ScrollController _headerHorizontalController = ScrollController(); final ScrollController _gridHorizontalController = ScrollController(); final ScrollController _channelVerticalController = ScrollController(); final ScrollController _gridVerticalController = ScrollController(); bool _syncingScroll = false; Timer? _timeIndicatorTimer; final _dayPickerKey = GlobalKey(); // Focus state final FocusNode _guideFocusNode = FocusNode(debugLabel: 'guide_tab'); _GuideZone _focusZone = _GuideZone.timeNav; int _timeNavIndex = 1; // 0=left arrow, 1=day picker, 2=right arrow int _gridChannelIndex = 0; int _gridColumn = 0; // 0=channel, 1=program bool _hasFocus = false; LiveTvProgram? _focusedProgram; bool _pendingFocus = false; /// Focus into the guide content (called from tab bar navigation or initial load). void focusContent() { if (!InputModeTracker.isKeyboardMode(context)) return; // If still loading programs, defer until the Focus widget is in the tree. if (_isLoading) { _pendingFocus = true; return; } _pendingFocus = false; _guideFocusNode.requestFocus(); setState(() { if (widget.channels.isNotEmpty) { _focusZone = _GuideZone.grid; _gridColumn = 0; _gridChannelIndex = 0; _focusedProgram = null; } else { _focusZone = _GuideZone.timeNav; _timeNavIndex = 1; } }); } @override void initState() { super.initState(); _initTimeRange(); _loadPrograms(); _gridHorizontalController.addListener(_syncGridToHeader); _headerHorizontalController.addListener(_syncHeaderToGrid); _timeIndicatorTimer = Timer.periodic(const Duration(minutes: 1), (_) { // ignore: no-empty-block - setState triggers rebuild to update time indicator if (mounted) setState(() {}); }); } void pauseRefresh() => _timeIndicatorTimer?.cancel(); void resumeRefresh() { _timeIndicatorTimer?.cancel(); _timeIndicatorTimer = Timer.periodic(const Duration(minutes: 1), (_) { // ignore: no-empty-block - setState triggers rebuild to update time indicator if (mounted) setState(() {}); }); } @override void didUpdateWidget(GuideTab oldWidget) { super.didUpdateWidget(oldWidget); if (widget.channels.isNotEmpty && _gridChannelIndex >= widget.channels.length) { _gridChannelIndex = widget.channels.length - 1; } } @override void dispose() { _guideFocusNode.dispose(); _gridVerticalController.dispose(); _gridHorizontalController.removeListener(_syncGridToHeader); _headerHorizontalController.removeListener(_syncHeaderToGrid); _headerHorizontalController.dispose(); _gridHorizontalController.dispose(); _channelVerticalController.dispose(); _timeIndicatorTimer?.cancel(); super.dispose(); } void _syncGridToHeader() { if (_syncingScroll) return; _syncingScroll = true; if (_headerHorizontalController.hasClients) { _headerHorizontalController.jumpTo(_gridHorizontalController.offset); } _syncingScroll = false; } void _syncHeaderToGrid() { if (_syncingScroll) return; _syncingScroll = true; if (_gridHorizontalController.hasClients) { _gridHorizontalController.jumpTo(_headerHorizontalController.offset); } _syncingScroll = false; } void _initTimeRange() { final now = DateTime.now(); _gridStart = DateTime(now.year, now.month, now.day, now.hour); if (now.minute >= 30) { _gridStart = _gridStart.add(const Duration(minutes: 30)); } _gridStart = _gridStart.subtract(const Duration(hours: 1)); _gridEnd = _gridStart.add(const Duration(hours: 6)); } void _shiftTimeRange(int hours) { setState(() { _gridStart = _gridStart.add(Duration(hours: hours)); _gridEnd = _gridStart.add(const Duration(hours: 6)); }); _loadPrograms(); } void _jumpToNow() { _initTimeRange(); _loadPrograms(); } Future _loadPrograms() async { if (!mounted) return; setState(() => _isLoading = true); try { final multiServer = context.read(); final liveTvServers = multiServer.liveTvServers; final allPrograms = []; final queriedServers = {}; for (final serverInfo in liveTvServers) { if (!queriedServers.add(serverInfo.serverId)) continue; try { final genericClient = multiServer.getClientForServer(serverInfo.serverId); if (genericClient == null) continue; final startEpoch = _gridStart.millisecondsSinceEpoch ~/ 1000; final endEpoch = _gridEnd.millisecondsSinceEpoch ~/ 1000; final fromDt = DateTime.fromMillisecondsSinceEpoch(startEpoch * 1000, isUtc: true); final toDt = DateTime.fromMillisecondsSinceEpoch(endEpoch * 1000, isUtc: true); final programs = await genericClient.liveTv.fetchSchedule(from: fromDt, to: toDt); allPrograms.addAll(programs); } catch (e) { appLogger.e('Failed to load programs from server ${serverInfo.serverId}', error: e); } } if (!mounted) return; final shouldFocus = _pendingFocus; setState(() { _programs = allPrograms; _isLoading = false; }); _scrollToNow(); if (shouldFocus) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) focusContent(); }); } } catch (e) { appLogger.e('Failed to load guide programs', error: e); if (mounted) { setState(() => _isLoading = false); } } } void _scrollToNow() { WidgetsBinding.instance.addPostFrameCallback((_) { final now = DateTime.now(); final minutesSinceStart = now.difference(_gridStart).inMinutes; final offset = (minutesSinceStart / _minutesPerSlot) * _slotWidth; if (_gridHorizontalController.hasClients) { _gridHorizontalController.jumpTo( (offset - MediaQuery.sizeOf(context).width / 3).clamp(0, _gridHorizontalController.position.maxScrollExtent), ); } }); } List _getProgramsForChannel(LiveTvChannel channel) { return _programs.where((p) => p.channelIdentifier == channel.key).toList() ..sort((a, b) => (a.beginsAt ?? 0).compareTo(b.beginsAt ?? 0)); } double _totalGridWidth() { final totalMinutes = _gridEnd.difference(_gridStart).inMinutes; return (totalMinutes / _minutesPerSlot) * _slotWidth; } Future _tuneChannel(LiveTvChannel channel) async { final multiServer = context.read(); await tuneAndNavigateToLiveTv(context, multiServer: multiServer, channel: channel, channels: widget.channels); } KeyEventResult _handleKeyEvent(FocusNode _, KeyEvent event) { final key = event.logicalKey; // Back key if (key.isBackKey) { if (BackKeyUpSuppressor.consumeIfSuppressed(event)) { return KeyEventResult.handled; } if (_focusZone == _GuideZone.grid) { if (event is KeyUpEvent) { setState(() { _focusZone = _GuideZone.timeNav; _timeNavIndex = 1; }); } return KeyEventResult.handled; } return handleBackKeyAction(event, () => widget.onBack?.call()); } if (!event.isActionable) return KeyEventResult.ignored; return _focusZone == _GuideZone.timeNav ? _handleTimeNavKey(key) : _handleGridKey(key); } KeyEventResult _handleTimeNavKey(LogicalKeyboardKey key) { if (key.isLeftKey) { if (_timeNavIndex > 0) { setState(() => _timeNavIndex--); } else { widget.onBack?.call(); } return KeyEventResult.handled; } if (key.isRightKey) { if (_timeNavIndex < 2) setState(() => _timeNavIndex++); return KeyEventResult.handled; } if (key.isDownKey) { if (widget.channels.isNotEmpty) { setState(() { _focusZone = _GuideZone.grid; _gridColumn = 0; _focusedProgram = null; }); _scrollToChannel(_gridChannelIndex); } return KeyEventResult.handled; } if (key.isUpKey) { widget.onNavigateUp?.call(); return KeyEventResult.handled; } if (key.isSelectKey) { switch (_timeNavIndex) { case 0: _shiftTimeRange(-2); case 1: _showDayPicker(); case 2: _shiftTimeRange(2); } return KeyEventResult.handled; } return KeyEventResult.ignored; } KeyEventResult _handleGridKey(LogicalKeyboardKey key) { if (key.isUpKey) { if (_gridChannelIndex > 0) { setState(() { _gridChannelIndex--; if (_gridColumn == 1) _focusedProgram = _findCurrentProgram(_gridChannelIndex); }); _scrollToChannel(_gridChannelIndex); } else { setState(() { _focusZone = _GuideZone.timeNav; _timeNavIndex = 1; }); } return KeyEventResult.handled; } if (key.isDownKey) { if (_gridChannelIndex < widget.channels.length - 1) { setState(() { _gridChannelIndex++; if (_gridColumn == 1) _focusedProgram = _findCurrentProgram(_gridChannelIndex); }); _scrollToChannel(_gridChannelIndex); } return KeyEventResult.handled; } if (key.isRightKey) { if (_gridColumn == 0) { final program = _findCurrentProgram(_gridChannelIndex); if (program != null) { setState(() { _gridColumn = 1; _focusedProgram = program; }); _scrollToProgramTime(program); } } else { // Already in program column — move to next program _navigateToAdjacentProgram(_gridChannelIndex, forward: true); } return KeyEventResult.handled; } if (key.isLeftKey) { if (_gridColumn == 1) { // Try moving to previous program; if at first program, go back to channel column if (!_navigateToAdjacentProgram(_gridChannelIndex, forward: false)) { setState(() { _gridColumn = 0; _focusedProgram = null; }); } } else { widget.onBack?.call(); } return KeyEventResult.handled; } if (key.isSelectKey) { if (_gridChannelIndex >= 0 && _gridChannelIndex < widget.channels.length) { final channel = widget.channels[_gridChannelIndex]; if (_gridColumn == 0) { _tuneChannel(channel); } else if (_focusedProgram != null) { _showProgramDetails(channel, _focusedProgram!); } } return KeyEventResult.handled; } // 'F' key toggles favorite on focused channel if (key == LogicalKeyboardKey.keyF && _gridColumn == 0) { if (_gridChannelIndex >= 0 && _gridChannelIndex < widget.channels.length) { widget.onToggleFavorite?.call(widget.channels[_gridChannelIndex]); } return KeyEventResult.handled; } return KeyEventResult.ignored; } LiveTvProgram? _findCurrentProgram(int channelIndex) { if (channelIndex < 0 || channelIndex >= widget.channels.length) return null; final channel = widget.channels[channelIndex]; final programs = _getProgramsForChannel(channel); final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; // Currently airing for (final p in programs) { if ((p.beginsAt ?? 0) <= now && (p.endsAt ?? 0) > now) return p; } // First future program for (final p in programs) { if ((p.endsAt ?? 0) > now) return p; } return programs.firstOrNull; } /// Navigate to the next or previous program on the same channel. /// Returns true if navigation succeeded, false if at the boundary. bool _navigateToAdjacentProgram(int channelIndex, {required bool forward}) { if (channelIndex < 0 || channelIndex >= widget.channels.length) return false; final channel = widget.channels[channelIndex]; final programs = _getProgramsForChannel(channel); if (programs.isEmpty || _focusedProgram == null) return false; final currentIndex = programs.indexWhere((p) => identical(p, _focusedProgram)); if (currentIndex < 0) return false; final nextIndex = forward ? currentIndex + 1 : currentIndex - 1; if (nextIndex < 0 || nextIndex >= programs.length) return false; setState(() { _focusedProgram = programs[nextIndex]; }); _scrollToProgramTime(_focusedProgram); return true; } void _scrollToChannel(int index) { if (!_gridVerticalController.hasClients) return; final targetTop = index * _rowHeight; final targetBottom = targetTop + _rowHeight; final viewportTop = _gridVerticalController.offset; final viewportBottom = viewportTop + _gridVerticalController.position.viewportDimension; double? newOffset; if (targetTop < viewportTop) { newOffset = targetTop; } else if (targetBottom > viewportBottom) { newOffset = targetBottom - _gridVerticalController.position.viewportDimension; } if (newOffset != null) { final clamped = newOffset.clamp(0.0, _gridVerticalController.position.maxScrollExtent); _gridVerticalController.animateTo(clamped, duration: const Duration(milliseconds: 150), curve: Curves.easeOut); if (_channelVerticalController.hasClients) { _channelVerticalController.animateTo( clamped.clamp(0.0, _channelVerticalController.position.maxScrollExtent), duration: const Duration(milliseconds: 150), curve: Curves.easeOut, ); } } } void _scrollToProgramTime(LiveTvProgram? program) { if (program == null || !_gridHorizontalController.hasClients) return; final gridStartEpoch = _gridStart.millisecondsSinceEpoch ~/ 1000; final gridEndEpoch = _gridEnd.millisecondsSinceEpoch ~/ 1000; final progStart = (program.beginsAt ?? gridStartEpoch).clamp(gridStartEpoch, gridEndEpoch); final startOffset = progStart - gridStartEpoch; final left = (startOffset / (_minutesPerSlot * 60)) * _slotWidth; final viewportWidth = _gridHorizontalController.position.viewportDimension; final currentOffset = _gridHorizontalController.offset; if (left < currentOffset || left > currentOffset + viewportWidth - 100) { final maxScroll = _gridHorizontalController.position.maxScrollExtent; _gridHorizontalController.jumpTo((left - 50).clamp(0.0, maxScroll)); } } @override Widget build(BuildContext context) { final theme = Theme.of(context); if (_isLoading) { return const Center(child: CircularProgressIndicator()); } return OverlaySheetHost( child: Focus( focusNode: _guideFocusNode, onFocusChange: (hasFocus) => setState(() => _hasFocus = hasFocus), onKeyEvent: _handleKeyEvent, child: _buildGuideGrid(theme), ), ); } Widget _buildGuideGrid(ThemeData theme) { return Column( children: [ _buildTimeNavigation(theme), Expanded( child: ListenableBuilder( listenable: _gridHorizontalController, builder: (context, child) { return Stack(children: [child!, _buildNowIndicatorOverlay(theme)]); }, child: Column( children: [ Row( children: [ const SizedBox(width: _channelColumnWidth, height: _timeHeaderHeight), Expanded( child: SingleChildScrollView( controller: _headerHorizontalController, scrollDirection: Axis.horizontal, physics: const ClampingScrollPhysics(), child: SizedBox( width: _totalGridWidth(), height: _timeHeaderHeight, child: _buildTimeHeader(theme), ), ), ), ], ), Expanded( child: Row( children: [ SizedBox( width: _channelColumnWidth, child: ListView.builder( controller: _channelVerticalController, physics: const NeverScrollableScrollPhysics(), itemCount: widget.channels.length, itemExtent: _rowHeight, itemBuilder: (context, index) => _buildChannelCell(widget.channels[index], theme, index: index), ), ), Expanded( child: NotificationListener( onNotification: (notification) { if (notification is ScrollUpdateNotification && notification.metrics.axis == Axis.vertical) { if (_channelVerticalController.hasClients) { _channelVerticalController.jumpTo(notification.metrics.pixels); } } return false; }, child: SingleChildScrollView( controller: _gridHorizontalController, scrollDirection: Axis.horizontal, physics: const ClampingScrollPhysics(), child: SizedBox( width: _totalGridWidth(), child: ListView.builder( controller: _gridVerticalController, itemCount: widget.channels.length, itemExtent: _rowHeight, itemBuilder: (context, index) { final channel = widget.channels[index]; final programs = _getProgramsForChannel(channel); return _buildProgramRow(channel, programs, theme, channelIndex: index); }, ), ), ), ), ), ], ), ), ], ), ), ), ], ); } Widget _buildNowIndicatorOverlay(ThemeData _) { final now = DateTime.now(); if (now.isBefore(_gridStart) || now.isAfter(_gridEnd)) { return const SizedBox.shrink(); } final minutesSinceStart = now.difference(_gridStart).inMinutes.toDouble(); final nowOffset = (minutesSinceStart / _minutesPerSlot) * _slotWidth; final scrollOffset = _gridHorizontalController.hasClients ? _gridHorizontalController.offset : 0.0; final left = _channelColumnWidth + nowOffset - scrollOffset; // Hide when scrolled behind the channel column if (left < _channelColumnWidth) return const SizedBox.shrink(); final gridHeight = _timeHeaderHeight + widget.channels.length * _rowHeight; return Positioned( left: left, top: 0, height: gridHeight, child: IgnorePointer(child: Container(width: 2, color: Colors.red)), ); } String _dayLabel(DateTime day) { final now = DateTime.now(); final today = DateTime(now.year, now.month, now.day); final target = DateTime(day.year, day.month, day.day); if (target == today) return t.liveTv.today; return DateFormat('EEEE', LocaleSettings.currentLocale.languageCode).format(target); } List<(String, int)> get _timeSlots => [ (t.liveTv.midnight, 0), (t.liveTv.overnight, 2), (t.liveTv.morning, 6), (t.liveTv.daytime, 12), (t.liveTv.evening, 18), (t.liveTv.lateNight, 22), ]; RelativeRect _menuPosition() { final renderBox = _dayPickerKey.currentContext?.findRenderObject() as RenderBox?; final overlay = Overlay.of(context).context.findRenderObject() as RenderBox?; if (renderBox == null || overlay == null) return RelativeRect.fill; final buttonPos = renderBox.localToGlobal(Offset.zero); final buttonSize = renderBox.size; return RelativeRect.fromRect( Rect.fromLTWH(buttonPos.dx, buttonPos.dy + buttonSize.height, buttonSize.width, 0), Offset.zero & overlay.size, ); } void _showDayPicker() { final now = DateTime.now(); final today = DateTime(now.year, now.month, now.day); final gridDay = DateTime(_gridStart.year, _gridStart.month, _gridStart.day); final theme = Theme.of(context); final days = []; for (var i = 0; i < 8; i++) { days.add(today.add(Duration(days: i))); } showMenu( context: context, position: _menuPosition(), items: [ PopupMenuItem( value: 'now', child: Text(t.liveTv.now, style: theme.textTheme.bodyMedium), ), ...days.map((day) { final isSelected = day == gridDay; final label = _dayLabel(day); return PopupMenuItem( value: day, child: Row( children: [ Expanded( child: Text( label, style: theme.textTheme.bodyMedium?.copyWith(color: isSelected ? theme.colorScheme.primary : null), ), ), if (isSelected) AppIcon(Symbols.check_rounded, size: 18, color: theme.colorScheme.primary), ], ), ); }), ], ).then((value) { if (value == null) { _guideFocusNode.requestFocus(); return; } if (value is String && value == 'now') { _jumpToNow(); _guideFocusNode.requestFocus(); } else if (value is DateTime) { _showTimeSlotPicker(value); } }); } void _showTimeSlotPicker(DateTime day) { final theme = Theme.of(context); final label = _dayLabel(day).toUpperCase(); showMenu( context: context, position: _menuPosition(), items: [ PopupMenuItem( value: -1, child: Row( children: [ AppIcon(Symbols.chevron_left_rounded, size: 20, color: theme.colorScheme.onSurface), const SizedBox(width: 8), Text(label, style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold)), ], ), ), const PopupMenuDivider(), ..._timeSlots.map((slot) { return PopupMenuItem( value: slot.$2, child: Text(slot.$1, style: theme.textTheme.bodyMedium), ); }), ], ).then((value) { if (value == null) { _guideFocusNode.requestFocus(); return; } if (value == -1) { _showDayPicker(); return; } setState(() { _gridStart = DateTime(day.year, day.month, day.day, value); _gridEnd = _gridStart.add(const Duration(hours: 6)); }); _loadPrograms(); _guideFocusNode.requestFocus(); }); } Widget _timeNavFocusWrap({required Widget child, required int index, required ThemeData theme}) { final isFocused = _hasFocus && _focusZone == _GuideZone.timeNav && _timeNavIndex == index; if (!isFocused) return child; return Container( decoration: BoxDecoration( color: theme.colorScheme.primary.withValues(alpha: 0.15), borderRadius: const BorderRadius.all(Radius.circular(8)), ), child: child, ); } Widget _buildTimeNavigation(ThemeData theme) { final timeLabel = formatClockTime(_gridStart, is24Hour: MediaQuery.alwaysUse24HourFormatOf(context)); final dayLabel = _dayLabel(_gridStart); return Container( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), decoration: BoxDecoration( border: Border(bottom: BorderSide(color: theme.dividerColor.withValues(alpha: 0.3))), ), child: Row( children: [ _timeNavFocusWrap( index: 0, theme: theme, child: IconButton( icon: const AppIcon(Symbols.chevron_left_rounded), onPressed: () => _shiftTimeRange(-2), iconSize: 20, visualDensity: VisualDensity.compact, ), ), Expanded( child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ _timeNavFocusWrap( index: 1, theme: theme, child: GestureDetector( key: _dayPickerKey, onTap: _showDayPicker, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), child: Row( mainAxisSize: MainAxisSize.min, children: [ Text(dayLabel, style: theme.textTheme.labelLarge), const SizedBox(width: 2), AppIcon(Symbols.arrow_drop_down_rounded, size: 18, color: theme.colorScheme.onSurface), ], ), ), ), ), const SizedBox(width: 8), Text(timeLabel, style: theme.textTheme.labelLarge), ], ), ), _timeNavFocusWrap( index: 2, theme: theme, child: IconButton( icon: const AppIcon(Symbols.chevron_right_rounded), onPressed: () => _shiftTimeRange(2), iconSize: 20, visualDensity: VisualDensity.compact, ), ), ], ), ); } Widget _buildTimeHeader(ThemeData theme) { final is24Hour = MediaQuery.alwaysUse24HourFormatOf(context); final slots = []; var current = _gridStart; while (current.isBefore(_gridEnd)) { final timeStr = formatClockTime(current, is24Hour: is24Hour); slots.add( SizedBox( width: _slotWidth, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8), child: Align( alignment: Alignment.centerLeft, child: Text( timeStr, style: theme.textTheme.labelSmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), ), ), ), ), ); current = current.add(const Duration(minutes: _minutesPerSlot)); } return Row(children: slots); } Widget _buildChannelCell(LiveTvChannel channel, ThemeData theme, {required int index}) { final multiServer = context.read(); final client = multiServer.getClientForServer(channel.serverId ?? ''); final isFocused = _hasFocus && _focusZone == _GuideZone.grid && _gridColumn == 0 && _gridChannelIndex == index; return _ChannelCell( rowHeight: _rowHeight, channelColumnWidth: _channelColumnWidth, channelThumb: channel.thumb, client: client, channel: channel, theme: theme, onTap: () => _tuneChannel(channel), onLongPress: widget.onToggleFavorite != null ? () => widget.onToggleFavorite!(channel) : null, isFocused: isFocused, isFavorite: widget.isFavoriteChannel?.call(channel) ?? false, fallbackBuilder: () => _buildChannelNameFallback(channel, theme), ); } Widget _buildChannelNameFallback(LiveTvChannel channel, ThemeData theme) { return Column( mainAxisAlignment: MainAxisAlignment.center, children: [ if (channel.number != null) Text( channel.number!, style: theme.textTheme.labelSmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), maxLines: 1, ), Text( channel.displayName, style: theme.textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w500), maxLines: 1, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, ), ], ); } Widget _buildProgramRow( LiveTvChannel channel, List programs, ThemeData theme, { required int channelIndex, }) { if (programs.isEmpty) { return Container( height: _rowHeight, decoration: BoxDecoration( border: Border(bottom: BorderSide(color: theme.dividerColor.withValues(alpha: 0.3))), ), child: Center( child: Text( t.liveTv.noPrograms, style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), ), ), ); } final blocks = []; final gridStartEpoch = _gridStart.millisecondsSinceEpoch ~/ 1000; final gridEndEpoch = _gridEnd.millisecondsSinceEpoch ~/ 1000; // Determine which program is focused in this row final focusProg = (_hasFocus && _focusZone == _GuideZone.grid && _gridColumn == 1 && _gridChannelIndex == channelIndex) ? _focusedProgram : null; for (final program in programs) { final progStart = (program.beginsAt ?? gridStartEpoch).clamp(gridStartEpoch, gridEndEpoch); final progEnd = (program.endsAt ?? gridEndEpoch).clamp(gridStartEpoch, gridEndEpoch); if (progEnd <= progStart) continue; final startOffset = progStart - gridStartEpoch; final duration = progEnd - progStart; final left = (startOffset / (_minutesPerSlot * 60)) * _slotWidth; final width = (duration / (_minutesPerSlot * 60)) * _slotWidth; final clampedWidth = width.clamp(2.0, double.infinity); blocks.add( Positioned( left: left, width: clampedWidth, top: 0, bottom: 0, child: _buildProgramBlock( channel, program, theme, isFirst: progStart == gridStartEpoch, isLast: program == programs.last && progEnd != gridEndEpoch, isFocused: identical(program, focusProg), tileLeft: left, tileWidth: clampedWidth, ), ), ); } return Container( height: _rowHeight, decoration: BoxDecoration( border: Border(bottom: BorderSide(color: theme.dividerColor.withValues(alpha: 0.3))), ), child: Stack(children: blocks), ); } Widget _buildProgramBlock( LiveTvChannel channel, LiveTvProgram program, ThemeData theme, { bool isFirst = false, bool isLast = false, bool isFocused = false, double tileLeft = 0, double tileWidth = 0, }) { final isCurrentlyAiring = program.isCurrentlyAiring; final isPast = program.endsAt != null && program.endsAt! < DateTime.now().millisecondsSinceEpoch ~/ 1000; Color materialColor; if (isFocused) { materialColor = theme.colorScheme.primary.withValues(alpha: 0.15); } else if (isCurrentlyAiring) { materialColor = theme.colorScheme.onSurface.withValues(alpha: 0.12); } else { materialColor = theme.colorScheme.onSurface.withValues(alpha: 0.05); } Color titleColor; if (isFocused) { titleColor = theme.colorScheme.primary; } else if (isCurrentlyAiring) { titleColor = theme.colorScheme.onSurface; } else { titleColor = theme.colorScheme.onSurface; } Color subtitleColor; if (isFocused) { subtitleColor = theme.colorScheme.primary.withValues(alpha: 0.7); } else if (isCurrentlyAiring) { subtitleColor = theme.colorScheme.onSurfaceVariant; } else { subtitleColor = theme.colorScheme.onSurfaceVariant; } return Opacity( opacity: isPast ? 0.5 : 1.0, child: Material( color: isFocused ? materialColor : Colors.transparent, shape: RoundedRectangleBorder( side: isFocused ? BorderSide(color: theme.colorScheme.primary, width: 2) : BorderSide.none, ), child: InkWell( canRequestFocus: false, onTap: () => _showProgramDetails(channel, program), child: Container( decoration: BoxDecoration( border: Border( left: isFirst ? BorderSide.none : BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)), right: isLast ? BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)) : BorderSide.none, ), ), child: ListenableBuilder( listenable: _gridHorizontalController, builder: (context, _) { const basePadding = 6.0; final scrollOffset = _gridHorizontalController.hasClients ? _gridHorizontalController.offset : 0.0; final maxInset = (tileWidth - 2 * basePadding - 20).clamp(0.0, double.infinity); final leftInset = (scrollOffset - tileLeft).clamp(0.0, maxInset); return Container( color: isFocused ? null : materialColor, padding: EdgeInsets.fromLTRB(basePadding + leftInset, 4, basePadding, 4), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: [ Text( program.grandparentTitle ?? program.title, style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600, color: titleColor), maxLines: 1, overflow: TextOverflow.ellipsis, ), if (program.grandparentTitle != null) Text( '${program.parentIndex != null && program.index != null ? 'S${program.parentIndex}E${program.index} · ' : ''}${program.title}', style: theme.textTheme.labelSmall?.copyWith(color: subtitleColor), maxLines: 1, overflow: TextOverflow.ellipsis, ), if (program.startTime != null) Text( '${formatClockTime(program.startTime!, is24Hour: MediaQuery.alwaysUse24HourFormatOf(context))} · ${formatDurationTextual(program.durationMinutes * 60000)}', style: theme.textTheme.labelSmall?.copyWith(color: subtitleColor), maxLines: 1, overflow: TextOverflow.ellipsis, ), ], ), ); }, ), ), ), ), ); } void _showProgramDetails(LiveTvChannel channel, LiveTvProgram program) { final multiServer = context.read(); final client = multiServer.getClientForServer(channel.serverId ?? ''); String? posterUrl; if (program.thumb != null && client != null) { posterUrl = MediaImageHelper.getOptimizedImageUrl( client: client, thumbPath: program.thumb, maxWidth: 80, maxHeight: 120, devicePixelRatio: MediaImageHelper.effectiveDevicePixelRatio(context), imageType: ImageType.poster, ); } showProgramDetailsSheet( context, program: program, channel: channel, posterUrl: posterUrl, onTuneChannel: () => _tuneChannel(channel), ); } } class _ChannelCell extends StatefulWidget { final double rowHeight; final double channelColumnWidth; final String? channelThumb; final MediaServerClient? client; final LiveTvChannel channel; final ThemeData theme; final VoidCallback onTap; final VoidCallback? onLongPress; final bool isFocused; final bool isFavorite; final Widget Function() fallbackBuilder; const _ChannelCell({ required this.rowHeight, required this.channelColumnWidth, required this.channelThumb, required this.client, required this.channel, required this.theme, required this.onTap, this.onLongPress, required this.isFocused, this.isFavorite = false, required this.fallbackBuilder, }); @override State<_ChannelCell> createState() => _ChannelCellState(); } class _ChannelCellState extends State<_ChannelCell> { bool _hovered = false; @override Widget build(BuildContext context) { final theme = widget.theme; final showAction = _hovered || widget.isFocused; return MouseRegion( onEnter: (_) => setState(() => _hovered = true), onExit: (_) => setState(() => _hovered = false), child: GestureDetector( onSecondaryTap: widget.onLongPress, child: Material( color: widget.isFocused ? theme.colorScheme.primary.withValues(alpha: 0.15) : Colors.transparent, child: InkWell( canRequestFocus: false, onTap: widget.onTap, onLongPress: widget.onLongPress, child: Container( height: widget.rowHeight, padding: const EdgeInsets.symmetric(horizontal: 8), decoration: BoxDecoration( border: Border( bottom: BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)), right: BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)), ), ), child: Stack( alignment: Alignment.center, children: [ AnimatedOpacity( opacity: showAction ? 0.3 : 1.0, duration: const Duration(milliseconds: 150), child: widget.channelThumb != null && widget.client != null ? OptimizedMediaImage.thumb( client: widget.client!, imagePath: widget.channelThumb, width: widget.channelColumnWidth - 16, height: widget.rowHeight - 16, fit: BoxFit.contain, ) : widget.fallbackBuilder(), ), if (showAction) AppIcon(Symbols.play_arrow_rounded, size: 32, color: theme.colorScheme.onSurface), if (widget.isFavorite) Positioned( top: 2, right: 0, child: AppIcon(Symbols.star_rounded, size: 14, color: theme.colorScheme.primary), ), ], ), ), ), ), ), ); } }