From 426f6ab6ba5967daedf6bbc433800d6ee4f7786d Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 13 Feb 2026 00:01:54 +0100 Subject: [PATCH] fix(tv): misc improvements --- lib/screens/livetv/live_tv_screen.dart | 20 ++++-- lib/screens/livetv/program_details_sheet.dart | 67 +++++++++---------- lib/screens/livetv/tabs/guide_tab.dart | 65 +++++++++--------- lib/screens/livetv/tabs/whats_on_tab.dart | 21 ++++-- lib/screens/video_player_screen.dart | 8 ++- lib/services/plex_client.dart | 14 ++-- .../desktop_video_controls.dart | 2 +- .../video_controls/mobile_video_controls.dart | 7 -- 8 files changed, 116 insertions(+), 88 deletions(-) diff --git a/lib/screens/livetv/live_tv_screen.dart b/lib/screens/livetv/live_tv_screen.dart index 1b4021a0..c744dbfa 100644 --- a/lib/screens/livetv/live_tv_screen.dart +++ b/lib/screens/livetv/live_tv_screen.dart @@ -76,6 +76,14 @@ class _LiveTvScreenState extends State with SingleTickerProviderSt void onTabChanged() { if (!tabController.indexIsChanging) { super.onTabChanged(); + // Pause/resume timers based on active tab + if (tabController.index == 0) { + _whatsOnTabKey.currentState?.pauseRefresh(); + _guideTabKey.currentState?.resumeRefresh(); + } else { + _guideTabKey.currentState?.pauseRefresh(); + _whatsOnTabKey.currentState?.resumeRefresh(); + } } } @@ -101,11 +109,15 @@ class _LiveTvScreenState extends State with SingleTickerProviderSt final allChannels = []; for (final serverInfo in liveTvServers) { - final client = multiServer.getClientForServer(serverInfo.serverId); - if (client == null) continue; + try { + final client = multiServer.getClientForServer(serverInfo.serverId); + if (client == null) continue; - final channels = await client.getEpgChannels(lineup: serverInfo.lineup); - allChannels.addAll(channels); + final channels = await client.getEpgChannels(lineup: serverInfo.lineup); + allChannels.addAll(channels); + } catch (e) { + appLogger.e('Failed to load channels from server ${serverInfo.serverId}', error: e); + } } allChannels.sort((a, b) { diff --git a/lib/screens/livetv/program_details_sheet.dart b/lib/screens/livetv/program_details_sheet.dart index ee8c23d6..9284ff8c 100644 --- a/lib/screens/livetv/program_details_sheet.dart +++ b/lib/screens/livetv/program_details_sheet.dart @@ -69,7 +69,8 @@ class _ProgramDetailsSheetContentState extends State<_ProgramDetailsSheetContent void _buildButtonFocusNodes() { int count = 0; if (widget.program.isCurrentlyAiring && widget.onTuneChannel != null) count++; - count++; // Record button always present + // TODO: Implement recording + // count++; // Record button if (!widget.program.isCurrentlyAiring && widget.onTuneChannel != null) count++; for (int i = 0; i < count; i++) { @@ -122,39 +123,37 @@ class _ProgramDetailsSheetContentState extends State<_ProgramDetailsSheetContent buttonIndex++; } - if (program.isCurrentlyAiring && widget.onTuneChannel != null) { - buttons.add(const SizedBox(width: 8)); - } - - // Record button - { - final idx = buttonIndex; - buttons.add( - FocusableWrapper( - focusNode: _buttonFocusNodes[idx], - onSelect: () { - Navigator.of(context).pop(); - // TODO: Record action - }, - onNavigateLeft: idx > 0 ? () => _focusButton(idx - 1) : null, - onNavigateRight: idx < _buttonFocusNodes.length - 1 ? () => _focusButton(idx + 1) : null, - onBack: () => Navigator.of(context).pop(), - borderRadius: 100, - useBackgroundFocus: true, - disableScale: true, - child: OutlinedButton.icon( - style: OutlinedButton.styleFrom(tapTargetSize: MaterialTapTargetSize.shrinkWrap), - onPressed: () { - Navigator.of(context).pop(); - // TODO: Record action - }, - icon: const AppIcon(Symbols.fiber_manual_record_rounded), - label: Text(t.liveTv.record), - ), - ), - ); - buttonIndex++; - } + // TODO: Implement recording + // if (program.isCurrentlyAiring && widget.onTuneChannel != null) { + // buttons.add(const SizedBox(width: 8)); + // } + // // Record button + // { + // final idx = buttonIndex; + // buttons.add( + // FocusableWrapper( + // focusNode: _buttonFocusNodes[idx], + // onSelect: () { + // Navigator.of(context).pop(); + // }, + // onNavigateLeft: idx > 0 ? () => _focusButton(idx - 1) : null, + // onNavigateRight: idx < _buttonFocusNodes.length - 1 ? () => _focusButton(idx + 1) : null, + // onBack: () => Navigator.of(context).pop(), + // borderRadius: 100, + // useBackgroundFocus: true, + // disableScale: true, + // child: OutlinedButton.icon( + // style: OutlinedButton.styleFrom(tapTargetSize: MaterialTapTargetSize.shrinkWrap), + // onPressed: () { + // Navigator.of(context).pop(); + // }, + // icon: const AppIcon(Symbols.fiber_manual_record_rounded), + // label: Text(t.liveTv.record), + // ), + // ), + // ); + // buttonIndex++; + // } if (!program.isCurrentlyAiring && widget.onTuneChannel != null) { buttons.add(const SizedBox(width: 8)); diff --git a/lib/screens/livetv/tabs/guide_tab.dart b/lib/screens/livetv/tabs/guide_tab.dart index e1904f3e..37eafd80 100644 --- a/lib/screens/livetv/tabs/guide_tab.dart +++ b/lib/screens/livetv/tabs/guide_tab.dart @@ -11,11 +11,13 @@ import '../../../i18n/strings.g.dart'; import '../../../models/livetv_channel.dart'; import '../../../models/livetv_program.dart'; import '../../../providers/multi_server_provider.dart'; +import '../../../services/plex_client.dart'; import '../../../utils/app_logger.dart'; import '../../../utils/formatters.dart'; import '../../../utils/plex_image_helper.dart'; import '../../../utils/live_tv_player_navigation.dart'; import '../../../widgets/app_icon.dart'; +import '../../../widgets/plex_optimized_image.dart'; import '../program_details_sheet.dart'; class GuideTab extends StatefulWidget { @@ -99,6 +101,15 @@ class GuideTabState extends State { }); } + void pauseRefresh() => _timeIndicatorTimer?.cancel(); + + void resumeRefresh() { + _timeIndicatorTimer?.cancel(); + _timeIndicatorTimer = Timer.periodic(const Duration(minutes: 1), (_) { + if (mounted) setState(() {}); + }); + } + @override void didUpdateWidget(GuideTab oldWidget) { super.didUpdateWidget(oldWidget); @@ -189,18 +200,22 @@ class GuideTabState extends State { final allPrograms = []; for (final serverInfo in liveTvServers) { - final client = multiServer.getClientForServer(serverInfo.serverId); - if (client == null) continue; + try { + final client = multiServer.getClientForServer(serverInfo.serverId); + if (client == null) continue; - final startEpoch = _gridStart.millisecondsSinceEpoch ~/ 1000; - final endEpoch = _gridEnd.millisecondsSinceEpoch ~/ 1000; + 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); + final programs = await client.getEpgGrid( + lineup: serverInfo.lineup, + beginsAt: startEpoch, + endsAt: endEpoch, + ); + allPrograms.addAll(programs); + } catch (e) { + appLogger.e('Failed to load programs from server ${serverInfo.serverId}', error: e); + } } if (!mounted) return; @@ -868,24 +883,13 @@ class GuideTabState extends State { 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, - ); - } - final isFocused = _hasFocus && _focusZone == _GuideZone.grid && _gridColumn == 0 && _gridChannelIndex == index; return _ChannelCell( rowHeight: _rowHeight, channelColumnWidth: _channelColumnWidth, - imageUrl: imageUrl, + channelThumb: channel.thumb, + client: client, channel: channel, theme: theme, onTap: () => _tuneChannel(channel), @@ -1113,7 +1117,8 @@ class GuideTabState extends State { class _ChannelCell extends StatefulWidget { final double rowHeight; final double channelColumnWidth; - final String? imageUrl; + final String? channelThumb; + final PlexClient? client; final LiveTvChannel channel; final ThemeData theme; final VoidCallback onTap; @@ -1123,7 +1128,8 @@ class _ChannelCell extends StatefulWidget { const _ChannelCell({ required this.rowHeight, required this.channelColumnWidth, - required this.imageUrl, + required this.channelThumb, + required this.client, required this.channel, required this.theme, required this.onTap, @@ -1170,14 +1176,13 @@ class _ChannelCellState extends State<_ChannelCell> { AnimatedOpacity( opacity: showAction ? 0.3 : 1.0, duration: const Duration(milliseconds: 150), - child: widget.imageUrl != null && widget.imageUrl!.isNotEmpty - ? Image.network( - widget.imageUrl!, + child: widget.channelThumb != null && widget.client != null + ? PlexOptimizedImage.thumb( + client: widget.client!, + imagePath: widget.channelThumb, width: widget.channelColumnWidth - 16, height: widget.rowHeight - 16, fit: BoxFit.contain, - errorBuilder: (_, _, _) => - widget.fallbackBuilder(), ) : widget.fallbackBuilder(), ), diff --git a/lib/screens/livetv/tabs/whats_on_tab.dart b/lib/screens/livetv/tabs/whats_on_tab.dart index c946c0d9..a969da95 100644 --- a/lib/screens/livetv/tabs/whats_on_tab.dart +++ b/lib/screens/livetv/tabs/whats_on_tab.dart @@ -53,6 +53,15 @@ class WhatsOnTabState extends State { }); } + void pauseRefresh() => _refreshTimer?.cancel(); + + void resumeRefresh() { + _refreshTimer?.cancel(); + _refreshTimer = Timer.periodic(const Duration(seconds: 60), (_) { + if (mounted) _loadHubs(); + }); + } + @override void dispose() { _refreshTimer?.cancel(); @@ -69,11 +78,15 @@ class WhatsOnTabState extends State { final allHubs = []; for (final serverInfo in liveTvServers) { - final client = multiServer.getClientForServer(serverInfo.serverId); - if (client == null) continue; + try { + final client = multiServer.getClientForServer(serverInfo.serverId); + if (client == null) continue; - final hubs = await client.getLiveTvHubs(); - allHubs.addAll(hubs); + final hubs = await client.getLiveTvHubs(); + allHubs.addAll(hubs); + } catch (e) { + appLogger.e('Failed to load hubs from server ${serverInfo.serverId}', error: e); + } } if (!mounted) return; diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index eb2582c2..55cb3083 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -892,6 +892,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin _startLiveTimelineUpdates(); } catch (e) { appLogger.e('Failed to start live TV playback', error: e); + _sendLiveTimeline('stopped'); if (mounted) { showErrorSnackBar(context, e.toString()); _handleBackButton(); @@ -1654,7 +1655,8 @@ class VideoPlayerScreenState extends State with WidgetsBindin void _startLiveTimelineUpdates() { _liveTimelineTimer?.cancel(); _liveTimelineTimer = Timer.periodic(const Duration(seconds: 10), (_) { - _sendLiveTimeline('playing'); + final state = player?.state.playing == true ? 'playing' : 'paused'; + _sendLiveTimeline(state); }); // Send initial heartbeat immediately _sendLiveTimeline('playing'); @@ -1714,6 +1716,10 @@ class VideoPlayerScreenState extends State with WidgetsBindin _isSwitchingChannel = true; + // Stop old session heartbeats and notify server + _stopLiveTimelineUpdates(); + await _sendLiveTimeline('stopped'); + final channel = channels[newIndex]; final channelId = channel.identifier ?? channel.key; appLogger.d('Switching to channel: ${channel.displayName} ($channelId)'); diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 8a79d8c3..c0f2a8fc 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -2006,6 +2006,7 @@ class PlexClient { return (container['Channel'] as List) .map((json) => LiveTvChannel.fromJson(json as Map) .copyWith(serverId: serverId, serverName: serverName)) + .where((ch) => ch.key.isNotEmpty) .toList(); } // Also check for Metadata key (some endpoints return channels there) @@ -2013,6 +2014,7 @@ class PlexClient { return (container['Metadata'] as List) .map((json) => LiveTvChannel.fromJson(json as Map) .copyWith(serverId: serverId, serverName: serverName)) + .where((ch) => ch.key.isNotEmpty) .toList(); } return []; @@ -2084,14 +2086,8 @@ class PlexClient { () => _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)); @@ -2300,7 +2296,11 @@ class PlexClient { .join('&'); // Decision — bare Dio so no default X-Plex-* HTTP headers leak through. - final decisionDio = Dio(BaseOptions(headers: {'Accept-Language': 'en'})); + final decisionDio = Dio(BaseOptions( + headers: {'Accept-Language': 'en'}, + connectTimeout: ConnectionTimeouts.connect, + receiveTimeout: ConnectionTimeouts.receive, + )); final decisionUrl = '${config.baseUrl}/video/:/transcode/universal/decision?$queryString'; final decisionResponse = await decisionDio.getUri(Uri.parse(decisionUrl)); diff --git a/lib/widgets/video_controls/desktop_video_controls.dart b/lib/widgets/video_controls/desktop_video_controls.dart index 08214eef..3b29e909 100644 --- a/lib/widgets/video_controls/desktop_video_controls.dart +++ b/lib/widgets/video_controls/desktop_video_controls.dart @@ -437,7 +437,7 @@ class DesktopVideoControlsState extends State { borderRadius: BorderRadius.circular(4), ), child: Text( - widget.liveChannelName != null ? '${t.liveTv.live} · ${widget.liveChannelName}' : t.liveTv.live, + t.liveTv.live, style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 12), ), ), diff --git a/lib/widgets/video_controls/mobile_video_controls.dart b/lib/widgets/video_controls/mobile_video_controls.dart index 04dce8d3..243650ec 100644 --- a/lib/widgets/video_controls/mobile_video_controls.dart +++ b/lib/widgets/video_controls/mobile_video_controls.dart @@ -182,13 +182,6 @@ class MobileVideoControls extends StatelessWidget { style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 12), ), ), - if (liveChannelName != null) ...[ - const SizedBox(width: 8), - Text( - liveChannelName!, - style: const TextStyle(color: Colors.white70, fontSize: 14), - ), - ], ], ), );