feat(livetv): group guide by source

This commit is contained in:
edde746
2026-05-17 06:41:47 +02:00
parent 6bad454830
commit 87db591792
11 changed files with 430 additions and 16 deletions
+153 -13
View File
@@ -18,6 +18,8 @@ import '../../../providers/multi_server_provider.dart';
import '../../../media/media_server_client.dart';
import '../../../utils/app_logger.dart';
import '../../../utils/formatters.dart';
import '../../../utils/live_tv_grouping.dart';
import '../../../utils/live_tv_matching.dart';
import '../../../utils/media_image_helper.dart';
import '../../../utils/live_tv_player_navigation.dart';
import '../../../widgets/app_icon.dart';
@@ -47,10 +49,28 @@ class GuideTab extends StatefulWidget {
enum _GuideZone { timeNav, grid }
sealed class _GuideRow {
const _GuideRow();
}
final class _GuideSourceHeaderRow extends _GuideRow {
final String label;
const _GuideSourceHeaderRow({required this.label});
}
final class _GuideChannelRow extends _GuideRow {
final LiveTvChannel channel;
final int channelIndex;
const _GuideChannelRow({required this.channel, required this.channelIndex});
}
class GuideTabState extends State<GuideTab> with MountedSetStateMixin {
static const _slotWidth = 180.0;
static const _channelColumnWidth = 100.0;
static const _channelColumnWidth = 132.0;
static const _rowHeight = 64.0;
static const _sourceHeaderRowHeight = 40.0;
static const _timeHeaderHeight = 40.0;
static const _minutesPerSlot = 30;
@@ -354,6 +374,52 @@ class GuideTabState extends State<GuideTab> with MountedSetStateMixin {
return trimmed == null || trimmed.isEmpty ? null : trimmed;
}
List<_GuideRow> get _guideRows {
final groups = groupLiveTvChannelsBySource(widget.channels);
if (groups.length <= 1) {
return [
for (var i = 0; i < widget.channels.length; i++) _GuideChannelRow(channel: widget.channels[i], channelIndex: i),
];
}
final channelIndexes = <LiveTvChannel, int>{};
for (var i = 0; i < widget.channels.length; i++) {
channelIndexes[widget.channels[i]] = i;
}
return [
for (final group in groups) ...[
_GuideSourceHeaderRow(label: group.label),
for (final channel in group.channels)
_GuideChannelRow(channel: channel, channelIndex: channelIndexes[channel] ?? 0),
],
];
}
double _guideRowHeight(_GuideRow row) {
return switch (row) {
_GuideSourceHeaderRow() => _sourceHeaderRowHeight,
_GuideChannelRow() => _rowHeight,
};
}
double _guideContentHeight(List<_GuideRow> rows) {
var height = 0.0;
for (final row in rows) {
height += _guideRowHeight(row);
}
return height;
}
double _rowTopForChannelIndex(int channelIndex) {
var top = 0.0;
for (final row in _guideRows) {
if (row is _GuideChannelRow && row.channelIndex == channelIndex) return top;
top += _guideRowHeight(row);
}
return channelIndex * _rowHeight;
}
void _scrollToNow() {
WidgetsBinding.instance.addPostFrameCallback((_) {
final now = DateTime.now();
@@ -368,7 +434,7 @@ class GuideTabState extends State<GuideTab> with MountedSetStateMixin {
}
List<LiveTvProgram> _getProgramsForChannel(LiveTvChannel channel) {
return _programs.where((p) => p.channelIdentifier == channel.key).toList()
return _programs.where((program) => liveTvProgramMatchesChannel(program, channel)).toList()
..sort((a, b) => (a.beginsAt ?? 0).compareTo(b.beginsAt ?? 0));
}
@@ -566,7 +632,7 @@ class GuideTabState extends State<GuideTab> with MountedSetStateMixin {
void _scrollToChannel(int index) {
if (!_gridVerticalController.hasClients) return;
final targetTop = index * _rowHeight;
final targetTop = _rowTopForChannelIndex(index);
final targetBottom = targetTop + _rowHeight;
final viewportTop = _gridVerticalController.offset;
final viewportBottom = viewportTop + _gridVerticalController.position.viewportDimension;
@@ -631,6 +697,7 @@ class GuideTabState extends State<GuideTab> with MountedSetStateMixin {
return ValueListenableBuilder<bool>(
valueListenable: _hasFocusNotifier,
builder: (context, hasFocus, child) {
final rows = _guideRows;
return Column(
children: [
_buildTimeNavigation(theme),
@@ -667,10 +734,18 @@ class GuideTabState extends State<GuideTab> with MountedSetStateMixin {
child: ListView.builder(
controller: _channelVerticalController,
physics: const NeverScrollableScrollPhysics(),
itemCount: widget.channels.length,
itemExtent: _rowHeight,
itemBuilder: (context, index) =>
_buildChannelCell(widget.channels[index], theme, index: index),
itemCount: rows.length,
itemBuilder: (context, index) {
final row = rows[index];
return switch (row) {
_GuideSourceHeaderRow(:final label) => _buildSourceHeaderCell(label, theme),
_GuideChannelRow(:final channel, :final channelIndex) => _buildChannelCell(
channel,
theme,
index: channelIndex,
),
};
},
),
),
Expanded(
@@ -692,12 +767,18 @@ class GuideTabState extends State<GuideTab> with MountedSetStateMixin {
width: _totalGridWidth(),
child: ListView.builder(
controller: _gridVerticalController,
itemCount: widget.channels.length,
itemExtent: _rowHeight,
itemCount: rows.length,
itemBuilder: (context, index) {
final channel = widget.channels[index];
final programs = _getProgramsForChannel(channel);
return _buildProgramRow(channel, programs, theme, channelIndex: index);
final row = rows[index];
return switch (row) {
_GuideSourceHeaderRow(:final label) => _buildSourceHeaderGridRow(label, theme),
_GuideChannelRow(:final channel, :final channelIndex) => _buildProgramRow(
channel,
_getProgramsForChannel(channel),
theme,
channelIndex: channelIndex,
),
};
},
),
),
@@ -730,7 +811,7 @@ class GuideTabState extends State<GuideTab> with MountedSetStateMixin {
// Hide when scrolled behind the channel column
if (left < _channelColumnWidth) return const SizedBox.shrink();
final gridHeight = _timeHeaderHeight + widget.channels.length * _rowHeight;
final gridHeight = _timeHeaderHeight + _guideContentHeight(_guideRows);
return Positioned(
left: left,
@@ -973,6 +1054,65 @@ class GuideTabState extends State<GuideTab> with MountedSetStateMixin {
return Row(children: slots);
}
Widget _buildSourceHeaderCell(String label, ThemeData theme) {
return Container(
height: _sourceHeaderRowHeight,
padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.35),
border: Border(
bottom: BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)),
right: BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)),
),
),
alignment: Alignment.centerLeft,
child: Text(
label,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w700,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
);
}
Widget _buildSourceHeaderGridRow(String label, ThemeData theme) {
return Container(
height: _sourceHeaderRowHeight,
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.25),
border: Border(bottom: BorderSide(color: theme.dividerColor.withValues(alpha: 0.3))),
),
child: ClipRect(
child: ListenableBuilder(
listenable: _gridHorizontalController,
builder: (context, child) {
final scrollOffset = _gridHorizontalController.hasClients ? _gridHorizontalController.offset : 0.0;
return Transform.translate(offset: Offset(scrollOffset, 0), child: child);
},
child: Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Text(
label,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.8),
fontWeight: FontWeight.w700,
letterSpacing: 0.3,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
),
),
);
}
Widget _buildChannelCell(LiveTvChannel channel, ThemeData theme, {required int index}) {
final multiServer = context.read<MultiServerProvider>();
final client = multiServer.getClientForServer(channel.serverId ?? '');