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
+5
View File
@@ -75,6 +75,8 @@ class LiveTvChannel with MultiServerFields {
@JsonKey(includeFromJson: false, includeToJson: false)
final String? liveDvrKey;
@JsonKey(includeFromJson: false, includeToJson: false)
final String? liveTvSourceTitle;
@JsonKey(includeFromJson: false, includeToJson: false)
final String? favoriteSource;
@JsonKey(includeFromJson: false, includeToJson: false)
final String? favoriteStoreKey;
@@ -94,6 +96,7 @@ class LiveTvChannel with MultiServerFields {
this.serverId,
this.serverName,
this.liveDvrKey,
this.liveTvSourceTitle,
this.favoriteSource,
this.favoriteStoreKey,
});
@@ -104,6 +107,7 @@ class LiveTvChannel with MultiServerFields {
String? serverId,
String? serverName,
String? liveDvrKey,
String? liveTvSourceTitle,
String? favoriteSource,
String? favoriteStoreKey,
}) {
@@ -122,6 +126,7 @@ class LiveTvChannel with MultiServerFields {
serverId: serverId ?? this.serverId,
serverName: serverName ?? this.serverName,
liveDvrKey: liveDvrKey ?? this.liveDvrKey,
liveTvSourceTitle: liveTvSourceTitle ?? this.liveTvSourceTitle,
favoriteSource: favoriteSource ?? this.favoriteSource,
favoriteStoreKey: favoriteStoreKey ?? this.favoriteStoreKey,
);
@@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
import '../../models/livetv_channel.dart';
import '../../models/livetv_program.dart';
import '../../providers/multi_server_provider.dart';
import '../../utils/live_tv_matching.dart';
import '../../utils/live_tv_player_navigation.dart';
import '../../utils/media_image_helper.dart';
import 'program_details_sheet.dart';
@@ -24,6 +25,10 @@ mixin LiveTvActionsMixin<T extends StatefulWidget> on State<T> {
}).firstOrNull;
}
LiveTvChannel? findChannelForProgram(LiveTvProgram program) {
return liveTvChannels.where((channel) => liveTvProgramMatchesChannel(program, channel)).firstOrNull;
}
/// Start live playback for [channel] on its owning server.
///
/// Both backends route through the live-TV navigator so the player
+16
View File
@@ -248,6 +248,20 @@ class _LiveTvScreenState extends State<LiveTvScreen>
return _extractEnabledChannelKeys(matching.isNotEmpty ? matching : serverInfo.dvrs);
}
String? _sourceTitleForServerInfo(LiveTvServerInfo serverInfo) {
for (final dvr in serverInfo.dvrs) {
if (dvr.key == serverInfo.dvrKey) {
return _nonEmpty(dvr.lineupTitle) ?? _nonEmpty(dvr.lineupURL) ?? _nonEmpty(dvr.lineup);
}
}
return _nonEmpty(serverInfo.lineup);
}
String? _nonEmpty(String? value) {
final trimmed = value?.trim();
return trimmed == null || trimmed.isEmpty ? null : trimmed;
}
Future<void> _loadChannels() async {
if (!mounted) return;
setState(() {
@@ -296,6 +310,7 @@ class _LiveTvScreenState extends State<LiveTvScreen>
final liveTv = genericClient.liveTv;
final source = await liveTv.buildFavoriteChannelSource(lineup: serverInfo.lineup);
final sourceTitle = _sourceTitleForServerInfo(serverInfo);
final storeKey = liveTv.favoriteStoreKey;
final liveServerKey = _liveServerScopeKey(serverInfo);
_favoriteSourceByLiveServer[liveServerKey] = source;
@@ -314,6 +329,7 @@ class _LiveTvScreenState extends State<LiveTvScreen>
if (enabledKeys != null && !enabledKeys.contains(channel.key)) continue;
final scopedChannel = channel.copyWith(
liveDvrKey: serverInfo.dvrKey,
liveTvSourceTitle: sourceTitle,
favoriteSource: source,
favoriteStoreKey: storeKey,
);
@@ -137,7 +137,7 @@ class _LiveTvShowScheduleScreenState extends State<LiveTvShowScheduleScreen>
SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final program = _programs[index];
final channel = findChannel(program.channelIdentifier);
final channel = findChannelForProgram(program);
void onTap() {
if (program.isCurrentlyAiring && channel != null) {
tuneChannel(channel);
+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 ?? '');
+2 -2
View File
@@ -140,7 +140,7 @@ class WhatsOnTabState extends State<WhatsOnTab> with LiveTvActionsMixin<WhatsOnT
}
void _onItemTap(LiveTvHubEntry entry) {
final channel = findChannel(entry.program.channelIdentifier);
final channel = findChannelForProgram(entry.program);
if (entry.program.isCurrentlyAiring && channel != null) {
// Live → play directly
@@ -189,7 +189,7 @@ class WhatsOnTabState extends State<WhatsOnTab> with LiveTvActionsMixin<WhatsOnT
onTap: _onItemTap,
onLongPress: (entry) => showProgramDetails(
program: entry.program,
channel: findChannel(entry.program.channelIdentifier),
channel: findChannelForProgram(entry.program),
posterThumb: entry.metadata.grandparentThumbPath ?? entry.metadata.thumbPath,
posterServerId: entry.metadata.serverId ?? '',
),
@@ -102,6 +102,8 @@ mixin _JellyfinLiveTvMethods on MediaServerCacheMixin {
channelCallSign: json['ChannelCallSign'] as String? ?? json['ChannelName'] as String?,
live: json['IsLive'] as bool?,
premiere: json['IsPremiere'] as bool?,
serverId: serverId,
serverName: serverName,
);
}
+83
View File
@@ -0,0 +1,83 @@
import '../models/livetv_channel.dart';
import 'live_tv_matching.dart';
class LiveTvChannelGroup {
final String key;
final String label;
final List<LiveTvChannel> channels;
const LiveTvChannelGroup({required this.key, required this.label, required this.channels});
LiveTvChannelGroup copyWith({String? label}) {
return LiveTvChannelGroup(key: key, label: label ?? this.label, channels: channels);
}
}
List<LiveTvChannelGroup> groupLiveTvChannelsBySource(List<LiveTvChannel> channels) {
final order = <String>[];
final bySource = <String, List<LiveTvChannel>>{};
final labels = <String, String>{};
for (final channel in channels) {
final key = liveTvChannelSourceKey(channel);
if (!bySource.containsKey(key)) {
order.add(key);
bySource[key] = [];
labels[key] = liveTvChannelSourceLabel(channel);
}
bySource[key]!.add(channel);
}
final groups = [
for (final key in order)
LiveTvChannelGroup(key: key, label: labels[key]!, channels: List.unmodifiable(bySource[key]!)),
];
final labelCounts = <String, int>{};
for (final group in groups) {
labelCounts[group.label] = (labelCounts[group.label] ?? 0) + 1;
}
return [
for (final group in groups)
if ((labelCounts[group.label] ?? 0) > 1) group.copyWith(label: _deduplicatedLabel(group)) else group,
];
}
String liveTvChannelSourceKey(LiveTvChannel channel) {
final serverId = _nonEmpty(channel.serverId) ?? '';
final providerSource = _nonEmpty(channel.favoriteSource) ?? _nonEmpty(channel.lineup) ?? '';
final dvrSource = _nonEmpty(channel.liveDvrKey) ?? '';
return '$serverId\u0000$providerSource\u0000$dvrSource';
}
String liveTvChannelSourceLabel(LiveTvChannel channel) {
final serverLabel = _nonEmpty(channel.serverName) ?? _nonEmpty(channel.serverId) ?? 'Live TV';
final sourceTitle = _nonEmpty(channel.liveTvSourceTitle);
if (sourceTitle == null || sourceTitle == serverLabel) return serverLabel;
return '$serverLabel - $sourceTitle';
}
String _deduplicatedLabel(LiveTvChannelGroup group) {
if (group.channels.isEmpty) return group.label;
final first = group.channels.first;
final suffixes = [
_nonEmpty(first.liveTvSourceTitle),
_nonEmpty(first.liveDvrKey),
liveTvProviderIdentifierForChannel(first),
];
String? suffix;
for (final value in suffixes) {
if (value != null && !group.label.contains(value)) {
suffix = value;
break;
}
}
if (suffix == null || group.label.contains(suffix)) return group.label;
return '${group.label} - $suffix';
}
String? _nonEmpty(String? value) {
final trimmed = value?.trim();
return trimmed == null || trimmed.isEmpty ? null : trimmed;
}
+42
View File
@@ -0,0 +1,42 @@
import '../models/livetv_channel.dart';
import '../models/livetv_program.dart';
bool liveTvProgramMatchesChannel(LiveTvProgram program, LiveTvChannel channel) {
final programChannel = _nonEmpty(program.channelIdentifier);
if (programChannel == null) return false;
if (programChannel != channel.key && programChannel != channel.identifier) return false;
if (!_nullableIdsMatch(program.serverId, channel.serverId)) return false;
if (!_nullableIdsMatch(program.liveDvrKey, channel.liveDvrKey)) return false;
final programProvider = _nonEmpty(program.providerIdentifier);
final channelProvider = liveTvProviderIdentifierForChannel(channel);
if (programProvider != null && channelProvider != null && programProvider != channelProvider) return false;
return true;
}
String? liveTvProviderIdentifierForChannel(LiveTvChannel channel) {
final source = _nonEmpty(channel.favoriteSource);
if (source != null) {
final uri = Uri.tryParse(source);
if (uri != null && uri.pathSegments.isNotEmpty) return _nonEmpty(uri.pathSegments.last);
final slashIndex = source.lastIndexOf('/');
if (slashIndex >= 0 && slashIndex < source.length - 1) {
return _nonEmpty(source.substring(slashIndex + 1));
}
}
return _nonEmpty(channel.lineup);
}
bool _nullableIdsMatch(String? a, String? b) {
final left = _nonEmpty(a);
final right = _nonEmpty(b);
return left == null || right == null || left == right;
}
String? _nonEmpty(String? value) {
final trimmed = value?.trim();
return trimmed == null || trimmed.isEmpty ? null : trimmed;
}