feat(tv): inline EPG guide grid with provider endpoint discovery
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
@@ -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
@@ -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>));
|
||||
|
||||
Reference in New Issue
Block a user