Files
plezy/lib/screens/livetv/tabs/guide_tab.dart
T

1604 lines
55 KiB
Dart

import 'dart:async';
import '../../../media/ids.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:flutter/services.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import '../../../focus/dpad_navigator.dart';
import '../../../focus/dpad_select_long_press_controller.dart';
import '../../../focus/focus_theme.dart';
import '../../../focus/input_mode_tracker.dart';
import '../../../focus/key_event_utils.dart';
import '../../../i18n/strings.g.dart';
import '../../../mixins/mounted_set_state_mixin.dart';
import '../../../models/livetv_channel.dart';
import '../../../models/livetv_program.dart';
import '../../../models/media_grab_operation.dart';
import '../../../providers/multi_server_provider.dart';
import '../../../media/media_server_client.dart';
import '../../../theme/mono_tokens.dart';
import '../../../utils/app_logger.dart';
import '../live_tv_refresh_lifecycle.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 '../../../utils/platform_detector.dart';
import '../../../widgets/app_icon.dart';
import '../../../widgets/app_menu.dart';
import '../../../widgets/clickable_cursor.dart';
import '../../../widgets/optimized_media_image.dart';
import '../livetv_styles.dart';
import '../program_details_sheet.dart';
class GuideTab extends StatefulWidget {
final List<LiveTvChannel> channels;
final bool Function(LiveTvChannel channel)? isFavoriteChannel;
final void Function(LiveTvChannel)? onToggleFavorite;
final VoidCallback? onNavigateUp;
final VoidCallback? onBack;
const GuideTab({
super.key,
required this.channels,
this.isFavoriteChannel,
this.onToggleFavorite,
this.onNavigateUp,
this.onBack,
});
@override
State<GuideTab> createState() => GuideTabState();
}
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, WidgetsBindingObserver {
static const _slotWidth = 180.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;
/// Minimum time away (backgrounded or on another section) before the
/// viewport is realigned to the live line on return.
static const _realignAfterAway = Duration(minutes: 30);
List<LiveTvProgram> _programs = [];
Set<String> _scheduledRecordingKeys = const {};
bool _isLoading = true;
late DateTime _gridStart;
late DateTime _gridEnd;
final ScrollController _headerHorizontalController = ScrollController();
final ScrollController _gridHorizontalController = ScrollController();
final ScrollController _channelVerticalController = ScrollController();
final ScrollController _gridVerticalController = ScrollController();
bool _syncingScroll = false;
Timer? _timeIndicatorTimer;
final _programSelectController = DpadSelectLongPressController();
final _dayPickerKey = GlobalKey();
// Stale-window catch-up state (#1297). The grid window is only auto
// re-anchored when it was live-anchored and has drifted fully into the
// past — deliberately picked day/time windows are never yanked.
bool _isGuideVisible = true;
DateTime? _hiddenSince;
bool _nowWasInWindow = true;
// Focus state
final FocusNode _guideFocusNode = FocusNode(debugLabel: 'guide_tab');
_GuideZone _focusZone = _GuideZone.timeNav;
int _timeNavIndex = 1; // 0=left arrow, 1=day picker, 2=right arrow
int _gridChannelIndex = 0;
int _gridColumn = 0; // 0=channel, 1=program
bool _hasFocus = false;
final ValueNotifier<bool> _hasFocusNotifier = ValueNotifier(false);
LiveTvProgram? _focusedProgram;
bool _pendingFocus = false;
/// Focus into the guide content (called from tab bar navigation or initial load).
void focusContent() {
if (!InputModeTracker.isKeyboardMode(context)) return;
// If still loading programs, defer until the Focus widget is in the tree.
if (_isLoading) {
_pendingFocus = true;
return;
}
_pendingFocus = false;
_guideFocusNode.requestFocus();
setState(() {
if (widget.channels.isNotEmpty) {
_focusZone = _GuideZone.grid;
_gridColumn = 0;
_gridChannelIndex = 0;
_focusedProgram = null;
} else {
_focusZone = _GuideZone.timeNav;
_timeNavIndex = 1;
}
});
}
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_initTimeRange();
_loadPrograms();
_gridHorizontalController.addListener(_syncGridToHeader);
_headerHorizontalController.addListener(_syncHeaderToGrid);
_startTimeIndicatorTimer();
}
void _startTimeIndicatorTimer() {
_timeIndicatorTimer?.cancel();
_timeIndicatorTimer = Timer.periodic(const Duration(minutes: 1), (_) {
_checkWindowDrift();
// ignore: no-empty-block - setState triggers rebuild to update time indicator
setStateIfMounted(() {});
});
}
void pauseRefresh() {
_hiddenSince ??= DateTime.now();
_timeIndicatorTimer?.cancel();
}
void resumeRefresh() {
_startTimeIndicatorTimer();
unawaited(_refreshScheduledRecordingKeys());
// Post-frame: resumeRefresh is invoked during tab transitions/build and
// the catch-up may setState (reload or scroll).
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _catchUpIfStale();
});
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Subscribes to TickerMode, so this fires whenever the guide is shown or
// hidden (main-screen IndexedStack section switch, opaque route push/pop).
final visible = TickerMode.valuesOf(context).enabled;
if (visible == _isGuideVisible) return;
_isGuideVisible = visible;
visible ? resumeRefresh() : pauseRefresh();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
switch (liveTvRefreshTransition(state)) {
case LiveTvRefreshLifecycleTransition.pause:
pauseRefresh();
case LiveTvRefreshLifecycleTransition.resume:
if (_isGuideVisible) resumeRefresh();
case LiveTvRefreshLifecycleTransition.ignore:
break;
}
}
@override
void didUpdateWidget(GuideTab oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.channels.isNotEmpty && _gridChannelIndex >= widget.channels.length) {
_gridChannelIndex = widget.channels.length - 1;
}
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_programSelectController.dispose();
_guideFocusNode.dispose();
_gridVerticalController.dispose();
_gridHorizontalController.removeListener(_syncGridToHeader);
_headerHorizontalController.removeListener(_syncHeaderToGrid);
_headerHorizontalController.dispose();
_gridHorizontalController.dispose();
_channelVerticalController.dispose();
_timeIndicatorTimer?.cancel();
_hasFocusNotifier.dispose();
super.dispose();
}
void _handleGuideFocusChange(bool hasFocus) {
if (_hasFocus == hasFocus) return;
if (!hasFocus) _resetProgramSelectLongPressState();
_hasFocus = hasFocus;
_hasFocusNotifier.value = hasFocus;
}
void _resetProgramSelectLongPressState() => _programSelectController.reset();
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;
}
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));
_nowWasInWindow = true;
}
void _shiftTimeRange(int hours) {
setState(() {
_gridStart = _gridStart.add(Duration(hours: hours));
_gridEnd = _gridStart.add(const Duration(hours: 6));
_nowWasInWindow = _nowInWindow(DateTime.now());
});
_loadPrograms();
}
void _jumpToNow() {
_initTimeRange();
_loadPrograms();
}
bool _nowInWindow(DateTime now) => !now.isBefore(_gridStart) && now.isBefore(_gridEnd);
/// Timer path: re-anchor only when a live-anchored window drifted fully past.
void _checkWindowDrift() {
if (!_isGuideVisible || _isLoading) return;
if (_nowWasInWindow && !_nowInWindow(DateTime.now())) _jumpToNow();
}
/// Active path (app resume / guide became visible): drift-jump, else
/// realign the viewport to the live line after a meaningful absence (#1297).
void _catchUpIfStale() {
if (!_isGuideVisible) return; // still hidden — keep _hiddenSince
final hiddenSince = _hiddenSince;
_hiddenSince = null; // evaluated while visible — consume it
if (_isLoading) return; // in-flight load already ends in _scrollToNow()
final now = DateTime.now();
if (_nowWasInWindow && !_nowInWindow(now)) {
_jumpToNow();
} else if (_nowInWindow(now) && hiddenSince != null && now.difference(hiddenSince) >= _realignAfterAway) {
_scrollToNow();
}
}
Future<void> _loadPrograms() async {
if (!mounted) return;
setState(() => _isLoading = true);
try {
final multiServer = context.read<MultiServerProvider>();
final liveTvServers = multiServer.liveTvServers;
final allPrograms = <LiveTvProgram>[];
final scheduledRecordingKeys = <String>{};
final queriedServers = <String>{};
for (final serverInfo in liveTvServers) {
if (!queriedServers.add(serverInfo.serverId)) continue;
try {
final genericClient = multiServer.getClientForServer(ServerId(serverInfo.serverId));
if (genericClient == null) continue;
final startEpoch = _gridStart.millisecondsSinceEpoch ~/ 1000;
final endEpoch = _gridEnd.millisecondsSinceEpoch ~/ 1000;
final fromDt = DateTime.fromMillisecondsSinceEpoch(startEpoch * 1000, isUtc: true);
final toDt = DateTime.fromMillisecondsSinceEpoch(endEpoch * 1000, isUtc: true);
final programs = await genericClient.liveTv.fetchSchedule(from: fromDt, to: toDt);
allPrograms.addAll(programs);
await _addScheduledRecordingKeysForServer(
client: genericClient,
serverId: ServerId(serverInfo.serverId),
keys: scheduledRecordingKeys,
);
} catch (e) {
appLogger.e('Failed to load programs from server ${serverInfo.serverId}', error: e);
}
}
if (!mounted) return;
final shouldFocus = _pendingFocus;
setState(() {
_programs = allPrograms;
_scheduledRecordingKeys = scheduledRecordingKeys;
_isLoading = false;
// Focus tracking compares by identity, so a reload orphans the
// focused program — re-resolve it against the fresh list.
if (_focusZone == _GuideZone.grid && _gridColumn == 1 && _focusedProgram != null) {
final focused = _focusedProgram;
if (!_programs.any((p) => identical(p, focused))) {
_focusedProgram = _findCurrentProgram(_gridChannelIndex);
}
}
});
_scrollToNow();
if (shouldFocus) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) focusContent();
});
}
} catch (e) {
appLogger.e('Failed to load guide programs', error: e);
if (mounted) {
setState(() => _isLoading = false);
}
}
}
Future<void> _refreshScheduledRecordingKeys() async {
if (!mounted) return;
final multiServer = context.read<MultiServerProvider>();
final scheduledRecordingKeys = <String>{};
final queriedServers = <String>{};
for (final serverInfo in multiServer.liveTvServers) {
if (!queriedServers.add(serverInfo.serverId)) continue;
final client = multiServer.getClientForServer(ServerId(serverInfo.serverId));
if (client == null) continue;
await _addScheduledRecordingKeysForServer(
client: client,
serverId: ServerId(serverInfo.serverId),
keys: scheduledRecordingKeys,
);
}
if (!mounted) return;
setState(() => _scheduledRecordingKeys = scheduledRecordingKeys);
}
Future<void> _addScheduledRecordingKeysForServer({
required MediaServerClient client,
required ServerId serverId,
required Set<String> keys,
}) async {
final dvr = client.liveTvDvr;
if (dvr == null) return;
try {
final grabs = await dvr.fetchScheduledRecordings();
for (final grab in grabs) {
_addRecordingKeysForGrab(grab, serverId: ServerId(serverId), keys: keys);
}
} catch (e) {
appLogger.d('Failed to load scheduled recordings for $serverId', error: e);
}
try {
final rules = await dvr.fetchRecordingRules(includeGrabs: true, includeStorage: false);
for (final rule in rules) {
for (final grab in rule.grabOperations) {
_addRecordingKeysForGrab(grab, serverId: ServerId(serverId), keys: keys);
}
}
} catch (e) {
appLogger.d('Failed to load active recording grabs for $serverId', error: e);
}
}
void _addRecordingKeysForGrab(MediaGrabOperation grab, {required ServerId serverId, required Set<String> keys}) {
if (!_isActiveScheduledGrab(grab)) return;
final program = grab.program;
if (program == null) return;
keys.addAll(_recordingKeysForProgram(program, fallbackServerId: serverId));
}
bool _isActiveScheduledGrab(MediaGrabOperation grab) {
final status = grab.status?.trim().toLowerCase();
return status == null || status.isEmpty || status == 'scheduled' || status == 'grabbing' || status == 'recording';
}
bool _isRecordingScheduled(LiveTvProgram program) {
final keys = _recordingKeysForProgram(program);
return keys.any(_scheduledRecordingKeys.contains);
}
void _handleRecordingStateChanged(LiveTvProgram program, bool isScheduled) {
final keys = _recordingKeysForProgram(program);
if (keys.isNotEmpty) {
setState(() {
final next = {..._scheduledRecordingKeys};
if (isScheduled) {
next.addAll(keys);
} else {
next.removeAll(keys);
}
_scheduledRecordingKeys = next;
});
}
unawaited(_refreshScheduledRecordingKeys());
}
Set<String> _recordingKeysForProgram(LiveTvProgram program, {String? fallbackServerId}) {
final serverId = _nonEmpty(program.serverId) ?? _nonEmpty(fallbackServerId);
if (serverId == null) return const <String>{};
final keys = <String>{};
void addMediaId(String? value) {
final normalized = _nonEmpty(value);
if (normalized != null) keys.add(_recordingKey(ServerId(serverId), 'media', normalized));
}
addMediaId(program.ratingKey);
addMediaId(program.guid);
addMediaId(program.key);
final channelIdentifier = _nonEmpty(program.channelIdentifier);
final beginsAt = program.beginsAt;
if (channelIdentifier != null && beginsAt != null) {
keys.add(_recordingKey(ServerId(serverId), 'slot', '$channelIdentifier|$beginsAt|${program.endsAt ?? ''}'));
}
return keys;
}
String _recordingKey(ServerId serverId, String type, String value) => '$serverId\u0000$type\u0000$value';
String? _nonEmpty(String? value) {
final trimmed = value?.trim();
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();
final minutesSinceStart = now.difference(_gridStart).inMinutes;
final offset = (minutesSinceStart / _minutesPerSlot) * _slotWidth;
if (_gridHorizontalController.hasClients) {
_gridHorizontalController.jumpTo(
(offset - MediaQuery.sizeOf(context).width / 3).clamp(0, _gridHorizontalController.position.maxScrollExtent),
);
}
});
}
List<LiveTvProgram> _getProgramsForChannel(LiveTvChannel channel) {
return _programs.where((program) => liveTvProgramMatchesChannel(program, channel)).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>();
await navigateToLiveTv(context, multiServer: multiServer, channel: channel, channels: widget.channels);
}
void _activateProgram(LiveTvChannel channel, LiveTvProgram program) {
if (PlatformDetector.isTV() && program.isCurrentlyAiring) {
_tuneChannel(channel);
return;
}
_showProgramDetails(channel, program);
}
({LiveTvChannel channel, LiveTvProgram program})? _focusedProgramTarget() {
if (_focusZone != _GuideZone.grid || _gridColumn != 1) return null;
final program = _focusedProgram;
if (program == null) return null;
if (_gridChannelIndex < 0 || _gridChannelIndex >= widget.channels.length) return null;
return (channel: widget.channels[_gridChannelIndex], program: program);
}
KeyEventResult _handleFocusedProgramSelectKey(KeyEvent event) {
final target = _focusedProgramTarget();
if (target == null) return KeyEventResult.ignored;
return _programSelectController.handleKeyEvent(
event,
isOwnerActive: () => mounted && _focusedProgramTarget() == target,
onShortPress: () => _activateProgram(target.channel, target.program),
onLongPress: () {
_programSelectController.reset();
_showProgramDetails(target.channel, target.program);
},
);
}
KeyEventResult _handleFocusedProgramContextMenuKey(KeyEvent event) {
if (!event.logicalKey.isContextMenuKey || !event.isActionable) return KeyEventResult.ignored;
final target = _focusedProgramTarget();
if (target == null) return KeyEventResult.ignored;
_resetProgramSelectLongPressState();
_showProgramDetails(target.channel, target.program);
return KeyEventResult.handled;
}
KeyEventResult _handleKeyEvent(FocusNode _, KeyEvent event) {
final key = event.logicalKey;
if (SelectKeyUpSuppressor.consumeIfSuppressed(event)) {
if (event is KeyUpEvent && key.isSelectKey) {
_resetProgramSelectLongPressState();
}
return KeyEventResult.handled;
}
// Back key
if (key.isBackKey) {
if (BackKeyUpSuppressor.consumeIfSuppressed(event)) {
return KeyEventResult.handled;
}
if (_focusZone == _GuideZone.grid) {
if (event is KeyUpEvent) {
setState(() {
_focusZone = _GuideZone.timeNav;
_timeNavIndex = 1;
});
}
return KeyEventResult.handled;
}
return handleBackKeyAction(event, () => widget.onBack?.call());
}
if (PlatformDetector.isTV()) {
final selectResult = _handleFocusedProgramSelectKey(event);
if (selectResult != KeyEventResult.ignored) return selectResult;
}
final contextMenuResult = _handleFocusedProgramContextMenuKey(event);
if (contextMenuResult != KeyEventResult.ignored) return contextMenuResult;
if (!event.isActionable) return KeyEventResult.ignored;
return _focusZone == _GuideZone.timeNav ? _handleTimeNavKey(key) : _handleGridKey(key);
}
KeyEventResult _handleTimeNavKey(LogicalKeyboardKey key) {
if (key.isLeftKey) {
if (_timeNavIndex > 0) {
setState(() => _timeNavIndex--);
} else {
widget.onBack?.call();
}
return KeyEventResult.handled;
}
if (key.isRightKey) {
if (_timeNavIndex < 2) setState(() => _timeNavIndex++);
return KeyEventResult.handled;
}
if (key.isDownKey) {
if (widget.channels.isNotEmpty) {
setState(() {
_focusZone = _GuideZone.grid;
_gridColumn = 0;
_focusedProgram = null;
});
_scrollToChannel(_gridChannelIndex);
}
return KeyEventResult.handled;
}
if (key.isUpKey) {
widget.onNavigateUp?.call();
return KeyEventResult.handled;
}
if (key.isSelectKey) {
switch (_timeNavIndex) {
case 0:
_shiftTimeRange(-2);
case 1:
_showDayPicker();
case 2:
_shiftTimeRange(2);
}
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
KeyEventResult _handleGridKey(LogicalKeyboardKey key) {
if (key.isUpKey) {
if (_gridChannelIndex > 0) {
setState(() {
_gridChannelIndex--;
if (_gridColumn == 1) _focusedProgram = _findCurrentProgram(_gridChannelIndex);
});
_scrollToChannel(_gridChannelIndex);
} else {
setState(() {
_focusZone = _GuideZone.timeNav;
_timeNavIndex = 1;
});
}
return KeyEventResult.handled;
}
if (key.isDownKey) {
if (_gridChannelIndex < widget.channels.length - 1) {
setState(() {
_gridChannelIndex++;
if (_gridColumn == 1) _focusedProgram = _findCurrentProgram(_gridChannelIndex);
});
_scrollToChannel(_gridChannelIndex);
}
return KeyEventResult.handled;
}
if (key.isRightKey) {
if (_gridColumn == 0) {
final program = _findCurrentProgram(_gridChannelIndex);
if (program != null) {
setState(() {
_gridColumn = 1;
_focusedProgram = program;
});
_scrollToProgramTime(program);
}
} else {
// Already in program column — move to next program
_navigateToAdjacentProgram(_gridChannelIndex, forward: true);
}
return KeyEventResult.handled;
}
if (key.isLeftKey) {
if (_gridColumn == 1) {
// Try moving to previous program; if at first program, go back to channel column
if (!_navigateToAdjacentProgram(_gridChannelIndex, forward: false)) {
setState(() {
_gridColumn = 0;
_focusedProgram = null;
});
}
} else {
widget.onBack?.call();
}
return KeyEventResult.handled;
}
if (key.isSelectKey) {
if (_gridChannelIndex >= 0 && _gridChannelIndex < widget.channels.length) {
final channel = widget.channels[_gridChannelIndex];
if (_gridColumn == 0) {
_tuneChannel(channel);
} else if (_focusedProgram != null) {
_activateProgram(channel, _focusedProgram!);
}
}
return KeyEventResult.handled;
}
// 'F' key toggles favorite on focused channel
if (key == LogicalKeyboardKey.keyF && _gridColumn == 0) {
if (_gridChannelIndex >= 0 && _gridChannelIndex < widget.channels.length) {
widget.onToggleFavorite?.call(widget.channels[_gridChannelIndex]);
}
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
LiveTvProgram? _findCurrentProgram(int channelIndex) {
if (channelIndex < 0 || channelIndex >= widget.channels.length) return null;
final channel = widget.channels[channelIndex];
final programs = _getProgramsForChannel(channel);
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
// Currently airing
for (final p in programs) {
if ((p.beginsAt ?? 0) <= now && (p.endsAt ?? 0) > now) return p;
}
// First future program
for (final p in programs) {
if ((p.endsAt ?? 0) > now) return p;
}
return programs.firstOrNull;
}
/// Navigate to the next or previous program on the same channel.
/// Returns true if navigation succeeded, false if at the boundary.
bool _navigateToAdjacentProgram(int channelIndex, {required bool forward}) {
if (channelIndex < 0 || channelIndex >= widget.channels.length) return false;
final channel = widget.channels[channelIndex];
final programs = _getProgramsForChannel(channel);
if (programs.isEmpty || _focusedProgram == null) return false;
final currentIndex = programs.indexWhere((p) => identical(p, _focusedProgram));
if (currentIndex < 0) return false;
final nextIndex = forward ? currentIndex + 1 : currentIndex - 1;
if (nextIndex < 0 || nextIndex >= programs.length) return false;
setState(() {
_focusedProgram = programs[nextIndex];
});
_scrollToProgramTime(_focusedProgram);
return true;
}
void _scrollToChannel(int index) {
if (!_gridVerticalController.hasClients) return;
final targetTop = _rowTopForChannelIndex(index);
final targetBottom = targetTop + _rowHeight;
final viewportTop = _gridVerticalController.offset;
final viewportBottom = viewportTop + _gridVerticalController.position.viewportDimension;
double? newOffset;
if (targetTop < viewportTop) {
newOffset = targetTop;
} else if (targetBottom > viewportBottom) {
newOffset = targetBottom - _gridVerticalController.position.viewportDimension;
}
if (newOffset != null) {
final clamped = newOffset.clamp(0.0, _gridVerticalController.position.maxScrollExtent);
_gridVerticalController.animateTo(clamped, duration: const Duration(milliseconds: 150), curve: Curves.easeOut);
if (_channelVerticalController.hasClients) {
_channelVerticalController.animateTo(
clamped.clamp(0.0, _channelVerticalController.position.maxScrollExtent),
duration: const Duration(milliseconds: 150),
curve: Curves.easeOut,
);
}
}
}
void _scrollToProgramTime(LiveTvProgram? program) {
if (program == null || !_gridHorizontalController.hasClients) return;
final gridStartEpoch = _gridStart.millisecondsSinceEpoch ~/ 1000;
final gridEndEpoch = _gridEnd.millisecondsSinceEpoch ~/ 1000;
final progStart = (program.beginsAt ?? gridStartEpoch).clamp(gridStartEpoch, gridEndEpoch);
final startOffset = progStart - gridStartEpoch;
final left = (startOffset / (_minutesPerSlot * 60)) * _slotWidth;
final viewportWidth = _gridHorizontalController.position.viewportDimension;
final currentOffset = _gridHorizontalController.offset;
if (left < currentOffset || left > currentOffset + viewportWidth - 100) {
final maxScroll = _gridHorizontalController.position.maxScrollExtent;
_gridHorizontalController.jumpTo((left - 50).clamp(0.0, maxScroll));
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
}
return Focus(
focusNode: _guideFocusNode,
onFocusChange: _handleGuideFocusChange,
onKeyEvent: _handleKeyEvent,
child: _buildGuideGrid(theme),
);
}
Widget _buildGuideGrid(ThemeData theme) {
return ValueListenableBuilder<bool>(
valueListenable: _hasFocusNotifier,
builder: (context, hasFocus, child) {
final rows = _guideRows;
return Column(
children: [
_buildTimeNavigation(theme),
Expanded(
child: ListenableBuilder(
listenable: _gridHorizontalController,
builder: (context, child) {
return Stack(children: [child!, _buildNowIndicatorOverlay(theme)]);
},
child: Column(
children: [
Row(
children: [
const 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: 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(
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: rows.length,
itemBuilder: (context, 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,
),
};
},
),
),
),
),
),
],
),
),
],
),
),
),
],
);
},
);
}
Widget _buildNowIndicatorOverlay(ThemeData _) {
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 + _guideContentHeight(_guideRows);
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);
final target = DateTime(day.year, day.month, day.day);
if (target == today) return t.liveTv.today;
return DateFormat('EEEE', LocaleSettings.currentLocale.languageCode).format(target);
}
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),
];
Rect? _menuAnchorRect() {
final renderBox = _dayPickerKey.currentContext?.findRenderObject() as RenderBox?;
if (renderBox == null) return null;
final buttonPos = renderBox.localToGlobal(Offset.zero);
final buttonSize = renderBox.size;
return Rect.fromLTWH(buttonPos.dx, buttonPos.dy, buttonSize.width, buttonSize.height);
}
Future<void> _showDayPicker() async {
final anchorRect = _menuAnchorRect();
if (anchorRect == null) return;
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final gridDay = DateTime(_gridStart.year, _gridStart.month, _gridStart.day);
final days = <DateTime>[];
for (var i = 0; i < 8; i++) {
days.add(today.add(Duration(days: i)));
}
final value = await showAppMenu<Object>(
context,
anchorRect: anchorRect,
focusFirstItem: InputModeTracker.isKeyboardMode(context),
entries: [
AppMenuItem<Object>(value: 'now', label: t.liveTv.now),
...days.map((day) {
final isSelected = day == gridDay;
final label = _dayLabel(day);
return AppMenuItem<Object>(value: day, label: label, selected: isSelected);
}),
],
);
if (!mounted) return;
if (value == null) {
_guideFocusNode.requestFocus();
return;
}
if (value is String && value == 'now') {
_jumpToNow();
_guideFocusNode.requestFocus();
} else if (value is DateTime) {
await _showTimeSlotPicker(value);
}
}
Future<void> _showTimeSlotPicker(DateTime day) async {
final anchorRect = _menuAnchorRect();
if (anchorRect == null) return;
final label = _dayLabel(day).toUpperCase();
final value = await showAppMenu<int>(
context,
anchorRect: anchorRect,
focusFirstItem: InputModeTracker.isKeyboardMode(context),
entries: [
AppMenuItem<int>(
value: -1,
icon: Symbols.chevron_left_rounded,
child: Text(label, style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: .bold)),
),
const AppMenuDivider<int>(),
..._timeSlots.map((slot) {
return AppMenuItem<int>(value: slot.$2, label: slot.$1);
}),
],
);
if (!mounted) return;
if (value == null) {
_guideFocusNode.requestFocus();
return;
}
if (value == -1) {
await _showDayPicker();
return;
}
setState(() {
_gridStart = DateTime(day.year, day.month, day.day, value);
_gridEnd = _gridStart.add(const Duration(hours: 6));
_nowWasInWindow = _nowInWindow(DateTime.now());
});
unawaited(_loadPrograms());
_guideFocusNode.requestFocus();
}
Widget _timeNavFocusWrap({required Widget child, required int index}) {
final isFocused = _hasFocus && _focusZone == _GuideZone.timeNav && _timeNavIndex == index;
if (!isFocused) return child;
return Container(
decoration: FocusTheme.textFillFocusDecoration(context, isFocused: true, borderRadius: MonoTokens.radiusFull),
child: child,
);
}
Widget _buildTimeNavigation(ThemeData theme) {
final timeLabel = formatClockTime(_gridStart, is24Hour: MediaQuery.alwaysUse24HourFormatOf(context));
final dayLabel = _dayLabel(_gridStart);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
child: Row(
children: [
_timeNavFocusWrap(
index: 0,
child: IconButton(
icon: const AppIcon(Symbols.chevron_left_rounded),
onPressed: () => _shiftTimeRange(-2),
iconSize: 20,
visualDensity: VisualDensity.compact,
),
),
Expanded(
child: Row(
mainAxisAlignment: .center,
children: [
_timeNavFocusWrap(
index: 1,
child: ClickableCursor(
child: GestureDetector(
key: _dayPickerKey,
onTap: _showDayPicker,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: tokens(context).text.withValues(alpha: 0.08),
borderRadius: const BorderRadius.all(Radius.circular(MonoTokens.radiusFull)),
),
child: Row(
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),
],
),
),
_timeNavFocusWrap(
index: 2,
child: IconButton(
icon: const AppIcon(Symbols.chevron_right_rounded),
onPressed: () => _shiftTimeRange(2),
iconSize: 20,
visualDensity: VisualDensity.compact,
),
),
],
),
);
}
Widget _buildTimeHeader(ThemeData theme) {
final is24Hour = MediaQuery.alwaysUse24HourFormatOf(context);
final slots = <Widget>[];
var current = _gridStart;
while (current.isBefore(_gridEnd)) {
final timeStr = formatClockTime(current, is24Hour: is24Hour);
slots.add(
SizedBox(
width: _slotWidth,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Align(
alignment: .centerLeft,
child: Text(timeStr, style: theme.textTheme.labelSmall?.copyWith(color: tokens(context).textMuted)),
),
),
),
);
current = current.add(const Duration(minutes: _minutesPerSlot));
}
return Row(children: slots);
}
Widget _buildSourceHeaderCell(String label, ThemeData theme) {
return Container(
height: _sourceHeaderRowHeight,
padding: const EdgeInsets.symmetric(horizontal: 8),
alignment: .centerLeft,
child: Text(
label,
style: theme.textTheme.labelSmall?.copyWith(color: tokens(context).textMuted, fontWeight: .w700),
maxLines: 2,
overflow: .ellipsis,
),
);
}
Widget _buildSourceHeaderGridRow(String label, ThemeData theme) {
return SizedBox(
height: _sourceHeaderRowHeight,
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: .centerLeft,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Text(
label,
style: theme.textTheme.labelSmall?.copyWith(
color: tokens(context).textMuted,
fontWeight: .w700,
letterSpacing: 0.3,
),
maxLines: 1,
overflow: .ellipsis,
),
),
),
),
),
);
}
Widget _buildChannelCell(LiveTvChannel channel, ThemeData theme, {required int index}) {
final multiServer = context.read<MultiServerProvider>();
final serverId = serverIdOrNull(channel.serverId);
final client = serverId == null ? null : multiServer.getClientForServer(serverId);
final isFocused = _hasFocus && _focusZone == _GuideZone.grid && _gridColumn == 0 && _gridChannelIndex == index;
return _ChannelCell(
rowHeight: _rowHeight,
channelColumnWidth: _channelColumnWidth,
channelThumb: channel.thumb,
client: client,
channel: channel,
theme: theme,
onTap: () => _tuneChannel(channel),
onLongPress: widget.onToggleFavorite != null ? () => widget.onToggleFavorite!(channel) : null,
isFocused: isFocused,
isFavorite: widget.isFavoriteChannel?.call(channel) ?? false,
fallbackBuilder: () => _buildChannelNameFallback(channel, theme),
);
}
Widget _buildChannelNameFallback(LiveTvChannel channel, ThemeData theme) {
return Column(
mainAxisAlignment: .center,
children: [
if (channel.number != null)
Text(
channel.number!,
style: theme.textTheme.labelSmall?.copyWith(color: tokens(context).textMuted),
maxLines: 1,
),
Text(
channel.displayName,
style: theme.textTheme.bodySmall?.copyWith(fontWeight: .w500),
maxLines: 1,
overflow: .ellipsis,
textAlign: TextAlign.center,
),
],
);
}
Widget _buildProgramRow(
LiveTvChannel channel,
List<LiveTvProgram> programs,
ThemeData theme, {
required int channelIndex,
}) {
if (programs.isEmpty) {
final tk = tokens(context);
return SizedBox(
height: _rowHeight,
child: Padding(
padding: EdgeInsets.only(right: tk.groupGap, bottom: tk.groupGap),
child: Material(
color: Color.alphaBlend(tk.surface.withValues(alpha: 0.5), tk.bg),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(tk.radiusXs)),
child: Center(
child: Text(t.liveTv.noPrograms, style: theme.textTheme.bodySmall?.copyWith(color: tk.textMuted)),
),
),
),
);
}
final blocks = <Widget>[];
final gridStartEpoch = _gridStart.millisecondsSinceEpoch ~/ 1000;
final gridEndEpoch = _gridEnd.millisecondsSinceEpoch ~/ 1000;
// Determine which program is focused in this row
final focusProg =
(_hasFocus && _focusZone == _GuideZone.grid && _gridColumn == 1 && _gridChannelIndex == channelIndex)
? _focusedProgram
: null;
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;
// Keep slivers wide enough to survive the trailing groupGap padding.
final clampedWidth = width.clamp(6.0, double.infinity);
blocks.add(
Positioned(
left: left,
width: clampedWidth,
top: 0,
bottom: 0,
child: _buildProgramBlock(
channel,
program,
theme,
isFocused: identical(program, focusProg),
tileLeft: left,
tileWidth: clampedWidth,
),
),
);
}
return SizedBox(
height: _rowHeight,
child: Stack(children: blocks),
);
}
Widget _buildProgramBlock(
LiveTvChannel channel,
LiveTvProgram program,
ThemeData theme, {
bool isFocused = false,
double tileLeft = 0,
double tileWidth = 0,
}) {
final tk = tokens(context);
final isCurrentlyAiring = program.isCurrentlyAiring;
final isPast = program.endsAt != null && program.endsAt! < DateTime.now().millisecondsSinceEpoch ~/ 1000;
final isRecordingScheduled = _isRecordingScheduled(program);
final Color fillColor;
final Color titleColor;
final Color subtitleColor;
if (isFocused) {
// Inverted focus card: primary == text in the mono theme, so the cursor
// reads as a solid inverted cell (white card, dark text in dark mode).
fillColor = theme.colorScheme.primary;
titleColor = theme.colorScheme.onPrimary;
subtitleColor = theme.colorScheme.onPrimary.withValues(alpha: 0.7);
} else if (isPast) {
fillColor = Color.alphaBlend(tk.surface.withValues(alpha: 0.5), tk.bg);
titleColor = tk.text.withValues(alpha: 0.5);
subtitleColor = tk.text.withValues(alpha: 0.3);
} else if (isCurrentlyAiring) {
fillColor = airingFill(context);
titleColor = tk.text;
subtitleColor = tk.textMuted;
} else {
fillColor = tk.surface;
titleColor = tk.text;
subtitleColor = tk.textMuted;
}
final radius = BorderRadius.circular(isFocused ? tk.radiusSm : tk.radiusXs);
return Padding(
padding: EdgeInsets.only(right: tk.groupGap, bottom: tk.groupGap),
child: Material(
color: fillColor,
shape: RoundedRectangleBorder(borderRadius: radius),
child: InkWell(
borderRadius: radius,
mouseCursor: SystemMouseCursors.click,
canRequestFocus: false,
onTap: () => _activateProgram(channel, program),
onLongPress: () => _showProgramDetails(channel, program),
onSecondaryTap: () => _showProgramDetails(channel, program),
child: ListenableBuilder(
listenable: _gridHorizontalController,
builder: (context, _) {
const basePadding = 6.0;
final scrollOffset = _gridHorizontalController.hasClients ? _gridHorizontalController.offset : 0.0;
final maxInset = (tileWidth - tk.groupGap - 2 * basePadding - 20).clamp(0.0, double.infinity);
final leftInset = (scrollOffset - tileLeft).clamp(0.0, maxInset);
return Padding(
padding: .fromLTRB(basePadding + leftInset, 4, basePadding, 4),
child: Column(
crossAxisAlignment: .start,
mainAxisAlignment: .center,
children: [
Row(
children: [
if (isRecordingScheduled) ...[
_RecordingDot(color: Colors.red, tooltip: t.liveTv.recordingScheduled),
const SizedBox(width: 5),
],
Expanded(
child: Text(
program.grandparentTitle ?? program.title,
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: .w600, color: titleColor),
maxLines: 1,
overflow: .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: subtitleColor),
maxLines: 1,
overflow: .ellipsis,
),
if (program.startTime != null)
Text(
'${formatClockTime(program.startTime!, is24Hour: MediaQuery.alwaysUse24HourFormatOf(context))} · ${formatDurationTextual(program.durationMinutes * 60_000)}',
style: theme.textTheme.labelSmall?.copyWith(color: subtitleColor),
maxLines: 1,
overflow: .ellipsis,
),
],
),
);
},
),
),
),
);
}
void _showProgramDetails(LiveTvChannel channel, LiveTvProgram program) {
final multiServer = context.read<MultiServerProvider>();
final serverId = serverIdOrNull(channel.serverId);
final client = serverId == null ? null : multiServer.getClientForServer(serverId);
String? posterUrl;
if (program.thumb != null && client != null) {
posterUrl = MediaImageHelper.getOptimizedImageUrl(
client: client,
thumbPath: program.thumb,
maxWidth: 80,
maxHeight: 120,
devicePixelRatio: MediaImageHelper.effectiveDevicePixelRatio(context),
imageType: ImageType.poster,
);
}
showProgramDetailsSheet(
context,
program: program,
channel: channel,
posterUrl: posterUrl,
onTuneChannel: () => _tuneChannel(channel),
client: client,
onRecordingStateChanged: (isScheduled) => _handleRecordingStateChanged(program, isScheduled),
);
}
}
class _RecordingDot extends StatelessWidget {
final Color color;
final String tooltip;
const _RecordingDot({required this.color, required this.tooltip});
@override
Widget build(BuildContext context) {
return Tooltip(
message: tooltip,
child: Semantics(
label: tooltip,
child: Container(
width: 8,
height: 8,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
),
);
}
}
class _ChannelCell extends StatefulWidget {
final double rowHeight;
final double channelColumnWidth;
final String? channelThumb;
final MediaServerClient? client;
final LiveTvChannel channel;
final ThemeData theme;
final VoidCallback onTap;
final VoidCallback? onLongPress;
final bool isFocused;
final bool isFavorite;
final Widget Function() fallbackBuilder;
const _ChannelCell({
required this.rowHeight,
required this.channelColumnWidth,
required this.channelThumb,
required this.client,
required this.channel,
required this.theme,
required this.onTap,
this.onLongPress,
required this.isFocused,
this.isFavorite = false,
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;
final tk = tokens(context);
final showAction = _hovered || widget.isFocused;
final radius = BorderRadius.circular(widget.isFocused ? tk.radiusSm : tk.radiusXs);
// Inverted focus card, matching the program-block cursor.
final contentColor = widget.isFocused ? theme.colorScheme.onPrimary : theme.colorScheme.onSurface;
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onSecondaryTap: widget.onLongPress,
child: SizedBox(
height: widget.rowHeight,
child: Padding(
padding: EdgeInsets.only(right: tk.groupGap, bottom: tk.groupGap),
child: Material(
color: widget.isFocused ? theme.colorScheme.primary : tk.surface,
shape: RoundedRectangleBorder(borderRadius: radius),
child: InkWell(
borderRadius: radius,
canRequestFocus: false,
onTap: widget.onTap,
onLongPress: widget.onLongPress,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Stack(
alignment: .center,
children: [
AnimatedOpacity(
opacity: showAction ? 0.3 : 1.0,
duration: FocusTheme.getAnimationDuration(context),
child: widget.channelThumb != null && widget.client != null
? OptimizedMediaImage.thumb(
client: widget.client!,
imagePath: widget.channelThumb,
width: widget.channelColumnWidth - 16,
height: widget.rowHeight - 16,
fit: BoxFit.contain,
)
: widget.fallbackBuilder(),
),
if (showAction) AppIcon(Symbols.play_arrow_rounded, size: 32, color: contentColor),
if (widget.isFavorite)
Positioned(
top: 2,
right: 0,
child: AppIcon(
Symbols.star_rounded,
size: 14,
color: widget.isFocused ? theme.colorScheme.onPrimary : theme.colorScheme.primary,
),
),
],
),
),
),
),
),
),
),
);
}
}