fix(tv): guide UX fixes
This commit is contained in:
@@ -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?,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user