Vertical D-pad/arrow navigation stepped through the flat channel list, which is number-sorted across servers. With overlapping channel numbers from multiple DVRs, focus interleaved source groups and could dead-end before the last displayed row. Derive the up/down order from the same grouped rows the guide renders. close #1843
1773 lines
62 KiB
Dart
1773 lines
62 KiB
Dart
import 'dart:async';
|
|
import '../../../media/ids.dart';
|
|
|
|
import 'package:flutter/foundation.dart' show ValueListenable;
|
|
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/app_locale_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_actions_mixin.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/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';
|
|
|
|
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();
|
|
}
|
|
|
|
@visibleForTesting
|
|
({String channelScopeKey, ({String kind, String value})? programId, int? beginsAt, int? endsAt}) guideAiringIdentity(
|
|
LiveTvChannel channel,
|
|
LiveTvProgram program,
|
|
) {
|
|
({String kind, String value})? programId;
|
|
if (program.ratingKey case final ratingKey? when ratingKey.isNotEmpty) {
|
|
programId = (kind: 'ratingKey', value: ratingKey);
|
|
} else if (program.guid case final guid? when guid.isNotEmpty) {
|
|
programId = (kind: 'guid', value: guid);
|
|
} else if (program.key case final key? when key.isNotEmpty) {
|
|
programId = (kind: 'key', value: key);
|
|
}
|
|
|
|
// Keep the slot even with an ID so repeated airings cannot inherit each
|
|
// other's hold; with no ID, channel scope plus timing is the fallback.
|
|
return (
|
|
channelScopeKey: liveTvChannelScopeKey(channel),
|
|
programId: programId,
|
|
beginsAt: program.beginsAt,
|
|
endsAt: program.endsAt,
|
|
);
|
|
}
|
|
|
|
enum _GuideZone { timeNav, grid }
|
|
|
|
typedef _GuideFocusSnapshot = ({
|
|
bool hasFocus,
|
|
_GuideZone zone,
|
|
int timeNavIndex,
|
|
int channelIndex,
|
|
int gridColumn,
|
|
LiveTvProgram? program,
|
|
});
|
|
|
|
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 LiveTvActionsMixin<GuideTab>, 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 = [];
|
|
Map<String, List<LiveTvProgram>> _programsByChannelScope = const {};
|
|
Set<String> _scheduledRecordingKeys = const {};
|
|
bool _isLoading = true;
|
|
int _programLoadGeneration = 0;
|
|
|
|
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<_GuideFocusSnapshot> _focusSnapshot = ValueNotifier((
|
|
hasFocus: false,
|
|
zone: _GuideZone.timeNav,
|
|
timeNavIndex: 1,
|
|
channelIndex: 0,
|
|
gridColumn: 0,
|
|
program: null,
|
|
));
|
|
LiveTvProgram? _focusedProgram;
|
|
bool _pendingFocus = false;
|
|
|
|
@override
|
|
List<LiveTvChannel> get liveTvChannels => widget.channels;
|
|
|
|
/// 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();
|
|
_updateFocus(() {
|
|
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(() {});
|
|
});
|
|
}
|
|
|
|
// Not the gated data-refresh timer the other tabs run: pause/resume drive the
|
|
// per-minute UI ticker, and pause has to stamp _hiddenSince on both a section
|
|
// hide and an app background so _catchUpIfStale can measure the absence.
|
|
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 (!identical(oldWidget.channels, widget.channels)) {
|
|
_programsByChannelScope = _indexProgramsByChannel(_programs, widget.channels);
|
|
}
|
|
if (widget.channels.isNotEmpty && _gridChannelIndex >= widget.channels.length) {
|
|
_gridChannelIndex = widget.channels.length - 1;
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) _publishFocusSnapshot();
|
|
});
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_programLoadGeneration++;
|
|
WidgetsBinding.instance.removeObserver(this);
|
|
_programSelectController.dispose();
|
|
_guideFocusNode.dispose();
|
|
_gridVerticalController.dispose();
|
|
_gridHorizontalController.removeListener(_syncGridToHeader);
|
|
_headerHorizontalController.removeListener(_syncHeaderToGrid);
|
|
_headerHorizontalController.dispose();
|
|
_gridHorizontalController.dispose();
|
|
_channelVerticalController.dispose();
|
|
_timeIndicatorTimer?.cancel();
|
|
_focusSnapshot.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _handleGuideFocusChange(bool hasFocus) {
|
|
if (_hasFocus == hasFocus) return;
|
|
if (!hasFocus) _resetProgramSelectLongPressState();
|
|
_hasFocus = hasFocus;
|
|
_publishFocusSnapshot();
|
|
}
|
|
|
|
void _updateFocus(VoidCallback update) {
|
|
update();
|
|
_publishFocusSnapshot();
|
|
}
|
|
|
|
void _publishFocusSnapshot() {
|
|
_focusSnapshot.value = (
|
|
hasFocus: _hasFocus,
|
|
zone: _focusZone,
|
|
timeNavIndex: _timeNavIndex,
|
|
channelIndex: _gridChannelIndex,
|
|
gridColumn: _gridColumn,
|
|
program: _focusedProgram,
|
|
);
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|
|
|
|
bool _isCurrentProgramLoad(int generation) => mounted && generation == _programLoadGeneration;
|
|
|
|
Future<void> _loadPrograms() async {
|
|
if (!mounted) return;
|
|
final loadGeneration = ++_programLoadGeneration;
|
|
final requestGridStart = _gridStart;
|
|
final requestGridEnd = _gridEnd;
|
|
final startEpoch = requestGridStart.millisecondsSinceEpoch ~/ 1000;
|
|
final endEpoch = requestGridEnd.millisecondsSinceEpoch ~/ 1000;
|
|
final from = DateTime.fromMillisecondsSinceEpoch(startEpoch * 1000, isUtc: true);
|
|
final to = DateTime.fromMillisecondsSinceEpoch(endEpoch * 1000, isUtc: true);
|
|
setState(() => _isLoading = true);
|
|
|
|
try {
|
|
final allPrograms = <LiveTvProgram>[];
|
|
final scheduledRecordingKeys = <String>{};
|
|
final multiServer = context.read<MultiServerProvider>();
|
|
final liveTvServers = List<LiveTvServerInfo>.of(multiServer.liveTvServers);
|
|
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 programs = await genericClient.liveTv.fetchSchedule(from: from, to: to);
|
|
if (!_isCurrentProgramLoad(loadGeneration)) return;
|
|
allPrograms.addAll(programs);
|
|
await _addScheduledRecordingKeysForServer(
|
|
client: genericClient,
|
|
serverId: ServerId(serverInfo.serverId),
|
|
keys: scheduledRecordingKeys,
|
|
isCurrent: () => _isCurrentProgramLoad(loadGeneration),
|
|
);
|
|
if (!_isCurrentProgramLoad(loadGeneration)) return;
|
|
} catch (e) {
|
|
if (!_isCurrentProgramLoad(loadGeneration)) return;
|
|
appLogger.e('Failed to load programs from server ${serverInfo.serverId}', error: e);
|
|
}
|
|
}
|
|
|
|
if (!_isCurrentProgramLoad(loadGeneration)) return;
|
|
final shouldFocus = _pendingFocus;
|
|
final programsByChannelScope = _indexProgramsByChannel(allPrograms, widget.channels);
|
|
|
|
setState(() {
|
|
_programs = allPrograms;
|
|
_programsByChannelScope = programsByChannelScope;
|
|
_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);
|
|
}
|
|
}
|
|
});
|
|
_publishFocusSnapshot();
|
|
|
|
_scrollToNow(loadGeneration: loadGeneration);
|
|
|
|
if (shouldFocus) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (_isCurrentProgramLoad(loadGeneration)) focusContent();
|
|
});
|
|
}
|
|
} catch (e) {
|
|
if (!_isCurrentProgramLoad(loadGeneration)) return;
|
|
appLogger.e('Failed to load guide programs', error: e);
|
|
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,
|
|
bool Function()? isCurrent,
|
|
}) async {
|
|
final dvr = client.liveTvDvr;
|
|
if (dvr == null) return;
|
|
try {
|
|
final grabs = await dvr.fetchScheduledRecordings();
|
|
if (isCurrent?.call() == false) return;
|
|
for (final grab in grabs) {
|
|
_addRecordingKeysForGrab(grab, serverId: ServerId(serverId), keys: keys);
|
|
}
|
|
} catch (e) {
|
|
if (isCurrent?.call() == false) return;
|
|
appLogger.d('Failed to load scheduled recordings for $serverId', error: e);
|
|
}
|
|
|
|
try {
|
|
final rules = await dvr.fetchRecordingRules(includeGrabs: true, includeStorage: false);
|
|
if (isCurrent?.call() == false) return;
|
|
for (final rule in rules) {
|
|
for (final grab in rule.grabOperations) {
|
|
_addRecordingKeysForGrab(grab, serverId: ServerId(serverId), keys: keys);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
if (isCurrent?.call() == false) return;
|
|
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 = liveTvNonEmpty(program.serverId) ?? liveTvNonEmpty(fallbackServerId);
|
|
if (serverId == null) return const <String>{};
|
|
|
|
final keys = <String>{};
|
|
void addMediaId(String? value) {
|
|
final normalized = liveTvNonEmpty(value);
|
|
if (normalized != null) keys.add(_recordingKey(ServerId(serverId), 'media', normalized));
|
|
}
|
|
|
|
addMediaId(program.ratingKey);
|
|
addMediaId(program.guid);
|
|
addMediaId(program.key);
|
|
|
|
final channelIdentifier = liveTvNonEmpty(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';
|
|
|
|
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),
|
|
],
|
|
];
|
|
}
|
|
|
|
/// Flat [GuideTab.channels] indexes in displayed row order. Differs from
|
|
/// ascending index order when multiple source groups exist: the flat list is
|
|
/// number-sorted across sources, while rows are grouped by source.
|
|
List<int> get _displayOrderChannelIndexes => [
|
|
for (final row in _guideRows)
|
|
if (row is _GuideChannelRow) row.channelIndex,
|
|
];
|
|
|
|
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({int? loadGeneration}) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted || (loadGeneration != null && !_isCurrentProgramLoad(loadGeneration))) return;
|
|
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),
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
Map<String, List<LiveTvProgram>> _indexProgramsByChannel(List<LiveTvProgram> programs, List<LiveTvChannel> channels) {
|
|
final programsByIdentifier = <String, List<LiveTvProgram>>{};
|
|
for (final program in programs) {
|
|
final identifier = program.channelIdentifier?.trim();
|
|
if (identifier == null || identifier.isEmpty) continue;
|
|
(programsByIdentifier[identifier] ??= []).add(program);
|
|
}
|
|
|
|
final indexed = <String, List<LiveTvProgram>>{};
|
|
for (final channel in channels) {
|
|
final candidates = <LiveTvProgram>{};
|
|
candidates.addAll(programsByIdentifier[channel.key] ?? const []);
|
|
final identifier = channel.identifier;
|
|
if (identifier != null && identifier != channel.key) {
|
|
candidates.addAll(programsByIdentifier[identifier] ?? const []);
|
|
}
|
|
final matching = candidates.where((program) => liveTvProgramMatchesChannel(program, channel)).toList()
|
|
..sort((a, b) => (a.beginsAt ?? 0).compareTo(b.beginsAt ?? 0));
|
|
indexed[liveTvChannelScopeKey(channel)] = matching;
|
|
}
|
|
return indexed;
|
|
}
|
|
|
|
List<LiveTvProgram> _getProgramsForChannel(LiveTvChannel channel) {
|
|
return _programsByChannelScope[liveTvChannelScopeKey(channel)] ?? const [];
|
|
}
|
|
|
|
double _totalGridWidth() {
|
|
final totalMinutes = _gridEnd.difference(_gridStart).inMinutes;
|
|
return (totalMinutes / _minutesPerSlot) * _slotWidth;
|
|
}
|
|
|
|
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;
|
|
|
|
final ownerChannelIndex = _gridChannelIndex;
|
|
final targetIdentity = guideAiringIdentity(target.channel, target.program);
|
|
return _programSelectController.handleKeyEvent(
|
|
event,
|
|
isOwnerActive: () {
|
|
if (!mounted || _focusZone != _GuideZone.grid || _gridColumn != 1 || _gridChannelIndex != ownerChannelIndex) {
|
|
return false;
|
|
}
|
|
|
|
final activeTarget = _focusedProgramTarget();
|
|
return activeTarget != null &&
|
|
guideAiringIdentity(activeTarget.channel, activeTarget.program) == targetIdentity;
|
|
},
|
|
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) {
|
|
_updateFocus(() {
|
|
_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) {
|
|
_updateFocus(() => _timeNavIndex--);
|
|
} else {
|
|
widget.onBack?.call();
|
|
}
|
|
return KeyEventResult.handled;
|
|
}
|
|
if (key.isRightKey) {
|
|
if (_timeNavIndex < 2) _updateFocus(() => _timeNavIndex++);
|
|
return KeyEventResult.handled;
|
|
}
|
|
if (key.isDownKey) {
|
|
if (widget.channels.isNotEmpty) {
|
|
_updateFocus(() {
|
|
_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 || key.isDownKey) {
|
|
// Move through rows in displayed (source-grouped) order. Stepping the
|
|
// flat channel index would interleave sources whose channel numbers
|
|
// overlap and could dead-end before the last displayed row.
|
|
final order = _displayOrderChannelIndexes;
|
|
final position = order.indexOf(_gridChannelIndex);
|
|
if (key.isUpKey && position <= 0) {
|
|
_updateFocus(() {
|
|
_focusZone = _GuideZone.timeNav;
|
|
_timeNavIndex = 1;
|
|
});
|
|
} else if (key.isUpKey || (position != -1 && position < order.length - 1)) {
|
|
_updateFocus(() {
|
|
_gridChannelIndex = order[key.isUpKey ? position - 1 : position + 1];
|
|
if (_gridColumn == 1) _focusedProgram = _findCurrentProgram(_gridChannelIndex);
|
|
});
|
|
_scrollToChannel(_gridChannelIndex);
|
|
}
|
|
return KeyEventResult.handled;
|
|
}
|
|
if (key.isRightKey) {
|
|
if (_gridColumn == 0) {
|
|
final program = _findCurrentProgram(_gridChannelIndex);
|
|
if (program != null) {
|
|
_updateFocus(() {
|
|
_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)) {
|
|
_updateFocus(() {
|
|
_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;
|
|
|
|
final nextProgram = programs[nextIndex];
|
|
_updateFocus(() => _focusedProgram = nextProgram);
|
|
_scrollToProgramTime(nextProgram);
|
|
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) {
|
|
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: CustomScrollView(
|
|
controller: _channelVerticalController,
|
|
physics: const NeverScrollableScrollPhysics(),
|
|
slivers: [
|
|
SliverVariedExtentList.builder(
|
|
itemCount: rows.length,
|
|
itemExtentBuilder: (index, _) => _guideRowHeight(rows[index]),
|
|
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: CustomScrollView(
|
|
controller: _gridVerticalController,
|
|
slivers: [
|
|
SliverVariedExtentList.builder(
|
|
itemCount: rows.length,
|
|
itemExtentBuilder: (index, _) => _guideRowHeight(rows[index]),
|
|
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.intlLocaleName).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}) {
|
|
return _GuideFocusSelector(
|
|
valueListenable: _focusSnapshot,
|
|
isSelected: (focus) => focus.hasFocus && focus.zone == _GuideZone.timeNav && focus.timeNavIndex == index,
|
|
builder: (context, isFocused) {
|
|
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);
|
|
|
|
return _GuideFocusSelector(
|
|
valueListenable: _focusSnapshot,
|
|
isSelected: (focus) =>
|
|
focus.hasFocus && focus.zone == _GuideZone.grid && focus.gridColumn == 0 && focus.channelIndex == index,
|
|
builder: (context, isFocused) {
|
|
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 gridStartEpoch = _gridStart.millisecondsSinceEpoch ~/ 1000;
|
|
final gridEndEpoch = _gridEnd.millisecondsSinceEpoch ~/ 1000;
|
|
|
|
return SizedBox(
|
|
height: _rowHeight,
|
|
child: ListenableBuilder(
|
|
listenable: _gridHorizontalController,
|
|
builder: (context, _) {
|
|
final scrollOffset = _gridHorizontalController.hasClients ? _gridHorizontalController.offset : 0.0;
|
|
// Keep one slot of overscan so the next D-pad target exists before the horizontal jump.
|
|
final visibleStart = scrollOffset > _slotWidth ? scrollOffset - _slotWidth : 0.0;
|
|
final visibleEnd = scrollOffset + MediaQuery.sizeOf(context).width + _slotWidth;
|
|
final blocks = <Widget>[];
|
|
|
|
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;
|
|
final clampedWidth = width.clamp(6.0, double.infinity);
|
|
if (left + clampedWidth < visibleStart || left > visibleEnd) continue;
|
|
|
|
blocks.add(
|
|
Positioned(
|
|
key: ObjectKey(program),
|
|
left: left,
|
|
width: clampedWidth,
|
|
top: 0,
|
|
bottom: 0,
|
|
child: _buildProgramBlock(
|
|
channel,
|
|
program,
|
|
theme,
|
|
channelIndex: channelIndex,
|
|
tileLeft: left,
|
|
tileWidth: clampedWidth,
|
|
scrollOffset: scrollOffset,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
return Stack(children: blocks);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildProgramBlock(
|
|
LiveTvChannel channel,
|
|
LiveTvProgram program,
|
|
ThemeData theme, {
|
|
required int channelIndex,
|
|
required double tileLeft,
|
|
required double tileWidth,
|
|
required double scrollOffset,
|
|
}) {
|
|
return _GuideFocusSelector(
|
|
valueListenable: _focusSnapshot,
|
|
isSelected: (focus) =>
|
|
focus.hasFocus &&
|
|
focus.zone == _GuideZone.grid &&
|
|
focus.gridColumn == 1 &&
|
|
focus.channelIndex == channelIndex &&
|
|
identical(focus.program, program),
|
|
builder: (context, isFocused) {
|
|
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: Padding(
|
|
padding: .fromLTRB(
|
|
6 + (scrollOffset - tileLeft).clamp(0.0, (tileWidth - tk.groupGap - 32).clamp(0.0, double.infinity)),
|
|
4,
|
|
6,
|
|
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) {
|
|
showProgramDetails(
|
|
program: program,
|
|
channel: channel,
|
|
posterThumb: program.thumb,
|
|
posterServerId: channel.serverId,
|
|
onRecordingStateChanged: (isScheduled) => _handleRecordingStateChanged(program, isScheduled),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _GuideFocusSelector extends StatefulWidget {
|
|
const _GuideFocusSelector({required this.valueListenable, required this.isSelected, required this.builder});
|
|
|
|
final ValueListenable<_GuideFocusSnapshot> valueListenable;
|
|
final bool Function(_GuideFocusSnapshot focus) isSelected;
|
|
final Widget Function(BuildContext context, bool isSelected) builder;
|
|
|
|
@override
|
|
State<_GuideFocusSelector> createState() => _GuideFocusSelectorState();
|
|
}
|
|
|
|
class _GuideFocusSelectorState extends State<_GuideFocusSelector> {
|
|
late bool _isSelected;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_isSelected = widget.isSelected(widget.valueListenable.value);
|
|
widget.valueListenable.addListener(_handleValueChanged);
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(_GuideFocusSelector oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
if (oldWidget.valueListenable != widget.valueListenable) {
|
|
oldWidget.valueListenable.removeListener(_handleValueChanged);
|
|
widget.valueListenable.addListener(_handleValueChanged);
|
|
}
|
|
_isSelected = widget.isSelected(widget.valueListenable.value);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
widget.valueListenable.removeListener(_handleValueChanged);
|
|
super.dispose();
|
|
}
|
|
|
|
void _handleValueChanged() {
|
|
final isSelected = widget.isSelected(widget.valueListenable.value);
|
|
if (isSelected == _isSelected) return;
|
|
setState(() => _isSelected = isSelected);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) => widget.builder(context, _isSelected);
|
|
}
|
|
|
|
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|