fix(tv): misc improvements
This commit is contained in:
@@ -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) {
|
||||
try {
|
||||
final client = multiServer.getClientForServer(serverInfo.serverId);
|
||||
if (client == null) continue;
|
||||
|
||||
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) {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,6 +200,7 @@ class GuideTabState extends State<GuideTab> {
|
||||
final allPrograms = <LiveTvProgram>[];
|
||||
|
||||
for (final serverInfo in liveTvServers) {
|
||||
try {
|
||||
final client = multiServer.getClientForServer(serverInfo.serverId);
|
||||
if (client == null) continue;
|
||||
|
||||
@@ -201,6 +213,9 @@ class GuideTabState extends State<GuideTab> {
|
||||
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(),
|
||||
),
|
||||
|
||||
@@ -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) {
|
||||
try {
|
||||
final client = multiServer.getClientForServer(serverInfo.serverId);
|
||||
if (client == null) continue;
|
||||
|
||||
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;
|
||||
|
||||
@@ -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)');
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user