feat(livetv): group guide by source
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ?? '');
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user