fix(tv): misc improvements

This commit is contained in:
edde746
2026-02-13 00:01:54 +01:00
parent 7745b5a2f8
commit 426f6ab6ba
8 changed files with 116 additions and 88 deletions
+16 -4
View File
@@ -76,6 +76,14 @@ class _LiveTvScreenState extends State<LiveTvScreen> 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<LiveTvScreen> with SingleTickerProviderSt
final allChannels = <LiveTvChannel>[];
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) {
+33 -34
View File
@@ -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));
+35 -30
View File
@@ -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<GuideTab> {
});
}
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<GuideTab> {
final allPrograms = <LiveTvProgram>[];
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<GuideTab> {
final multiServer = context.read<MultiServerProvider>();
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<GuideTab> {
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(),
),
+17 -4
View File
@@ -53,6 +53,15 @@ class WhatsOnTabState extends State<WhatsOnTab> {
});
}
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<WhatsOnTab> {
final allHubs = <LiveTvHubResult>[];
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;
+7 -1
View File
@@ -892,6 +892,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> 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<VideoPlayerScreen> 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<VideoPlayerScreen> 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)');
+7 -7
View File
@@ -2006,6 +2006,7 @@ class PlexClient {
return (container['Channel'] as List)
.map((json) => LiveTvChannel.fromJson(json as Map<String, dynamic>)
.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<String, dynamic>)
.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 = <LiveTvProgram>[];
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<String, dynamic>));
@@ -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));
@@ -437,7 +437,7 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
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),
),
),
@@ -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),
),
],
],
),
);