feat(tv): inline EPG guide grid with provider endpoint discovery

This commit is contained in:
edde746
2026-02-12 14:16:10 +01:00
parent 2ae6385913
commit 80f26b07c0
7 changed files with 909 additions and 919 deletions
+9 -1
View File
@@ -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",
+2 -2
View File
@@ -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
+34 -2
View File
@@ -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',
+12 -5
View File
@@ -43,6 +43,10 @@ class LiveTvProgram {
});
factory LiveTvProgram.fromJson(Map<String, dynamic> json) {
// Grid endpoint nests timing/channel info inside Media[0] and Channel[0]
final media = (json['Media'] as List?)?.firstOrNull as Map<String, dynamic>?;
final channel = (json['Channel'] as List?)?.firstOrNull as Map<String, dynamic>?;
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',
);
-623
View File
@@ -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<EpgGuideScreen> createState() => _EpgGuideScreenState();
}
class _EpgGuideScreenState extends State<EpgGuideScreen> {
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<LiveTvChannel> _channels = [];
List<LiveTvProgram> _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<void> _loadData() async {
if (!mounted) return;
setState(() {
_isLoading = true;
_error = null;
});
try {
final multiServer = context.read<MultiServerProvider>();
final liveTvServers = multiServer.liveTvServers;
if (liveTvServers.isEmpty) {
setState(() {
_isLoading = false;
_error = t.liveTv.noDvr;
});
return;
}
final allChannels = <LiveTvChannel>[];
final allPrograms = <LiveTvProgram>[];
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<LiveTvProgram> _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<ScrollNotification>(
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 = <Widget>[];
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<MultiServerProvider>();
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<LiveTvProgram> 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 = <Widget>[];
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<void> _tuneToChannel(LiveTvChannel channel) async {
final multiServer = context.read<MultiServerProvider>();
// 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,
);
}
}
File diff suppressed because it is too large Load Diff
+52 -4
View File
@@ -1995,7 +1995,7 @@ class PlexClient {
/// Get EPG channels for a specific lineup
Future<List<LiveTvChannel>> getEpgChannels({String? lineup}) async {
final queryParams = <String, dynamic>{};
if (lineup != null) queryParams['lineup'] = lineup;
if (lineup != null) queryParams['lineup'] = Uri.decodeComponent(lineup);
return _wrapListApiCall<LiveTvChannel>(
() => _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<String?> _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<List<LiveTvProgram>> 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 = <String, dynamic>{};
if (lineup != null) queryParams['lineup'] = lineup;
if (beginsAt != null) queryParams['beginsAt>'] = beginsAt;
if (endsAt != null) queryParams['endsAt<'] = endsAt;
return _wrapListApiCall<LiveTvProgram>(
() => _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 = <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>));