diff --git a/lib/models/livetv_dvr.dart b/lib/models/livetv_dvr.dart index 594185c4..f087e0fb 100644 --- a/lib/models/livetv_dvr.dart +++ b/lib/models/livetv_dvr.dart @@ -79,7 +79,7 @@ class ChannelMapping { return ChannelMapping( channelKey: json['channelKey'] as String?, deviceIdentifier: json['deviceIdentifier'] as String?, - enabled: json['enabled'] == true || json['enabled'] == 1, + enabled: json['enabled'] == true || json['enabled'] == 1 || json['enabled'] == '1', lineupIdentifier: json['lineupIdentifier'] as String?, ); } diff --git a/lib/screens/livetv/live_tv_screen.dart b/lib/screens/livetv/live_tv_screen.dart index 3ec5c8a8..51a28af9 100644 --- a/lib/screens/livetv/live_tv_screen.dart +++ b/lib/screens/livetv/live_tv_screen.dart @@ -111,14 +111,46 @@ class _LiveTvScreenState extends State with SingleTickerProviderSt appLogger.d('Live TV DVRs: ${liveTvServers.map((s) => '${s.serverId}/${s.dvrKey} lineup=${s.lineup}').join(', ')}'); + // Build a set of enabled channel keys per server from DVR mappings + final enabledKeysByServer = >{}; + final queriedServers = {}; + for (final serverInfo in liveTvServers) { + if (!queriedServers.add(serverInfo.serverId)) continue; + try { + final client = multiServer.getClientForServer(serverInfo.serverId); + if (client == null) continue; + final dvrs = await client.getDvrs(); + final enabledKeys = {}; + bool hasMappings = false; + for (final dvr in dvrs) { + if (dvr.channelMappings.isNotEmpty) { + hasMappings = true; + for (final m in dvr.channelMappings) { + if (m.enabled == true && m.channelKey != null) { + enabledKeys.add(m.channelKey!); + } + } + } + } + if (hasMappings) { + enabledKeysByServer[serverInfo.serverId] = enabledKeys; + } + } catch (e) { + appLogger.e('Failed to load DVR mappings for server ${serverInfo.serverId}', error: e); + } + } + for (final serverInfo in liveTvServers) { try { final client = multiServer.getClientForServer(serverInfo.serverId); if (client == null) continue; final channels = await client.getEpgChannels(lineup: serverInfo.lineup); - appLogger.d('Channels from DVR ${serverInfo.dvrKey}: ${channels.length} channels'); + final enabledKeys = enabledKeysByServer[serverInfo.serverId]; + appLogger.d('Channels from DVR ${serverInfo.dvrKey}: ${channels.length} channels (${enabledKeys?.length ?? 'all'} enabled)'); for (final channel in channels) { + // Skip disabled channels if DVR has mapping data + if (enabledKeys != null && !enabledKeys.contains(channel.key)) continue; final dedupKey = '${serverInfo.serverId}:${channel.key}'; if (seenChannels.add(dedupKey)) { allChannels.add(channel); @@ -144,7 +176,7 @@ class _LiveTvScreenState extends State with SingleTickerProviderSt _isLoading = false; }); - if (allChannels.isNotEmpty) { + if (allChannels.isNotEmpty && PlatformDetector.shouldUseSideNavigation(context)) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _focusCurrentTab(); }); diff --git a/lib/screens/livetv/tabs/guide_tab.dart b/lib/screens/livetv/tabs/guide_tab.dart index 61279572..08689f42 100644 --- a/lib/screens/livetv/tabs/guide_tab.dart +++ b/lib/screens/livetv/tabs/guide_tab.dart @@ -516,75 +516,120 @@ class GuideTabState extends State { return Column( children: [ _buildTimeNavigation(theme), - Row( - children: [ - 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, - 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); - }, + child: ListenableBuilder( + listenable: _gridHorizontalController, + builder: (context, child) { + return Stack( + children: [ + child!, + _buildNowIndicatorOverlay(theme), + ], + ); + }, + child: Column( + children: [ + Row( + children: [ + 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 theme) { + 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); @@ -851,29 +896,9 @@ class GuideTabState extends State { current = current.add(const Duration(minutes: _minutesPerSlot)); } - return Stack( - children: [ - Row(children: slots), - _buildNowIndicator(theme), - ], - ); + return Row(children: slots); } - 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), - ); - } // --------------------------------------------------------------------------- // Channel column @@ -994,12 +1019,7 @@ class GuideTabState extends State { bottom: BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)), ), ), - child: Stack( - children: [ - ...blocks, - _buildNowIndicator(theme), - ], - ), + child: Stack(children: blocks), ); }