fix(tv): guide UX fixes

This commit is contained in:
edde746
2026-02-13 18:21:05 +01:00
parent fd53acbb92
commit fa06f23b42
3 changed files with 140 additions and 88 deletions
+1 -1
View File
@@ -79,7 +79,7 @@ class ChannelMapping {
return ChannelMapping(
channelKey: json['channelKey'] as String?,
deviceIdentifier: json['deviceIdentifier'] as String?,
enabled: json['enabled'] == true || json['enabled'] == 1,
enabled: json['enabled'] == true || json['enabled'] == 1 || json['enabled'] == '1',
lineupIdentifier: json['lineupIdentifier'] as String?,
);
}
+34 -2
View File
@@ -111,14 +111,46 @@ class _LiveTvScreenState extends State<LiveTvScreen> with SingleTickerProviderSt
appLogger.d('Live TV DVRs: ${liveTvServers.map((s) => '${s.serverId}/${s.dvrKey} lineup=${s.lineup}').join(', ')}');
// Build a set of enabled channel keys per server from DVR mappings
final enabledKeysByServer = <String, Set<String>>{};
final queriedServers = <String>{};
for (final serverInfo in liveTvServers) {
if (!queriedServers.add(serverInfo.serverId)) continue;
try {
final client = multiServer.getClientForServer(serverInfo.serverId);
if (client == null) continue;
final dvrs = await client.getDvrs();
final enabledKeys = <String>{};
bool hasMappings = false;
for (final dvr in dvrs) {
if (dvr.channelMappings.isNotEmpty) {
hasMappings = true;
for (final m in dvr.channelMappings) {
if (m.enabled == true && m.channelKey != null) {
enabledKeys.add(m.channelKey!);
}
}
}
}
if (hasMappings) {
enabledKeysByServer[serverInfo.serverId] = enabledKeys;
}
} catch (e) {
appLogger.e('Failed to load DVR mappings for server ${serverInfo.serverId}', error: e);
}
}
for (final serverInfo in liveTvServers) {
try {
final client = multiServer.getClientForServer(serverInfo.serverId);
if (client == null) continue;
final channels = await client.getEpgChannels(lineup: serverInfo.lineup);
appLogger.d('Channels from DVR ${serverInfo.dvrKey}: ${channels.length} channels');
final enabledKeys = enabledKeysByServer[serverInfo.serverId];
appLogger.d('Channels from DVR ${serverInfo.dvrKey}: ${channels.length} channels (${enabledKeys?.length ?? 'all'} enabled)');
for (final channel in channels) {
// Skip disabled channels if DVR has mapping data
if (enabledKeys != null && !enabledKeys.contains(channel.key)) continue;
final dedupKey = '${serverInfo.serverId}:${channel.key}';
if (seenChannels.add(dedupKey)) {
allChannels.add(channel);
@@ -144,7 +176,7 @@ class _LiveTvScreenState extends State<LiveTvScreen> with SingleTickerProviderSt
_isLoading = false;
});
if (allChannels.isNotEmpty) {
if (allChannels.isNotEmpty && PlatformDetector.shouldUseSideNavigation(context)) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _focusCurrentTab();
});
+105 -85
View File
@@ -516,75 +516,120 @@ class GuideTabState extends State<GuideTab> {
return Column(
children: [
_buildTimeNavigation(theme),
Row(
children: [
SizedBox(width: _channelColumnWidth, height: _timeHeaderHeight),
Expanded(
child: SingleChildScrollView(
controller: _headerHorizontalController,
scrollDirection: Axis.horizontal,
physics: const ClampingScrollPhysics(),
child: SizedBox(
width: _totalGridWidth(),
height: _timeHeaderHeight,
child: _buildTimeHeader(theme),
),
),
),
],
),
Expanded(
child: Row(
children: [
SizedBox(
width: _channelColumnWidth,
child: ListView.builder(
controller: _channelVerticalController,
itemCount: widget.channels.length,
itemExtent: _rowHeight,
itemBuilder: (context, index) =>
_buildChannelCell(widget.channels[index], theme, index: index),
),
),
Expanded(
child: NotificationListener<ScrollNotification>(
onNotification: (notification) {
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,
physics: const ClampingScrollPhysics(),
child: SizedBox(
width: _totalGridWidth(),
child: ListView.builder(
controller: _gridVerticalController,
itemCount: widget.channels.length,
itemExtent: _rowHeight,
itemBuilder: (context, index) {
final channel = widget.channels[index];
final programs = _getProgramsForChannel(channel);
return _buildProgramRow(channel, programs, theme, channelIndex: index);
},
child: ListenableBuilder(
listenable: _gridHorizontalController,
builder: (context, child) {
return Stack(
children: [
child!,
_buildNowIndicatorOverlay(theme),
],
);
},
child: Column(
children: [
Row(
children: [
SizedBox(width: _channelColumnWidth, height: _timeHeaderHeight),
Expanded(
child: SingleChildScrollView(
controller: _headerHorizontalController,
scrollDirection: Axis.horizontal,
physics: const ClampingScrollPhysics(),
child: SizedBox(
width: _totalGridWidth(),
height: _timeHeaderHeight,
child: _buildTimeHeader(theme),
),
),
),
],
),
Expanded(
child: Row(
children: [
SizedBox(
width: _channelColumnWidth,
child: ListView.builder(
controller: _channelVerticalController,
physics: const NeverScrollableScrollPhysics(),
itemCount: widget.channels.length,
itemExtent: _rowHeight,
itemBuilder: (context, index) =>
_buildChannelCell(widget.channels[index], theme, index: index),
),
),
Expanded(
child: NotificationListener<ScrollNotification>(
onNotification: (notification) {
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,
physics: const ClampingScrollPhysics(),
child: SizedBox(
width: _totalGridWidth(),
child: ListView.builder(
controller: _gridVerticalController,
itemCount: widget.channels.length,
itemExtent: _rowHeight,
itemBuilder: (context, index) {
final channel = widget.channels[index];
final programs = _getProgramsForChannel(channel);
return _buildProgramRow(channel, programs, theme, channelIndex: index);
},
),
),
),
),
),
],
),
),
),
],
],
),
),
),
],
);
}
Widget _buildNowIndicatorOverlay(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 nowOffset = (minutesSinceStart / _minutesPerSlot) * _slotWidth;
final scrollOffset = _gridHorizontalController.hasClients
? _gridHorizontalController.offset
: 0.0;
final left = _channelColumnWidth + nowOffset - scrollOffset;
// Hide when scrolled behind the channel column
if (left < _channelColumnWidth) return const SizedBox.shrink();
final gridHeight = _timeHeaderHeight + widget.channels.length * _rowHeight;
return Positioned(
left: left,
top: 0,
height: gridHeight,
child: IgnorePointer(
child: Container(width: 2, color: Colors.red),
),
);
}
String _dayLabel(DateTime day) {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
@@ -851,29 +896,9 @@ class GuideTabState extends State<GuideTab> {
current = current.add(const Duration(minutes: _minutesPerSlot));
}
return Stack(
children: [
Row(children: slots),
_buildNowIndicator(theme),
],
);
return Row(children: slots);
}
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),
);
}
// ---------------------------------------------------------------------------
// Channel column
@@ -994,12 +1019,7 @@ class GuideTabState extends State<GuideTab> {
bottom: BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)),
),
),
child: Stack(
children: [
...blocks,
_buildNowIndicator(theme),
],
),
child: Stack(children: blocks),
);
}