diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index 4ccd740a..f76292cd 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -558,7 +558,15 @@ "premiere": "NEW", "reloadGuide": "Reload Guide", "guideReloaded": "Guide data reloaded", - "allChannels": "All Channels" + "allChannels": "All Channels", + "now": "Now", + "today": "Today", + "midnight": "Midnight", + "overnight": "Overnight", + "morning": "Morning", + "daytime": "Daytime", + "evening": "Evening", + "lateNight": "Late Night" }, "collections": { "title": "Collections", diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index c9c4f43c..7431dbde 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 9 -/// Strings: 6480 (720 per locale) +/// Strings: 6488 (720 per locale) /// -/// Built on 2026-02-11 at 15:19 UTC +/// Built on 2026-02-12 at 12:55 UTC // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index 20438c99..b59f5ff7 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -1728,6 +1728,30 @@ class TranslationsLiveTvEn { /// en: 'All Channels' String get allChannels => 'All Channels'; + + /// en: 'Now' + String get now => 'Now'; + + /// en: 'Today' + String get today => 'Today'; + + /// en: 'Midnight' + String get midnight => 'Midnight'; + + /// en: 'Overnight' + String get overnight => 'Overnight'; + + /// en: 'Morning' + String get morning => 'Morning'; + + /// en: 'Daytime' + String get daytime => 'Daytime'; + + /// en: 'Evening' + String get evening => 'Evening'; + + /// en: 'Late Night' + String get lateNight => 'Late Night'; } // Path: collections @@ -3149,6 +3173,14 @@ extension on Translations { 'liveTv.reloadGuide' => 'Reload Guide', 'liveTv.guideReloaded' => 'Guide data reloaded', 'liveTv.allChannels' => 'All Channels', + 'liveTv.now' => 'Now', + 'liveTv.today' => 'Today', + 'liveTv.midnight' => 'Midnight', + 'liveTv.overnight' => 'Overnight', + 'liveTv.morning' => 'Morning', + 'liveTv.daytime' => 'Daytime', + 'liveTv.evening' => 'Evening', + 'liveTv.lateNight' => 'Late Night', 'collections.title' => 'Collections', 'collections.collection' => 'Collection', 'collections.empty' => 'Collection is empty', @@ -3158,6 +3190,8 @@ extension on Translations { 'collections.deleted' => 'Collection deleted', 'collections.deleteFailed' => 'Failed to delete collection', 'collections.deleteFailedWithError' => ({required Object error}) => 'Failed to delete collection: ${error}', + _ => null, + } ?? switch (path) { 'collections.failedToLoadItems' => ({required Object error}) => 'Failed to load collection items: ${error}', 'collections.selectCollection' => 'Select Collection', 'collections.createNewCollection' => 'Create New Collection', @@ -3166,8 +3200,6 @@ extension on Translations { 'collections.addedToCollection' => 'Added to collection', 'collections.errorAddingToCollection' => 'Failed to add to collection', 'collections.created' => 'Collection created', - _ => null, - } ?? switch (path) { 'collections.removeFromCollection' => 'Remove from collection', 'collections.removeFromCollectionConfirm' => ({required Object title}) => 'Remove "${title}" from this collection?', 'collections.removedFromCollection' => 'Removed from collection', diff --git a/lib/models/livetv_program.dart b/lib/models/livetv_program.dart index 827725cd..9ef7275c 100644 --- a/lib/models/livetv_program.dart +++ b/lib/models/livetv_program.dart @@ -43,6 +43,10 @@ class LiveTvProgram { }); factory LiveTvProgram.fromJson(Map json) { + // Grid endpoint nests timing/channel info inside Media[0] and Channel[0] + final media = (json['Media'] as List?)?.firstOrNull as Map?; + final channel = (json['Channel'] as List?)?.firstOrNull as Map?; + return LiveTvProgram( key: json['key'] as String?, ratingKey: json['ratingKey'] as String?, @@ -51,16 +55,19 @@ class LiveTvProgram { summary: json['summary'] as String?, type: json['type'] as String?, year: (json['year'] as num?)?.toInt(), - beginsAt: (json['beginsAt'] as num?)?.toInt(), - endsAt: (json['endsAt'] as num?)?.toInt(), + beginsAt: (json['beginsAt'] as num?)?.toInt() ?? (media?['beginsAt'] as num?)?.toInt(), + endsAt: (json['endsAt'] as num?)?.toInt() ?? (media?['endsAt'] as num?)?.toInt(), grandparentTitle: json['grandparentTitle'] as String?, parentTitle: json['parentTitle'] as String?, index: (json['index'] as num?)?.toInt(), parentIndex: (json['parentIndex'] as num?)?.toInt(), - thumb: json['thumb'] as String?, + thumb: json['thumb'] as String? ?? json['grandparentThumb'] as String?, art: json['art'] as String?, - channelIdentifier: json['channelIdentifier'] as String?, - channelCallSign: json['channelCallSign'] as String?, + channelIdentifier: json['channelIdentifier'] as String? + ?? media?['channelIdentifier']?.toString() + ?? channel?['id']?.toString(), + channelCallSign: json['channelCallSign'] as String? + ?? media?['channelCallSign'] as String?, live: json['live'] == true || json['live'] == 1 || json['live'] == '1', premiere: json['premiere'] == true || json['premiere'] == 1 || json['premiere'] == '1', ); diff --git a/lib/screens/livetv/epg_guide_screen.dart b/lib/screens/livetv/epg_guide_screen.dart deleted file mode 100644 index 8ab5fef0..00000000 --- a/lib/screens/livetv/epg_guide_screen.dart +++ /dev/null @@ -1,623 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:material_symbols_icons/symbols.dart'; -import 'package:provider/provider.dart'; - -import '../../i18n/strings.g.dart'; -import '../../models/livetv_channel.dart'; -import '../../models/livetv_program.dart'; -import '../../providers/multi_server_provider.dart'; -import '../../utils/app_logger.dart'; -import '../../utils/formatters.dart'; -import '../../utils/plex_url_helper.dart'; -import '../../utils/live_tv_player_navigation.dart'; -import '../../widgets/app_icon.dart'; - -/// EPG (Electronic Program Guide) screen with a time-based grid -class EpgGuideScreen extends StatefulWidget { - const EpgGuideScreen({super.key}); - - @override - State createState() => _EpgGuideScreenState(); -} - -class _EpgGuideScreenState extends State { - static const _slotWidth = 180.0; - static const _channelColumnWidth = 140.0; - static const _rowHeight = 64.0; - static const _timeHeaderHeight = 40.0; - static const _minutesPerSlot = 30; - - List _channels = []; - List _programs = []; - bool _isLoading = true; - String? _error; - - // Time range: 6 hours centered on current time - late DateTime _gridStart; - late DateTime _gridEnd; - - final ScrollController _headerHorizontalController = ScrollController(); - final ScrollController _gridHorizontalController = ScrollController(); - final ScrollController _channelVerticalController = ScrollController(); - bool _syncingScroll = false; - - Timer? _timeIndicatorTimer; - - @override - void initState() { - super.initState(); - _initTimeRange(); - _loadData(); - - // Sync horizontal scroll: grid → header - _gridHorizontalController.addListener(_syncGridToHeader); - // Sync horizontal scroll: header → grid - _headerHorizontalController.addListener(_syncHeaderToGrid); - - // Update time indicator every minute - _timeIndicatorTimer = Timer.periodic(const Duration(minutes: 1), (_) { - if (mounted) setState(() {}); - }); - } - - 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; - } - - @override - void dispose() { - _gridHorizontalController.removeListener(_syncGridToHeader); - _headerHorizontalController.removeListener(_syncHeaderToGrid); - _headerHorizontalController.dispose(); - _gridHorizontalController.dispose(); - _channelVerticalController.dispose(); - _timeIndicatorTimer?.cancel(); - super.dispose(); - } - - void _initTimeRange() { - final now = DateTime.now(); - // Start 1 hour before, rounded to nearest 30 min - _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)); - } - - Future _loadData() async { - if (!mounted) return; - setState(() { - _isLoading = true; - _error = null; - }); - - try { - final multiServer = context.read(); - final liveTvServers = multiServer.liveTvServers; - - if (liveTvServers.isEmpty) { - setState(() { - _isLoading = false; - _error = t.liveTv.noDvr; - }); - return; - } - - final allChannels = []; - final allPrograms = []; - - for (final serverInfo in liveTvServers) { - final client = multiServer.getClientForServer(serverInfo.serverId); - if (client == null) continue; - - final channels = await client.getEpgChannels(lineup: serverInfo.lineup); - allChannels.addAll(channels); - - final startEpoch = _gridStart.millisecondsSinceEpoch ~/ 1000; - final endEpoch = _gridEnd.millisecondsSinceEpoch ~/ 1000; - - final programs = await client.getEpgGrid( - lineup: serverInfo.lineup, - beginsAt: startEpoch, - endsAt: endEpoch, - ); - allPrograms.addAll(programs); - } - - // Sort channels by number - allChannels.sort((a, b) { - final aNum = double.tryParse(a.number ?? '') ?? 999999; - final bNum = double.tryParse(b.number ?? '') ?? 999999; - return aNum.compareTo(bNum); - }); - - if (!mounted) return; - setState(() { - _channels = allChannels; - _programs = allPrograms; - _isLoading = false; - }); - - // Scroll to current time - _scrollToNow(); - } catch (e) { - appLogger.e('Failed to load EPG data', error: e); - if (mounted) { - setState(() { - _isLoading = false; - _error = e.toString(); - }); - } - } - } - - 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.of(context).size.width / 3).clamp(0, _gridHorizontalController.position.maxScrollExtent), - ); - } - }); - } - - /// Get programs for a specific channel - List _getProgramsForChannel(LiveTvChannel channel) { - final channelId = channel.identifier ?? channel.key; - return _programs.where((p) => p.channelIdentifier == channelId).toList() - ..sort((a, b) => (a.beginsAt ?? 0).compareTo(b.beginsAt ?? 0)); - } - - double _totalGridWidth() { - final totalMinutes = _gridEnd.difference(_gridStart).inMinutes; - return (totalMinutes / _minutesPerSlot) * _slotWidth; - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return Scaffold( - appBar: AppBar( - title: Text(t.liveTv.guide), - actions: [ - IconButton( - icon: const AppIcon(Symbols.refresh_rounded), - tooltip: t.liveTv.reloadGuide, - onPressed: _loadData, - ), - ], - ), - body: _isLoading - ? const Center(child: CircularProgressIndicator()) - : _error != null - ? Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text(_error!, style: theme.textTheme.bodyLarge), - const SizedBox(height: 16), - FilledButton.icon( - onPressed: _loadData, - icon: const AppIcon(Symbols.refresh_rounded), - label: Text(t.common.retry), - ), - ], - ), - ) - : _channels.isEmpty - ? Center(child: Text(t.liveTv.noChannels)) - : _buildGuideGrid(theme), - ); - } - - Widget _buildGuideGrid(ThemeData theme) { - return Column( - children: [ - // Time header - Row( - children: [ - // Empty corner cell - SizedBox(width: _channelColumnWidth, height: _timeHeaderHeight), - // Scrollable time slots - Expanded( - child: SingleChildScrollView( - controller: _headerHorizontalController, - scrollDirection: Axis.horizontal, - child: SizedBox( - width: _totalGridWidth(), - height: _timeHeaderHeight, - child: _buildTimeHeader(theme), - ), - ), - ), - ], - ), - // Channel rows + program grid - Expanded( - child: Row( - children: [ - // Fixed channel column - SizedBox( - width: _channelColumnWidth, - child: ListView.builder( - controller: _channelVerticalController, - itemCount: _channels.length, - itemExtent: _rowHeight, - itemBuilder: (context, index) => _buildChannelCell(_channels[index], theme), - ), - ), - // Scrollable program grid - Expanded( - child: NotificationListener( - onNotification: (notification) { - // Sync vertical scroll from grid to channel column - 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, - child: SizedBox( - width: _totalGridWidth(), - child: ListView.builder( - itemCount: _channels.length, - itemExtent: _rowHeight, - itemBuilder: (context, index) { - final channel = _channels[index]; - final programs = _getProgramsForChannel(channel); - return _buildProgramRow(channel, programs, theme); - }, - ), - ), - ), - ), - ), - ], - ), - ), - ], - ); - } - - Widget _buildTimeHeader(ThemeData theme) { - final slots = []; - var current = _gridStart; - - while (current.isBefore(_gridEnd)) { - final timeStr = '${current.hour.toString().padLeft(2, '0')}:${current.minute.toString().padLeft(2, '0')}'; - 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 Stack( - children: [ - Row(children: slots), - // Current time indicator - _buildNowIndicator(theme), - ], - ); - } - - Widget _buildNowIndicator(ThemeData theme) { - final now = DateTime.now(); - if (now.isBefore(_gridStart) || now.isAfter(_gridEnd)) { - return const SizedBox.shrink(); - } - final minutesSinceStart = now.difference(_gridStart).inMinutes.toDouble(); - final offset = (minutesSinceStart / _minutesPerSlot) * _slotWidth; - - return Positioned( - left: offset, - top: 0, - bottom: 0, - child: Container( - width: 2, - color: Colors.red, - ), - ); - } - - Widget _buildChannelCell(LiveTvChannel channel, ThemeData theme) { - final multiServer = context.read(); - final client = multiServer.getClientForServer(channel.serverId ?? ''); - - return Container( - height: _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: Row( - children: [ - if (channel.thumb != null && client != null) - ClipRRect( - borderRadius: BorderRadius.circular(3), - child: Image.network( - '${client.config.baseUrl}${channel.thumb}'.withPlexToken(client.config.token), - width: 28, - height: 28, - fit: BoxFit.contain, - errorBuilder: (_, _, _) => const SizedBox(width: 28), - ), - ) - else - const AppIcon(Symbols.live_tv_rounded, size: 28), - const SizedBox(width: 6), - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - 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, - ), - ], - ), - ), - ], - ), - ); - } - - Widget _buildProgramRow(LiveTvChannel channel, List programs, ThemeData theme) { - 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; - - 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; - - blocks.add( - Positioned( - left: left, - width: width.clamp(2.0, double.infinity), - top: 2, - bottom: 2, - child: _buildProgramBlock(channel, program, theme), - ), - ); - } - - return Container( - height: _rowHeight, - decoration: BoxDecoration( - border: Border( - bottom: BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)), - ), - ), - child: Stack( - children: [ - ...blocks, - _buildNowIndicator(theme), - ], - ), - ); - } - - Widget _buildProgramBlock(LiveTvChannel channel, LiveTvProgram program, ThemeData theme) { - final isCurrentlyAiring = program.isCurrentlyAiring; - - return Material( - color: isCurrentlyAiring - ? theme.colorScheme.primaryContainer - : theme.colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(4), - child: InkWell( - borderRadius: BorderRadius.circular(4), - onTap: () => _showProgramDetails(channel, program), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - program.title, - style: theme.textTheme.bodySmall?.copyWith( - fontWeight: isCurrentlyAiring ? FontWeight.w600 : FontWeight.normal, - color: isCurrentlyAiring - ? theme.colorScheme.onPrimaryContainer - : theme.colorScheme.onSurface, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - if (program.startTime != null) - Text( - '${program.startTime!.hour.toString().padLeft(2, '0')}:${program.startTime!.minute.toString().padLeft(2, '0')} · ${formatDurationTextual(program.durationMinutes * 60000)}', - style: theme.textTheme.labelSmall?.copyWith( - color: isCurrentlyAiring - ? theme.colorScheme.onPrimaryContainer.withValues(alpha: 0.7) - : theme.colorScheme.onSurfaceVariant, - ), - maxLines: 1, - ), - ], - ), - ), - ), - ); - } - - void _showProgramDetails(LiveTvChannel channel, LiveTvProgram program) { - final theme = Theme.of(context); - - showModalBottomSheet( - context: context, - builder: (sheetContext) { - return Padding( - padding: const EdgeInsets.all(20), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text( - program.displayTitle, - style: theme.textTheme.titleMedium, - ), - ), - if (program.isCurrentlyAiring) - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: Colors.red, - borderRadius: BorderRadius.circular(4), - ), - child: Text( - t.liveTv.live, - style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 11), - ), - ), - ], - ), - const SizedBox(height: 4), - Text( - '${channel.displayName} · ${program.startTime?.hour.toString().padLeft(2, '0')}:${program.startTime?.minute.toString().padLeft(2, '0')} - ${program.endTime?.hour.toString().padLeft(2, '0')}:${program.endTime?.minute.toString().padLeft(2, '0')} · ${formatDurationTextual(program.durationMinutes * 60000)}', - style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), - ), - if (program.summary != null && program.summary!.isNotEmpty) ...[ - const SizedBox(height: 12), - Text( - program.summary!, - style: theme.textTheme.bodyMedium, - maxLines: 4, - overflow: TextOverflow.ellipsis, - ), - ], - const SizedBox(height: 16), - Row( - children: [ - if (program.isCurrentlyAiring) - FilledButton.icon( - onPressed: () { - Navigator.of(sheetContext).pop(); - _tuneToChannel(channel); - }, - icon: const AppIcon(Symbols.play_arrow_rounded), - label: Text(t.common.play), - ), - const SizedBox(width: 8), - OutlinedButton.icon( - onPressed: () { - Navigator.of(sheetContext).pop(); - // TODO: Record action - }, - icon: const AppIcon(Symbols.fiber_manual_record_rounded), - label: Text(t.liveTv.record), - ), - ], - ), - ], - ), - ); - }, - ); - } - - Future _tuneToChannel(LiveTvChannel channel) async { - final multiServer = context.read(); - - // Find the DVR server info matching this channel's serverId - final serverInfo = multiServer.liveTvServers.where( - (s) => s.serverId == channel.serverId, - ).firstOrNull ?? multiServer.liveTvServers.firstOrNull; - - if (serverInfo == null) return; - - final client = multiServer.getClientForServer(serverInfo.serverId); - if (client == null) return; - - await navigateToLiveTv( - context, - client: client, - dvrKey: serverInfo.dvrKey, - channel: channel, - channels: _channels, - ); - } -} diff --git a/lib/screens/livetv/live_tv_screen.dart b/lib/screens/livetv/live_tv_screen.dart index 9cbc056f..2e6edb35 100644 --- a/lib/screens/livetv/live_tv_screen.dart +++ b/lib/screens/livetv/live_tv_screen.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; @@ -7,10 +9,11 @@ import '../../models/livetv_channel.dart'; import '../../models/livetv_program.dart'; import '../../providers/multi_server_provider.dart'; import '../../utils/app_logger.dart'; +import '../../utils/formatters.dart'; +import '../../utils/plex_image_helper.dart'; import '../../utils/plex_url_helper.dart'; import '../../utils/live_tv_player_navigation.dart'; import '../../widgets/app_icon.dart'; -import 'epg_guide_screen.dart'; import 'dvr_recordings_screen.dart'; class LiveTvScreen extends StatefulWidget { @@ -21,18 +24,114 @@ class LiveTvScreen extends StatefulWidget { } class _LiveTvScreenState extends State { + static const _slotWidth = 180.0; + static const _channelColumnWidth = 140.0; + static const _rowHeight = 64.0; + static const _timeHeaderHeight = 40.0; + static const _minutesPerSlot = 30; + List _channels = []; - Map _nowPlaying = {}; + List _programs = []; bool _isLoading = true; String? _error; + late DateTime _gridStart; + late DateTime _gridEnd; + + final ScrollController _headerHorizontalController = ScrollController(); + final ScrollController _gridHorizontalController = ScrollController(); + final ScrollController _channelVerticalController = ScrollController(); + bool _syncingScroll = false; + + Timer? _timeIndicatorTimer; + final _dayPickerKey = GlobalKey(); + @override void initState() { super.initState(); - _loadChannels(); + _initTimeRange(); + _loadData(); + + _gridHorizontalController.addListener(_syncGridToHeader); + _headerHorizontalController.addListener(_syncHeaderToGrid); + + _timeIndicatorTimer = Timer.periodic(const Duration(minutes: 1), (_) { + if (mounted) setState(() {}); + }); } - Future _loadChannels() async { + 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; + } + + @override + void dispose() { + _gridHorizontalController.removeListener(_syncGridToHeader); + _headerHorizontalController.removeListener(_syncHeaderToGrid); + _headerHorizontalController.dispose(); + _gridHorizontalController.dispose(); + _channelVerticalController.dispose(); + _timeIndicatorTimer?.cancel(); + super.dispose(); + } + + 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)); + }); + _loadData(); + } + + void _jumpToNow() { + _initTimeRange(); + _loadData(); + } + + void _jumpToDay(DateTime day) { + final now = DateTime.now(); + final isToday = day.year == now.year && + day.month == now.month && + day.day == now.day; + + if (isToday) { + _jumpToNow(); + return; + } + + setState(() { + // Start at midnight for non-today days + _gridStart = DateTime(day.year, day.month, day.day); + _gridEnd = _gridStart.add(const Duration(hours: 6)); + }); + _loadData(); + } + + Future _loadData() async { if (!mounted) return; setState(() { _isLoading = true; @@ -52,6 +151,7 @@ class _LiveTvScreenState extends State { } final allChannels = []; + final allPrograms = []; for (final serverInfo in liveTvServers) { final client = multiServer.getClientForServer(serverInfo.serverId); @@ -59,9 +159,18 @@ class _LiveTvScreenState extends State { final channels = await client.getEpgChannels(lineup: serverInfo.lineup); allChannels.addAll(channels); + + final startEpoch = _gridStart.millisecondsSinceEpoch ~/ 1000; + final endEpoch = _gridEnd.millisecondsSinceEpoch ~/ 1000; + + final programs = await client.getEpgGrid( + lineup: serverInfo.lineup, + beginsAt: startEpoch, + endsAt: endEpoch, + ); + allPrograms.addAll(programs); } - // Sort channels by number allChannels.sort((a, b) { final aNum = double.tryParse(a.number ?? '') ?? 999999; final bNum = double.tryParse(b.number ?? '') ?? 999999; @@ -70,15 +179,25 @@ class _LiveTvScreenState extends State { if (!mounted) return; - // Load "now playing" data - await _loadNowPlaying(allChannels); + appLogger.d('EPG loaded: ${allChannels.length} channels, ${allPrograms.length} programs'); + if (allChannels.isNotEmpty) { + final ch = allChannels.first; + appLogger.d('Sample channel: key=${ch.key}, identifier=${ch.identifier}'); + } + if (allPrograms.isNotEmpty) { + final p = allPrograms.first; + appLogger.d('Sample program: channelIdentifier=${p.channelIdentifier}, title=${p.title}'); + } setState(() { _channels = allChannels; + _programs = allPrograms; _isLoading = false; }); + + _scrollToNow(); } catch (e) { - appLogger.e('Failed to load Live TV channels', error: e); + appLogger.e('Failed to load Live TV data', error: e); if (mounted) { setState(() { _isLoading = false; @@ -88,44 +207,38 @@ class _LiveTvScreenState extends State { } } - Future _loadNowPlaying(List channels) async { - final multiServer = context.read(); - final nowPlaying = {}; - - for (final serverInfo in multiServer.liveTvServers) { - final client = multiServer.getClientForServer(serverInfo.serverId); - if (client == null) continue; - - try { - final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; - final programs = await client.getEpgGrid( - lineup: serverInfo.lineup, - beginsAt: now - 7200, // 2 hours before - endsAt: now + 7200, // 2 hours after + 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.of(context).size.width / 3) + .clamp(0, _gridHorizontalController.position.maxScrollExtent), ); - - for (final program in programs) { - if (program.isCurrentlyAiring && program.channelIdentifier != null) { - nowPlaying[program.channelIdentifier!] = program; - } - } - } catch (e) { - appLogger.d('Failed to load now playing data', error: e); } - } + }); + } - if (mounted) { - setState(() => _nowPlaying = nowPlaying); - } + List _getProgramsForChannel(LiveTvChannel channel) { + final channelId = channel.identifier ?? channel.key; + return _programs.where((p) => p.channelIdentifier == channelId).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(); - // Find the DVR server info matching this channel's serverId - final serverInfo = multiServer.liveTvServers.where( - (s) => s.serverId == channel.serverId, - ).firstOrNull ?? multiServer.liveTvServers.firstOrNull; + final serverInfo = multiServer.liveTvServers + .where((s) => s.serverId == channel.serverId) + .firstOrNull ?? + multiServer.liveTvServers.firstOrNull; if (serverInfo == null) return; @@ -141,12 +254,6 @@ class _LiveTvScreenState extends State { ); } - void _openGuide() { - Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const EpgGuideScreen()), - ); - } - void _openRecordings() { Navigator.of(context).push( MaterialPageRoute(builder: (_) => const DvrRecordingsScreen()), @@ -162,9 +269,9 @@ class _LiveTvScreenState extends State { title: Text(t.liveTv.title), actions: [ IconButton( - icon: const AppIcon(Symbols.menu_book_rounded), - tooltip: t.liveTv.guide, - onPressed: _openGuide, + icon: const AppIcon(Symbols.refresh_rounded), + tooltip: t.liveTv.reloadGuide, + onPressed: _loadData, ), IconButton( icon: const AppIcon(Symbols.fiber_dvr_rounded), @@ -180,12 +287,13 @@ class _LiveTvScreenState extends State { child: Column( mainAxisSize: MainAxisSize.min, children: [ - AppIcon(Symbols.error_rounded, size: 48, color: theme.colorScheme.error), + AppIcon(Symbols.error_rounded, + size: 48, color: theme.colorScheme.error), const SizedBox(height: 16), Text(_error!, style: theme.textTheme.bodyLarge), const SizedBox(height: 16), FilledButton.icon( - onPressed: _loadChannels, + onPressed: _loadData, icon: const AppIcon(Symbols.refresh_rounded), label: Text(t.common.retry), ), @@ -194,266 +302,676 @@ class _LiveTvScreenState extends State { ) : _channels.isEmpty ? Center(child: Text(t.liveTv.noChannels)) - : RefreshIndicator( - onRefresh: _loadChannels, - child: _buildChannelList(theme), - ), + : _buildGuideGrid(theme), ); } - Widget _buildChannelList(ThemeData theme) { - // Build "What's On Now" section + channel list - final currentlyAiring = _channels.where((ch) { - final id = ch.identifier ?? ch.key; - return _nowPlaying.containsKey(id) && _nowPlaying[id] != null; - }).toList(); - - return CustomScrollView( - slivers: [ - // "What's On Now" section - if (currentlyAiring.isNotEmpty) ...[ - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Text(t.liveTv.whatsOnNow, style: theme.textTheme.titleMedium), - ), - ), - SliverToBoxAdapter( - child: SizedBox( - height: 140, - child: ListView.builder( + Widget _buildGuideGrid(ThemeData theme) { + return Column( + children: [ + // Time navigation bar + _buildTimeNavigation(theme), + // Time header + Row( + children: [ + SizedBox(width: _channelColumnWidth, height: _timeHeaderHeight), + Expanded( + child: SingleChildScrollView( + controller: _headerHorizontalController, scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 12), - itemCount: currentlyAiring.length, - itemBuilder: (context, index) { - final channel = currentlyAiring[index]; - final id = channel.identifier ?? channel.key; - final program = _nowPlaying[id]!; - return _buildNowPlayingCard(channel, program, theme); - }, - ), - ), - ), - ], - - // All channels header - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Text(t.liveTv.allChannels, style: theme.textTheme.titleMedium), - ), - ), - - // Channel grid - SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 12), - sliver: SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) => _buildChannelTile( - _channels[index], - theme, - ), - childCount: _channels.length, - ), - ), - ), - - const SliverToBoxAdapter(child: SizedBox(height: 80)), - ], - ); - } - - Widget _buildNowPlayingCard(LiveTvChannel channel, LiveTvProgram program, ThemeData theme) { - final client = context.read().getClientForServer(channel.serverId ?? ''); - - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 4), - child: SizedBox( - width: 280, - child: Card( - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: () => _tuneChannel(channel), - child: Padding( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - if (channel.thumb != null && client != null) - ClipRRect( - borderRadius: BorderRadius.circular(4), - child: Image.network( - '${client.config.baseUrl}${channel.thumb}'.withPlexToken(client.config.token), - width: 32, - height: 32, - fit: BoxFit.contain, - errorBuilder: (_, _, _) => const SizedBox(width: 32, height: 32), - ), - ) - else - const AppIcon(Symbols.live_tv_rounded, size: 32), - const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - channel.displayName, - style: theme.textTheme.titleSmall, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - if (channel.number != null) - Text( - t.liveTv.channelNumber(number: channel.number!), - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - _LiveBadge(), - ], - ), - const SizedBox(height: 8), - Text( - program.title, - style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - if (program.grandparentTitle != null) - Text( - program.grandparentTitle!, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const Spacer(), - // Progress bar - LinearProgressIndicator( - value: program.progress, - backgroundColor: theme.colorScheme.surfaceContainerHighest, - ), - ], - ), - ), - ), - ), - ), - ); - } - - Widget _buildChannelTile(LiveTvChannel channel, ThemeData theme) { - final id = channel.identifier ?? channel.key; - final program = _nowPlaying[id]; - final client = context.read().getClientForServer(channel.serverId ?? ''); - - return ListTile( - leading: SizedBox( - width: 48, - height: 48, - child: channel.thumb != null && client != null - ? ClipRRect( - borderRadius: BorderRadius.circular(4), - child: Image.network( - '${client.config.baseUrl}${channel.thumb}'.withPlexToken(client.config.token), - fit: BoxFit.contain, - errorBuilder: (_, _, _) => const Center(child: AppIcon(Symbols.live_tv_rounded)), - ), - ) - : const Center(child: AppIcon(Symbols.live_tv_rounded)), - ), - title: Row( - children: [ - if (channel.number != null) ...[ - SizedBox( - width: 48, - child: Text( - channel.number!, - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w500, + physics: const ClampingScrollPhysics(), + child: SizedBox( + width: _totalGridWidth(), + height: _timeHeaderHeight, + child: _buildTimeHeader(theme), ), ), ), ], - Expanded( - child: Text( - channel.displayName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - if (channel.hd) - Padding( - padding: const EdgeInsets.only(left: 4), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), - decoration: BoxDecoration( - border: Border.all(color: theme.colorScheme.outline.withValues(alpha: 0.5)), - borderRadius: BorderRadius.circular(3), + ), + // Channel rows + program grid + Expanded( + child: Row( + children: [ + SizedBox( + width: _channelColumnWidth, + child: ListView.builder( + controller: _channelVerticalController, + itemCount: _channels.length, + itemExtent: _rowHeight, + itemBuilder: (context, index) => + _buildChannelCell(_channels[index], theme), ), - child: Text( - t.liveTv.hd, - style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - fontSize: 9, + ), + 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( + itemCount: _channels.length, + itemExtent: _rowHeight, + itemBuilder: (context, index) { + final channel = _channels[index]; + final programs = _getProgramsForChannel(channel); + return _buildProgramRow(channel, programs, theme); + }, + ), + ), ), ), ), - ), - ], + ], + ), + ), + ], + ); + } + + 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; + + final format = MaterialLocalizations.of(context); + // formatFullDate gives "Monday, January 1, 2026" — extract weekday name + final full = format.formatFullDate(target); + return full.split(',').first; + } + + 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, ), - subtitle: program != null - ? Row( + 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: [ - if (program.isCurrentlyAiring) ...[ - _LiveBadge(small: true), - const SizedBox(width: 4), - ], Expanded( child: Text( - program.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, + label, + style: theme.textTheme.bodyMedium?.copyWith( + color: isSelected ? theme.colorScheme.primary : null, + ), ), ), + if (isSelected) + AppIcon(Symbols.check_rounded, + size: 18, color: theme.colorScheme.primary), ], - ) - : null, + ), + ); + }), + ], + ).then((value) { + if (value == null) return; + if (value is String && value == 'now') { + _jumpToNow(); + } 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) return; + if (value == -1) { + // Back to day picker + _showDayPicker(); + return; + } + setState(() { + _gridStart = DateTime(day.year, day.month, day.day, value); + _gridEnd = _gridStart.add(const Duration(hours: 6)); + }); + _loadData(); + }); + } + + Widget _buildTimeNavigation(ThemeData theme) { + final format = MaterialLocalizations.of(context); + final timeLabel = + format.formatTimeOfDay(TimeOfDay.fromDateTime(_gridStart)); + 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: [ + IconButton( + icon: const AppIcon(Symbols.chevron_left_rounded), + onPressed: () => _shiftTimeRange(-2), + iconSize: 20, + visualDensity: VisualDensity.compact, + ), + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + GestureDetector( + key: _dayPickerKey, + onTap: _showDayPicker, + 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, + ), + ], + ), + ), + IconButton( + icon: const AppIcon(Symbols.chevron_right_rounded), + onPressed: () => _shiftTimeRange(2), + iconSize: 20, + visualDensity: VisualDensity.compact, + ), + ], + ), + ); + } + + Widget _buildTimeHeader(ThemeData theme) { + final slots = []; + var current = _gridStart; + + while (current.isBefore(_gridEnd)) { + final timeStr = + '${current.hour.toString().padLeft(2, '0')}:${current.minute.toString().padLeft(2, '0')}'; + 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 Stack( + children: [ + Row(children: slots), + _buildNowIndicator(theme), + ], + ); + } + + Widget _buildNowIndicator(ThemeData theme) { + final now = DateTime.now(); + if (now.isBefore(_gridStart) || now.isAfter(_gridEnd)) { + return const SizedBox.shrink(); + } + final minutesSinceStart = now.difference(_gridStart).inMinutes.toDouble(); + final offset = (minutesSinceStart / _minutesPerSlot) * _slotWidth; + + return Positioned( + left: offset, + top: 0, + bottom: 0, + child: Container(width: 2, color: Colors.red), + ); + } + + Widget _buildChannelCell(LiveTvChannel channel, ThemeData theme) { + final multiServer = context.read(); + final client = multiServer.getClientForServer(channel.serverId ?? ''); + + String? imageUrl; + if (channel.thumb != null && client != null) { + imageUrl = PlexImageHelper.getOptimizedImageUrl( + client: client, + thumbPath: channel.thumb, + maxWidth: _channelColumnWidth - 16, + maxHeight: _rowHeight - 16, + devicePixelRatio: PlexImageHelper.effectiveDevicePixelRatio(context), + imageType: ImageType.logo, + ); + } + + return _ChannelCell( + rowHeight: _rowHeight, + channelColumnWidth: _channelColumnWidth, + imageUrl: imageUrl, + channel: channel, + theme: theme, onTap: () => _tuneChannel(channel), + 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) { + 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; + + 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; + + blocks.add( + Positioned( + left: left, + width: width.clamp(2.0, double.infinity), + top: 0, + bottom: 0, + child: _buildProgramBlock(channel, program, theme), + ), + ); + } + + return Container( + height: _rowHeight, + decoration: BoxDecoration( + border: Border( + bottom: BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)), + ), + ), + child: Stack( + children: [ + ...blocks, + _buildNowIndicator(theme), + ], + ), + ); + } + + Widget _buildProgramBlock( + LiveTvChannel channel, LiveTvProgram program, ThemeData theme) { + final isCurrentlyAiring = program.isCurrentlyAiring; + final isPast = program.endsAt != null && + program.endsAt! < DateTime.now().millisecondsSinceEpoch ~/ 1000; + + return Opacity( + opacity: isPast ? 0.5 : 1.0, + child: Material( + color: isCurrentlyAiring + ? theme.colorScheme.primaryContainer + : theme.colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(4), + child: InkWell( + borderRadius: BorderRadius.circular(4), + onTap: () => _showProgramDetails(channel, program), + child: Container( + decoration: BoxDecoration( + border: Border(left: BorderSide(color: theme.dividerColor.withValues(alpha: 0.3))), + ), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + program.grandparentTitle ?? program.title, + style: theme.textTheme.bodySmall?.copyWith( + fontWeight: + isCurrentlyAiring ? FontWeight.w600 : FontWeight.normal, + color: isCurrentlyAiring + ? theme.colorScheme.onPrimaryContainer + : theme.colorScheme.onSurface, + ), + 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: isCurrentlyAiring + ? theme.colorScheme.onPrimaryContainer + .withValues(alpha: 0.7) + : theme.colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (program.startTime != null) + Text( + '${program.startTime!.hour.toString().padLeft(2, '0')}:${program.startTime!.minute.toString().padLeft(2, '0')} · ${formatDurationTextual(program.durationMinutes * 60000)}', + style: theme.textTheme.labelSmall?.copyWith( + color: isCurrentlyAiring + ? theme.colorScheme.onPrimaryContainer + .withValues(alpha: 0.7) + : theme.colorScheme.onSurfaceVariant, + ), + maxLines: 1, + ), + ], + ), + ), + ), + ), + ); + } + + void _showProgramDetails(LiveTvChannel channel, LiveTvProgram program) { + final theme = Theme.of(context); + + showModalBottomSheet( + context: context, + builder: (sheetContext) { + return Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + program.displayTitle, + style: theme.textTheme.titleMedium, + ), + ), + if (program.isCurrentlyAiring) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.red, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + t.liveTv.live, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 11), + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + '${channel.displayName} · ${program.startTime?.hour.toString().padLeft(2, '0')}:${program.startTime?.minute.toString().padLeft(2, '0')} - ${program.endTime?.hour.toString().padLeft(2, '0')}:${program.endTime?.minute.toString().padLeft(2, '0')} · ${formatDurationTextual(program.durationMinutes * 60000)}', + style: theme.textTheme.bodySmall + ?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + if (program.summary != null && + program.summary!.isNotEmpty) ...[ + const SizedBox(height: 12), + Text( + program.summary!, + style: theme.textTheme.bodyMedium, + maxLines: 4, + overflow: TextOverflow.ellipsis, + ), + ], + const SizedBox(height: 16), + Row( + children: [ + if (program.isCurrentlyAiring) + FilledButton.icon( + onPressed: () { + Navigator.of(sheetContext).pop(); + _tuneChannel(channel); + }, + icon: const AppIcon(Symbols.play_arrow_rounded), + label: Text(t.common.play), + ), + const SizedBox(width: 8), + OutlinedButton.icon( + onPressed: () { + Navigator.of(sheetContext).pop(); + // TODO: Record action + }, + icon: const AppIcon(Symbols.fiber_manual_record_rounded), + label: Text(t.liveTv.record), + ), + ], + ), + ], + ), + ); + }, ); } } -class _LiveBadge extends StatelessWidget { - final bool small; - const _LiveBadge({this.small = false}); +class _ChannelCell extends StatefulWidget { + final double rowHeight; + final double channelColumnWidth; + final String? imageUrl; + final LiveTvChannel channel; + final ThemeData theme; + final VoidCallback onTap; + final Widget Function() fallbackBuilder; + + const _ChannelCell({ + required this.rowHeight, + required this.channelColumnWidth, + required this.imageUrl, + required this.channel, + required this.theme, + required this.onTap, + required this.fallbackBuilder, + }); + + @override + State<_ChannelCell> createState() => _ChannelCellState(); +} + +class _ChannelCellState extends State<_ChannelCell> { + bool _hovered = false; @override Widget build(BuildContext context) { - final theme = Theme.of(context); - return Container( - padding: EdgeInsets.symmetric( - horizontal: small ? 4 : 6, - vertical: small ? 1 : 2, - ), - decoration: BoxDecoration( - color: Colors.red, - borderRadius: BorderRadius.circular(3), - ), - child: Text( - t.liveTv.live, - style: theme.textTheme.labelSmall?.copyWith( - color: Colors.white, - fontWeight: FontWeight.bold, - fontSize: small ? 8 : 10, + final theme = widget.theme; + + return MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: widget.onTap, + 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: _hovered ? 0.3 : 1.0, + duration: const Duration(milliseconds: 150), + child: widget.imageUrl != null && widget.imageUrl!.isNotEmpty + ? Image.network( + widget.imageUrl!, + width: widget.channelColumnWidth - 16, + height: widget.rowHeight - 16, + fit: BoxFit.contain, + errorBuilder: (_, _, _) => + widget.fallbackBuilder(), + ) + : widget.fallbackBuilder(), + ), + if (_hovered) + AppIcon( + Symbols.play_arrow_rounded, + size: 32, + color: theme.colorScheme.onSurface, + ), + ], + ), + ), ), ), ); diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index ebcae405..9131ef65 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -1995,7 +1995,7 @@ class PlexClient { /// Get EPG channels for a specific lineup Future> getEpgChannels({String? lineup}) async { final queryParams = {}; - if (lineup != null) queryParams['lineup'] = lineup; + if (lineup != null) queryParams['lineup'] = Uri.decodeComponent(lineup); return _wrapListApiCall( () => _dio.get('/livetv/epg/channels', queryParameters: queryParams), @@ -2020,24 +2020,72 @@ class PlexClient { ); } + /// Cached EPG grid endpoint path (discovered from /media/providers) + String? _epgGridEndpoint; + + /// Discover the EPG grid endpoint from media providers + Future _getEpgGridEndpoint() async { + if (_epgGridEndpoint != null) return _epgGridEndpoint; + + try { + final response = await _dio.get('/media/providers'); + final container = _getMediaContainer(response); + if (container == null) return null; + + final providers = container['MediaProvider'] as List?; + if (providers == null) return null; + + for (final provider in providers) { + if (provider is! Map) continue; + final protocols = provider['protocols'] as String?; + if (protocols == null || !protocols.contains('livetv')) continue; + + final features = provider['Feature'] as List?; + if (features == null) continue; + for (final feature in features) { + if (feature is! Map) continue; + if (feature['type'] == 'grid') { + _epgGridEndpoint = feature['key'] as String?; + appLogger.d('Discovered EPG grid endpoint: $_epgGridEndpoint'); + return _epgGridEndpoint; + } + } + } + } catch (e) { + appLogger.e('Failed to discover EPG grid endpoint', error: e); + } + return null; + } + /// Get guide/program data for channels (EPG grid data) - /// Returns programs grouped in the MediaContainer + /// Discovers the grid endpoint from /media/providers on first call Future> getEpgGrid({ String? lineup, int? beginsAt, int? endsAt, }) async { + final gridEndpoint = await _getEpgGridEndpoint(); + if (gridEndpoint == null) { + appLogger.w('No EPG grid endpoint found'); + return []; + } + final queryParams = {}; - if (lineup != null) queryParams['lineup'] = lineup; if (beginsAt != null) queryParams['beginsAt>'] = beginsAt; if (endsAt != null) queryParams['endsAt<'] = endsAt; return _wrapListApiCall( - () => _dio.get('/livetv/epg', queryParameters: queryParams), + () => _dio.get(gridEndpoint, queryParameters: queryParams), (response) { final container = _getMediaContainer(response); + appLogger.d('getEpgGrid: container keys=${container?.keys.toList()}'); final programs = []; if (container != null && container['Metadata'] != null) { + final firstItem = (container['Metadata'] as List).firstOrNull; + if (firstItem is Map) { + appLogger.d('getEpgGrid: sample program keys=${firstItem.keys.toList()}'); + appLogger.d('getEpgGrid: Channel=${firstItem['Channel']}, Media=${firstItem['Media']}, beginsAt=${firstItem['beginsAt']}, endsAt=${firstItem['endsAt']}, duration=${firstItem['duration']}'); + } for (final item in container['Metadata'] as List) { try { programs.add(LiveTvProgram.fromJson(item as Map));