feat(tv): add What's On tab with API-driven hubs and show schedule

This commit is contained in:
edde746
2026-02-12 16:46:15 +01:00
parent fbb9982f44
commit ac1dae25b8
26 changed files with 2212 additions and 937 deletions
+936
View File
@@ -0,0 +1,936 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import '../../../i18n/strings.g.dart';
import '../../../models/livetv_channel.dart';
import '../../../models/livetv_program.dart';
import '../../../providers/multi_server_provider.dart';
import '../../../utils/app_logger.dart';
import '../../../utils/formatters.dart';
import '../../../utils/plex_image_helper.dart';
import '../../../utils/live_tv_player_navigation.dart';
import '../../../widgets/app_icon.dart';
class GuideTab extends StatefulWidget {
final List<LiveTvChannel> channels;
const GuideTab({super.key, required this.channels});
@override
State<GuideTab> createState() => _GuideTabState();
}
class _GuideTabState extends State<GuideTab> {
static const _slotWidth = 180.0;
static const _channelColumnWidth = 140.0;
static const _rowHeight = 64.0;
static const _timeHeaderHeight = 40.0;
static const _minutesPerSlot = 30;
List<LiveTvProgram> _programs = [];
bool _isLoading = true;
late DateTime _gridStart;
late DateTime _gridEnd;
final ScrollController _headerHorizontalController = ScrollController();
final ScrollController _gridHorizontalController = ScrollController();
final ScrollController _channelVerticalController = ScrollController();
bool _syncingScroll = false;
Timer? _timeIndicatorTimer;
final _dayPickerKey = GlobalKey();
@override
void initState() {
super.initState();
_initTimeRange();
_loadPrograms();
_gridHorizontalController.addListener(_syncGridToHeader);
_headerHorizontalController.addListener(_syncHeaderToGrid);
_timeIndicatorTimer = Timer.periodic(const Duration(minutes: 1), (_) {
if (mounted) setState(() {});
});
}
void _syncGridToHeader() {
if (_syncingScroll) return;
_syncingScroll = true;
if (_headerHorizontalController.hasClients) {
_headerHorizontalController.jumpTo(_gridHorizontalController.offset);
}
_syncingScroll = false;
}
void _syncHeaderToGrid() {
if (_syncingScroll) return;
_syncingScroll = true;
if (_gridHorizontalController.hasClients) {
_gridHorizontalController.jumpTo(_headerHorizontalController.offset);
}
_syncingScroll = false;
}
@override
void dispose() {
_gridHorizontalController.removeListener(_syncGridToHeader);
_headerHorizontalController.removeListener(_syncHeaderToGrid);
_headerHorizontalController.dispose();
_gridHorizontalController.dispose();
_channelVerticalController.dispose();
_timeIndicatorTimer?.cancel();
super.dispose();
}
void _initTimeRange() {
final now = DateTime.now();
_gridStart = DateTime(now.year, now.month, now.day, now.hour);
if (now.minute >= 30) {
_gridStart = _gridStart.add(const Duration(minutes: 30));
}
_gridStart = _gridStart.subtract(const Duration(hours: 1));
_gridEnd = _gridStart.add(const Duration(hours: 6));
}
void _shiftTimeRange(int hours) {
setState(() {
_gridStart = _gridStart.add(Duration(hours: hours));
_gridEnd = _gridStart.add(const Duration(hours: 6));
});
_loadPrograms();
}
void _jumpToNow() {
_initTimeRange();
_loadPrograms();
}
void _jumpToDay(DateTime day) {
final now = DateTime.now();
final isToday = day.year == now.year &&
day.month == now.month &&
day.day == now.day;
if (isToday) {
_jumpToNow();
return;
}
setState(() {
_gridStart = DateTime(day.year, day.month, day.day);
_gridEnd = _gridStart.add(const Duration(hours: 6));
});
_loadPrograms();
}
Future<void> _loadPrograms() async {
if (!mounted) return;
setState(() => _isLoading = true);
try {
final multiServer = context.read<MultiServerProvider>();
final liveTvServers = multiServer.liveTvServers;
final allPrograms = <LiveTvProgram>[];
for (final serverInfo in liveTvServers) {
final client = multiServer.getClientForServer(serverInfo.serverId);
if (client == null) continue;
final startEpoch = _gridStart.millisecondsSinceEpoch ~/ 1000;
final endEpoch = _gridEnd.millisecondsSinceEpoch ~/ 1000;
final programs = await client.getEpgGrid(
lineup: serverInfo.lineup,
beginsAt: startEpoch,
endsAt: endEpoch,
);
allPrograms.addAll(programs);
}
if (!mounted) return;
setState(() {
_programs = allPrograms;
_isLoading = false;
});
_scrollToNow();
} catch (e) {
appLogger.e('Failed to load guide programs', error: e);
if (mounted) {
setState(() => _isLoading = false);
}
}
}
void _scrollToNow() {
WidgetsBinding.instance.addPostFrameCallback((_) {
final now = DateTime.now();
final minutesSinceStart = now.difference(_gridStart).inMinutes;
final offset = (minutesSinceStart / _minutesPerSlot) * _slotWidth;
if (_gridHorizontalController.hasClients) {
_gridHorizontalController.jumpTo(
(offset - MediaQuery.of(context).size.width / 3)
.clamp(0, _gridHorizontalController.position.maxScrollExtent),
);
}
});
}
List<LiveTvProgram> _getProgramsForChannel(LiveTvChannel channel) {
final channelId = channel.identifier ?? channel.key;
return _programs.where((p) => p.channelIdentifier == channelId).toList()
..sort((a, b) => (a.beginsAt ?? 0).compareTo(b.beginsAt ?? 0));
}
double _totalGridWidth() {
final totalMinutes = _gridEnd.difference(_gridStart).inMinutes;
return (totalMinutes / _minutesPerSlot) * _slotWidth;
}
Future<void> _tuneChannel(LiveTvChannel channel) async {
final multiServer = context.read<MultiServerProvider>();
final serverInfo = multiServer.liveTvServers
.where((s) => s.serverId == channel.serverId)
.firstOrNull ??
multiServer.liveTvServers.firstOrNull;
if (serverInfo == null) return;
final client = multiServer.getClientForServer(serverInfo.serverId);
if (client == null) return;
await navigateToLiveTv(
context,
client: client,
dvrKey: serverInfo.dvrKey,
channel: channel,
channels: widget.channels,
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
}
return _buildGuideGrid(theme);
}
Widget _buildGuideGrid(ThemeData theme) {
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),
),
),
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(
itemCount: widget.channels.length,
itemExtent: _rowHeight,
itemBuilder: (context, index) {
final channel = widget.channels[index];
final programs = _getProgramsForChannel(channel);
return _buildProgramRow(channel, programs, theme);
},
),
),
),
),
),
],
),
),
],
);
}
String _dayLabel(DateTime day) {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final target = DateTime(day.year, day.month, day.day);
if (target == today) return t.liveTv.today;
final format = MaterialLocalizations.of(context);
final full = format.formatFullDate(target);
return full.split(',').first;
}
List<(String, int)> get _timeSlots => [
(t.liveTv.midnight, 0),
(t.liveTv.overnight, 2),
(t.liveTv.morning, 6),
(t.liveTv.daytime, 12),
(t.liveTv.evening, 18),
(t.liveTv.lateNight, 22),
];
RelativeRect _menuPosition() {
final renderBox =
_dayPickerKey.currentContext?.findRenderObject() as RenderBox?;
final overlay =
Overlay.of(context).context.findRenderObject() as RenderBox?;
if (renderBox == null || overlay == null) return RelativeRect.fill;
final buttonPos = renderBox.localToGlobal(Offset.zero);
final buttonSize = renderBox.size;
return RelativeRect.fromRect(
Rect.fromLTWH(
buttonPos.dx,
buttonPos.dy + buttonSize.height,
buttonSize.width,
0,
),
Offset.zero & overlay.size,
);
}
void _showDayPicker() {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final gridDay = DateTime(_gridStart.year, _gridStart.month, _gridStart.day);
final theme = Theme.of(context);
final days = <DateTime>[];
for (var i = 0; i < 8; i++) {
days.add(today.add(Duration(days: i)));
}
showMenu<Object>(
context: context,
position: _menuPosition(),
items: [
PopupMenuItem<String>(
value: 'now',
child: Text(t.liveTv.now, style: theme.textTheme.bodyMedium),
),
...days.map((day) {
final isSelected = day == gridDay;
final label = _dayLabel(day);
return PopupMenuItem<DateTime>(
value: day,
child: Row(
children: [
Expanded(
child: Text(
label,
style: theme.textTheme.bodyMedium?.copyWith(
color: isSelected ? theme.colorScheme.primary : null,
),
),
),
if (isSelected)
AppIcon(Symbols.check_rounded,
size: 18, color: theme.colorScheme.primary),
],
),
);
}),
],
).then((value) {
if (value == null) return;
if (value is String && value == 'now') {
_jumpToNow();
} else if (value is DateTime) {
_showTimeSlotPicker(value);
}
});
}
void _showTimeSlotPicker(DateTime day) {
final theme = Theme.of(context);
final label = _dayLabel(day).toUpperCase();
showMenu<int>(
context: context,
position: _menuPosition(),
items: [
PopupMenuItem<int>(
value: -1,
child: Row(
children: [
AppIcon(Symbols.chevron_left_rounded,
size: 20, color: theme.colorScheme.onSurface),
const SizedBox(width: 8),
Text(label,
style: theme.textTheme.titleSmall
?.copyWith(fontWeight: FontWeight.bold)),
],
),
),
const PopupMenuDivider(),
..._timeSlots.map((slot) {
return PopupMenuItem<int>(
value: slot.$2,
child: Text(slot.$1, style: theme.textTheme.bodyMedium),
);
}),
],
).then((value) {
if (value == null) return;
if (value == -1) {
_showDayPicker();
return;
}
setState(() {
_gridStart = DateTime(day.year, day.month, day.day, value);
_gridEnd = _gridStart.add(const Duration(hours: 6));
});
_loadPrograms();
});
}
Widget _buildTimeNavigation(ThemeData theme) {
final format = MaterialLocalizations.of(context);
final timeLabel =
format.formatTimeOfDay(TimeOfDay.fromDateTime(_gridStart));
final dayLabel = _dayLabel(_gridStart);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)),
),
),
child: Row(
children: [
IconButton(
icon: const AppIcon(Symbols.chevron_left_rounded),
onPressed: () => _shiftTimeRange(-2),
iconSize: 20,
visualDensity: VisualDensity.compact,
),
Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
GestureDetector(
key: _dayPickerKey,
onTap: _showDayPicker,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
dayLabel,
style: theme.textTheme.labelLarge,
),
const SizedBox(width: 2),
AppIcon(Symbols.arrow_drop_down_rounded,
size: 18, color: theme.colorScheme.onSurface),
],
),
),
const SizedBox(width: 8),
Text(
timeLabel,
style: theme.textTheme.labelLarge,
),
],
),
),
IconButton(
icon: const AppIcon(Symbols.chevron_right_rounded),
onPressed: () => _shiftTimeRange(2),
iconSize: 20,
visualDensity: VisualDensity.compact,
),
],
),
);
}
Widget _buildTimeHeader(ThemeData theme) {
final slots = <Widget>[];
var current = _gridStart;
while (current.isBefore(_gridEnd)) {
final timeStr =
'${current.hour.toString().padLeft(2, '0')}:${current.minute.toString().padLeft(2, '0')}';
slots.add(
SizedBox(
width: _slotWidth,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
timeStr,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
),
),
);
current = current.add(const Duration(minutes: _minutesPerSlot));
}
return Stack(
children: [
Row(children: slots),
_buildNowIndicator(theme),
],
);
}
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),
);
}
Widget _buildChannelCell(LiveTvChannel channel, ThemeData theme) {
final multiServer = context.read<MultiServerProvider>();
final client = multiServer.getClientForServer(channel.serverId ?? '');
String? imageUrl;
if (channel.thumb != null && client != null) {
imageUrl = PlexImageHelper.getOptimizedImageUrl(
client: client,
thumbPath: channel.thumb,
maxWidth: _channelColumnWidth - 16,
maxHeight: _rowHeight - 16,
devicePixelRatio: PlexImageHelper.effectiveDevicePixelRatio(context),
imageType: ImageType.logo,
);
}
return _ChannelCell(
rowHeight: _rowHeight,
channelColumnWidth: _channelColumnWidth,
imageUrl: imageUrl,
channel: channel,
theme: theme,
onTap: () => _tuneChannel(channel),
fallbackBuilder: () => _buildChannelNameFallback(channel, theme),
);
}
Widget _buildChannelNameFallback(LiveTvChannel channel, ThemeData theme) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (channel.number != null)
Text(
channel.number!,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
maxLines: 1,
),
Text(
channel.displayName,
style: theme.textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w500),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
),
],
);
}
Widget _buildProgramRow(
LiveTvChannel channel, List<LiveTvProgram> programs, ThemeData theme) {
if (programs.isEmpty) {
return Container(
height: _rowHeight,
decoration: BoxDecoration(
border: Border(
bottom:
BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)),
),
),
child: Center(
child: Text(
t.liveTv.noPrograms,
style: theme.textTheme.bodySmall
?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
),
);
}
final blocks = <Widget>[];
final gridStartEpoch = _gridStart.millisecondsSinceEpoch ~/ 1000;
final gridEndEpoch = _gridEnd.millisecondsSinceEpoch ~/ 1000;
for (final program in programs) {
final progStart =
(program.beginsAt ?? gridStartEpoch).clamp(gridStartEpoch, gridEndEpoch);
final progEnd =
(program.endsAt ?? gridEndEpoch).clamp(gridStartEpoch, gridEndEpoch);
if (progEnd <= progStart) continue;
final startOffset = progStart - gridStartEpoch;
final duration = progEnd - progStart;
final left = (startOffset / (_minutesPerSlot * 60)) * _slotWidth;
final width = (duration / (_minutesPerSlot * 60)) * _slotWidth;
blocks.add(
Positioned(
left: left,
width: width.clamp(2.0, double.infinity),
top: 0,
bottom: 0,
child: _buildProgramBlock(channel, program, theme, isLast: program == programs.last),
),
);
}
return Container(
height: _rowHeight,
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)),
),
),
child: Stack(
children: [
...blocks,
_buildNowIndicator(theme),
],
),
);
}
Widget _buildProgramBlock(
LiveTvChannel channel, LiveTvProgram program, ThemeData theme, {bool isLast = false}) {
final isCurrentlyAiring = program.isCurrentlyAiring;
final isPast = program.endsAt != null &&
program.endsAt! < DateTime.now().millisecondsSinceEpoch ~/ 1000;
return Opacity(
opacity: isPast ? 0.5 : 1.0,
child: Material(
color: isCurrentlyAiring
? theme.colorScheme.primaryContainer
: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(4),
child: InkWell(
borderRadius: BorderRadius.circular(4),
onTap: () => _showProgramDetails(channel, program),
child: Container(
decoration: BoxDecoration(
border: Border(
left: BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)),
right: isLast ? BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)) : BorderSide.none,
),
),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
program.grandparentTitle ?? program.title,
style: theme.textTheme.bodySmall?.copyWith(
fontWeight:
isCurrentlyAiring ? FontWeight.w600 : FontWeight.normal,
color: isCurrentlyAiring
? theme.colorScheme.onPrimaryContainer
: theme.colorScheme.onSurface,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (program.grandparentTitle != null)
Text(
'${program.parentIndex != null && program.index != null ? 'S${program.parentIndex}E${program.index} · ' : ''}${program.title}',
style: theme.textTheme.labelSmall?.copyWith(
color: isCurrentlyAiring
? theme.colorScheme.onPrimaryContainer
.withValues(alpha: 0.7)
: theme.colorScheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (program.startTime != null)
Text(
'${program.startTime!.hour.toString().padLeft(2, '0')}:${program.startTime!.minute.toString().padLeft(2, '0')} · ${formatDurationTextual(program.durationMinutes * 60000)}',
style: theme.textTheme.labelSmall?.copyWith(
color: isCurrentlyAiring
? theme.colorScheme.onPrimaryContainer
.withValues(alpha: 0.7)
: theme.colorScheme.onSurfaceVariant,
),
maxLines: 1,
),
],
),
),
),
),
);
}
void _showProgramDetails(LiveTvChannel channel, LiveTvProgram program) {
final theme = Theme.of(context);
final multiServer = context.read<MultiServerProvider>();
final client = multiServer.getClientForServer(channel.serverId ?? '');
String? posterUrl;
if (program.thumb != null && client != null) {
posterUrl = PlexImageHelper.getOptimizedImageUrl(
client: client,
thumbPath: program.thumb,
maxWidth: 80,
maxHeight: 120,
devicePixelRatio: PlexImageHelper.effectiveDevicePixelRatio(context),
imageType: ImageType.poster,
);
}
showModalBottomSheet(
context: context,
builder: (sheetContext) {
return Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (posterUrl != null) ...[
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Image.network(
posterUrl,
width: 80,
height: 120,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
),
),
const SizedBox(width: 14),
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
program.displayTitle,
style: theme.textTheme.titleMedium,
),
),
if (program.isCurrentlyAiring)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(4),
),
child: Text(
t.liveTv.live,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 11),
),
),
],
),
const SizedBox(height: 4),
Text(
'${channel.displayName} · ${program.startTime?.hour.toString().padLeft(2, '0')}:${program.startTime?.minute.toString().padLeft(2, '0')} - ${program.endTime?.hour.toString().padLeft(2, '0')}:${program.endTime?.minute.toString().padLeft(2, '0')} · ${formatDurationTextual(program.durationMinutes * 60000)}',
style: theme.textTheme.bodySmall
?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
if (program.summary != null &&
program.summary!.isNotEmpty) ...[
const SizedBox(height: 12),
Text(
program.summary!,
style: theme.textTheme.bodyMedium,
maxLines: 4,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
],
),
const SizedBox(height: 16),
Row(
children: [
if (program.isCurrentlyAiring)
FilledButton.icon(
onPressed: () {
Navigator.of(sheetContext).pop();
_tuneChannel(channel);
},
icon: const AppIcon(Symbols.play_arrow_rounded),
label: Text(t.common.play),
),
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: () {
Navigator.of(sheetContext).pop();
// TODO: Record action
},
icon: const AppIcon(Symbols.fiber_manual_record_rounded),
label: Text(t.liveTv.record),
),
],
),
],
),
);
},
);
}
}
class _ChannelCell extends StatefulWidget {
final double rowHeight;
final double channelColumnWidth;
final String? imageUrl;
final LiveTvChannel channel;
final ThemeData theme;
final VoidCallback onTap;
final Widget Function() fallbackBuilder;
const _ChannelCell({
required this.rowHeight,
required this.channelColumnWidth,
required this.imageUrl,
required this.channel,
required this.theme,
required this.onTap,
required this.fallbackBuilder,
});
@override
State<_ChannelCell> createState() => _ChannelCellState();
}
class _ChannelCellState extends State<_ChannelCell> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final theme = widget.theme;
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: widget.onTap,
child: Container(
height: widget.rowHeight,
padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: theme.dividerColor.withValues(alpha: 0.3)),
right: BorderSide(
color: theme.dividerColor.withValues(alpha: 0.3)),
),
),
child: Stack(
alignment: Alignment.center,
children: [
AnimatedOpacity(
opacity: _hovered ? 0.3 : 1.0,
duration: const Duration(milliseconds: 150),
child: widget.imageUrl != null && widget.imageUrl!.isNotEmpty
? Image.network(
widget.imageUrl!,
width: widget.channelColumnWidth - 16,
height: widget.rowHeight - 16,
fit: BoxFit.contain,
errorBuilder: (_, _, _) =>
widget.fallbackBuilder(),
)
: widget.fallbackBuilder(),
),
if (_hovered)
AppIcon(
Symbols.play_arrow_rounded,
size: 32,
color: theme.colorScheme.onSurface,
),
],
),
),
),
),
);
}
}