1202 lines
41 KiB
Dart
1202 lines
41 KiB
Dart
import 'dart:async';
|
|
|
|
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/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 '../../../providers/multi_server_provider.dart';
|
|
import '../../../media/media_server_client.dart';
|
|
import '../../../utils/app_logger.dart';
|
|
import '../../../utils/formatters.dart';
|
|
import '../../../utils/media_image_helper.dart';
|
|
import '../../../utils/live_tv_player_navigation.dart';
|
|
import '../../../widgets/app_icon.dart';
|
|
import '../../../widgets/overlay_sheet.dart';
|
|
import '../../../widgets/optimized_media_image.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 }
|
|
|
|
class GuideTabState extends State<GuideTab> with MountedSetStateMixin {
|
|
static const _slotWidth = 180.0;
|
|
static const _channelColumnWidth = 100.0;
|
|
static const _rowHeight = 64.0;
|
|
static const _timeHeaderHeight = 40.0;
|
|
static const _minutesPerSlot = 30;
|
|
|
|
List<LiveTvProgram> _programs = [];
|
|
bool _isLoading = true;
|
|
|
|
late DateTime _gridStart;
|
|
late DateTime _gridEnd;
|
|
|
|
final ScrollController _headerHorizontalController = ScrollController();
|
|
final ScrollController _gridHorizontalController = ScrollController();
|
|
final ScrollController _channelVerticalController = ScrollController();
|
|
final ScrollController _gridVerticalController = ScrollController();
|
|
bool _syncingScroll = false;
|
|
|
|
Timer? _timeIndicatorTimer;
|
|
final _dayPickerKey = GlobalKey();
|
|
|
|
// 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();
|
|
_initTimeRange();
|
|
_loadPrograms();
|
|
|
|
_gridHorizontalController.addListener(_syncGridToHeader);
|
|
_headerHorizontalController.addListener(_syncHeaderToGrid);
|
|
|
|
_timeIndicatorTimer = Timer.periodic(const Duration(minutes: 1), (_) {
|
|
// ignore: no-empty-block - setState triggers rebuild to update time indicator
|
|
setStateIfMounted(() {});
|
|
});
|
|
}
|
|
|
|
void pauseRefresh() => _timeIndicatorTimer?.cancel();
|
|
|
|
void resumeRefresh() {
|
|
_timeIndicatorTimer?.cancel();
|
|
_timeIndicatorTimer = Timer.periodic(const Duration(minutes: 1), (_) {
|
|
// ignore: no-empty-block - setState triggers rebuild to update time indicator
|
|
setStateIfMounted(() {});
|
|
});
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(GuideTab oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
if (widget.channels.isNotEmpty && _gridChannelIndex >= widget.channels.length) {
|
|
_gridChannelIndex = widget.channels.length - 1;
|
|
}
|
|
}
|
|
|
|
@override
|
|
void 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;
|
|
_hasFocus = hasFocus;
|
|
_hasFocusNotifier.value = hasFocus;
|
|
}
|
|
|
|
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));
|
|
}
|
|
|
|
void _shiftTimeRange(int hours) {
|
|
setState(() {
|
|
_gridStart = _gridStart.add(Duration(hours: hours));
|
|
_gridEnd = _gridStart.add(const Duration(hours: 6));
|
|
});
|
|
_loadPrograms();
|
|
}
|
|
|
|
void _jumpToNow() {
|
|
_initTimeRange();
|
|
_loadPrograms();
|
|
}
|
|
|
|
Future<void> _loadPrograms() async {
|
|
if (!mounted) return;
|
|
setState(() => _isLoading = true);
|
|
|
|
try {
|
|
final multiServer = context.read<MultiServerProvider>();
|
|
final liveTvServers = multiServer.liveTvServers;
|
|
final allPrograms = <LiveTvProgram>[];
|
|
final queriedServers = <String>{};
|
|
|
|
for (final serverInfo in liveTvServers) {
|
|
if (!queriedServers.add(serverInfo.serverId)) continue;
|
|
try {
|
|
final genericClient = multiServer.getClientForServer(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);
|
|
} catch (e) {
|
|
appLogger.e('Failed to load programs from server ${serverInfo.serverId}', error: e);
|
|
}
|
|
}
|
|
|
|
if (!mounted) return;
|
|
|
|
final shouldFocus = _pendingFocus;
|
|
|
|
setState(() {
|
|
_programs = allPrograms;
|
|
_isLoading = false;
|
|
});
|
|
|
|
_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);
|
|
}
|
|
}
|
|
}
|
|
|
|
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((p) => p.channelIdentifier == channel.key).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 tuneAndNavigateToLiveTv(context, multiServer: multiServer, channel: channel, channels: widget.channels);
|
|
}
|
|
|
|
KeyEventResult _handleKeyEvent(FocusNode _, KeyEvent event) {
|
|
final key = event.logicalKey;
|
|
|
|
// 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 (!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) {
|
|
_showProgramDetails(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 = index * _rowHeight;
|
|
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 OverlaySheetHost(
|
|
child: Focus(
|
|
focusNode: _guideFocusNode,
|
|
onFocusChange: _handleGuideFocusChange,
|
|
onKeyEvent: _handleKeyEvent,
|
|
child: _buildGuideGrid(theme),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildGuideGrid(ThemeData theme) {
|
|
return ValueListenableBuilder<bool>(
|
|
valueListenable: _hasFocusNotifier,
|
|
builder: (context, hasFocus, child) {
|
|
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: widget.channels.length,
|
|
itemExtent: _rowHeight,
|
|
itemBuilder: (context, index) =>
|
|
_buildChannelCell(widget.channels[index], theme, index: index),
|
|
),
|
|
),
|
|
Expanded(
|
|
child: NotificationListener<ScrollNotification>(
|
|
onNotification: (notification) {
|
|
if (notification is ScrollUpdateNotification &&
|
|
notification.metrics.axis == Axis.vertical) {
|
|
if (_channelVerticalController.hasClients) {
|
|
_channelVerticalController.jumpTo(notification.metrics.pixels);
|
|
}
|
|
}
|
|
return false;
|
|
},
|
|
child: SingleChildScrollView(
|
|
controller: _gridHorizontalController,
|
|
scrollDirection: Axis.horizontal,
|
|
physics: const ClampingScrollPhysics(),
|
|
child: SizedBox(
|
|
width: _totalGridWidth(),
|
|
child: ListView.builder(
|
|
controller: _gridVerticalController,
|
|
itemCount: widget.channels.length,
|
|
itemExtent: _rowHeight,
|
|
itemBuilder: (context, index) {
|
|
final channel = widget.channels[index];
|
|
final programs = _getProgramsForChannel(channel);
|
|
return _buildProgramRow(channel, programs, theme, channelIndex: index);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildNowIndicatorOverlay(ThemeData _) {
|
|
final now = DateTime.now();
|
|
if (now.isBefore(_gridStart) || now.isAfter(_gridEnd)) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
final minutesSinceStart = now.difference(_gridStart).inMinutes.toDouble();
|
|
final nowOffset = (minutesSinceStart / _minutesPerSlot) * _slotWidth;
|
|
final scrollOffset = _gridHorizontalController.hasClients ? _gridHorizontalController.offset : 0.0;
|
|
final left = _channelColumnWidth + nowOffset - scrollOffset;
|
|
|
|
// Hide when scrolled behind the channel column
|
|
if (left < _channelColumnWidth) return const SizedBox.shrink();
|
|
|
|
final gridHeight = _timeHeaderHeight + widget.channels.length * _rowHeight;
|
|
|
|
return Positioned(
|
|
left: left,
|
|
top: 0,
|
|
height: gridHeight,
|
|
child: IgnorePointer(child: Container(width: 2, color: Colors.red)),
|
|
);
|
|
}
|
|
|
|
String _dayLabel(DateTime day) {
|
|
final now = DateTime.now();
|
|
final today = DateTime(now.year, now.month, now.day);
|
|
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),
|
|
];
|
|
|
|
RelativeRect _menuPosition() {
|
|
final renderBox = _dayPickerKey.currentContext?.findRenderObject() as RenderBox?;
|
|
final overlay = Overlay.of(context).context.findRenderObject() as RenderBox?;
|
|
if (renderBox == null || overlay == null) return RelativeRect.fill;
|
|
|
|
final buttonPos = renderBox.localToGlobal(Offset.zero);
|
|
final buttonSize = renderBox.size;
|
|
return RelativeRect.fromRect(
|
|
Rect.fromLTWH(buttonPos.dx, buttonPos.dy + buttonSize.height, buttonSize.width, 0),
|
|
Offset.zero & overlay.size,
|
|
);
|
|
}
|
|
|
|
void _showDayPicker() {
|
|
final now = DateTime.now();
|
|
final today = DateTime(now.year, now.month, now.day);
|
|
final gridDay = DateTime(_gridStart.year, _gridStart.month, _gridStart.day);
|
|
final theme = Theme.of(context);
|
|
|
|
final days = <DateTime>[];
|
|
for (var i = 0; i < 8; i++) {
|
|
days.add(today.add(Duration(days: i)));
|
|
}
|
|
|
|
showMenu<Object>(
|
|
context: context,
|
|
position: _menuPosition(),
|
|
items: [
|
|
PopupMenuItem<String>(
|
|
value: 'now',
|
|
child: Text(t.liveTv.now, style: theme.textTheme.bodyMedium),
|
|
),
|
|
...days.map((day) {
|
|
final isSelected = day == gridDay;
|
|
final label = _dayLabel(day);
|
|
return PopupMenuItem<DateTime>(
|
|
value: day,
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
label,
|
|
style: theme.textTheme.bodyMedium?.copyWith(color: isSelected ? theme.colorScheme.primary : null),
|
|
),
|
|
),
|
|
if (isSelected) AppIcon(Symbols.check_rounded, size: 18, color: theme.colorScheme.primary),
|
|
],
|
|
),
|
|
);
|
|
}),
|
|
],
|
|
).then((value) {
|
|
if (value == null) {
|
|
_guideFocusNode.requestFocus();
|
|
return;
|
|
}
|
|
if (value is String && value == 'now') {
|
|
_jumpToNow();
|
|
_guideFocusNode.requestFocus();
|
|
} else if (value is DateTime) {
|
|
_showTimeSlotPicker(value);
|
|
}
|
|
});
|
|
}
|
|
|
|
void _showTimeSlotPicker(DateTime day) {
|
|
final theme = Theme.of(context);
|
|
final label = _dayLabel(day).toUpperCase();
|
|
|
|
showMenu<int>(
|
|
context: context,
|
|
position: _menuPosition(),
|
|
items: [
|
|
PopupMenuItem<int>(
|
|
value: -1,
|
|
child: Row(
|
|
children: [
|
|
AppIcon(Symbols.chevron_left_rounded, size: 20, color: theme.colorScheme.onSurface),
|
|
const SizedBox(width: 8),
|
|
Text(label, style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold)),
|
|
],
|
|
),
|
|
),
|
|
const PopupMenuDivider(),
|
|
..._timeSlots.map((slot) {
|
|
return PopupMenuItem<int>(
|
|
value: slot.$2,
|
|
child: Text(slot.$1, style: theme.textTheme.bodyMedium),
|
|
);
|
|
}),
|
|
],
|
|
).then((value) {
|
|
if (value == null) {
|
|
_guideFocusNode.requestFocus();
|
|
return;
|
|
}
|
|
if (value == -1) {
|
|
_showDayPicker();
|
|
return;
|
|
}
|
|
setState(() {
|
|
_gridStart = DateTime(day.year, day.month, day.day, value);
|
|
_gridEnd = _gridStart.add(const Duration(hours: 6));
|
|
});
|
|
_loadPrograms();
|
|
_guideFocusNode.requestFocus();
|
|
});
|
|
}
|
|
|
|
Widget _timeNavFocusWrap({required Widget child, required int index, required ThemeData theme}) {
|
|
final isFocused = _hasFocus && _focusZone == _GuideZone.timeNav && _timeNavIndex == index;
|
|
if (!isFocused) return child;
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
color: theme.colorScheme.primary.withValues(alpha: 0.15),
|
|
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
|
),
|
|
child: child,
|
|
);
|
|
}
|
|
|
|
Widget _buildTimeNavigation(ThemeData theme) {
|
|
final timeLabel = formatClockTime(_gridStart, is24Hour: MediaQuery.alwaysUse24HourFormatOf(context));
|
|
final dayLabel = _dayLabel(_gridStart);
|
|
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
|
decoration: BoxDecoration(
|
|
border: Border(bottom: BorderSide(color: theme.dividerColor.withValues(alpha: 0.3))),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
_timeNavFocusWrap(
|
|
index: 0,
|
|
theme: theme,
|
|
child: IconButton(
|
|
icon: const AppIcon(Symbols.chevron_left_rounded),
|
|
onPressed: () => _shiftTimeRange(-2),
|
|
iconSize: 20,
|
|
visualDensity: VisualDensity.compact,
|
|
),
|
|
),
|
|
Expanded(
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
_timeNavFocusWrap(
|
|
index: 1,
|
|
theme: theme,
|
|
child: GestureDetector(
|
|
key: _dayPickerKey,
|
|
onTap: _showDayPicker,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(dayLabel, style: theme.textTheme.labelLarge),
|
|
const SizedBox(width: 2),
|
|
AppIcon(Symbols.arrow_drop_down_rounded, size: 18, color: theme.colorScheme.onSurface),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(timeLabel, style: theme.textTheme.labelLarge),
|
|
],
|
|
),
|
|
),
|
|
_timeNavFocusWrap(
|
|
index: 2,
|
|
theme: theme,
|
|
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: Alignment.centerLeft,
|
|
child: Text(
|
|
timeStr,
|
|
style: theme.textTheme.labelSmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
current = current.add(const Duration(minutes: _minutesPerSlot));
|
|
}
|
|
|
|
return Row(children: slots);
|
|
}
|
|
|
|
Widget _buildChannelCell(LiveTvChannel channel, ThemeData theme, {required int index}) {
|
|
final multiServer = context.read<MultiServerProvider>();
|
|
final client = multiServer.getClientForServer(channel.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: MainAxisAlignment.center,
|
|
children: [
|
|
if (channel.number != null)
|
|
Text(
|
|
channel.number!,
|
|
style: theme.textTheme.labelSmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
|
maxLines: 1,
|
|
),
|
|
Text(
|
|
channel.displayName,
|
|
style: theme.textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w500),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
textAlign: TextAlign.center,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildProgramRow(
|
|
LiveTvChannel channel,
|
|
List<LiveTvProgram> programs,
|
|
ThemeData theme, {
|
|
required int channelIndex,
|
|
}) {
|
|
if (programs.isEmpty) {
|
|
return Container(
|
|
height: _rowHeight,
|
|
decoration: BoxDecoration(
|
|
border: Border(bottom: BorderSide(color: theme.dividerColor.withValues(alpha: 0.3))),
|
|
),
|
|
child: Center(
|
|
child: Text(
|
|
t.liveTv.noPrograms,
|
|
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
final blocks = <Widget>[];
|
|
final gridStartEpoch = _gridStart.millisecondsSinceEpoch ~/ 1000;
|
|
final gridEndEpoch = _gridEnd.millisecondsSinceEpoch ~/ 1000;
|
|
|
|
// 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;
|
|
final clampedWidth = width.clamp(2.0, double.infinity);
|
|
|
|
blocks.add(
|
|
Positioned(
|
|
left: left,
|
|
width: clampedWidth,
|
|
top: 0,
|
|
bottom: 0,
|
|
child: _buildProgramBlock(
|
|
channel,
|
|
program,
|
|
theme,
|
|
isFirst: progStart == gridStartEpoch,
|
|
isLast: program == programs.last && progEnd != gridEndEpoch,
|
|
isFocused: identical(program, focusProg),
|
|
tileLeft: left,
|
|
tileWidth: clampedWidth,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
return Container(
|
|
height: _rowHeight,
|
|
decoration: BoxDecoration(
|
|
border: Border(bottom: BorderSide(color: theme.dividerColor.withValues(alpha: 0.3))),
|
|
),
|
|
child: Stack(children: blocks),
|
|
);
|
|
}
|
|
|
|
Widget _buildProgramBlock(
|
|
LiveTvChannel channel,
|
|
LiveTvProgram program,
|
|
ThemeData theme, {
|
|
bool isFirst = false,
|
|
bool isLast = false,
|
|
bool isFocused = false,
|
|
double tileLeft = 0,
|
|
double tileWidth = 0,
|
|
}) {
|
|
final isCurrentlyAiring = program.isCurrentlyAiring;
|
|
final isPast = program.endsAt != null && program.endsAt! < DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
|
|
|
Color materialColor;
|
|
if (isFocused) {
|
|
materialColor = theme.colorScheme.primary.withValues(alpha: 0.15);
|
|
} else if (isCurrentlyAiring) {
|
|
materialColor = theme.colorScheme.onSurface.withValues(alpha: 0.12);
|
|
} else {
|
|
materialColor = theme.colorScheme.onSurface.withValues(alpha: 0.05);
|
|
}
|
|
|
|
Color titleColor;
|
|
if (isFocused) {
|
|
titleColor = theme.colorScheme.primary;
|
|
} else if (isCurrentlyAiring) {
|
|
titleColor = theme.colorScheme.onSurface;
|
|
} else {
|
|
titleColor = theme.colorScheme.onSurface;
|
|
}
|
|
|
|
Color subtitleColor;
|
|
if (isFocused) {
|
|
subtitleColor = theme.colorScheme.primary.withValues(alpha: 0.7);
|
|
} else if (isCurrentlyAiring) {
|
|
subtitleColor = theme.colorScheme.onSurfaceVariant;
|
|
} else {
|
|
subtitleColor = theme.colorScheme.onSurfaceVariant;
|
|
}
|
|
|
|
return Opacity(
|
|
opacity: isPast ? 0.5 : 1.0,
|
|
child: Material(
|
|
color: isFocused ? materialColor : Colors.transparent,
|
|
shape: RoundedRectangleBorder(
|
|
side: isFocused ? BorderSide(color: theme.colorScheme.primary, width: 2) : BorderSide.none,
|
|
),
|
|
child: InkWell(
|
|
canRequestFocus: false,
|
|
onTap: () => _showProgramDetails(channel, program),
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
border: Border(
|
|
left: isFirst ? BorderSide.none : BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)),
|
|
right: isLast ? BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)) : BorderSide.none,
|
|
),
|
|
),
|
|
child: ListenableBuilder(
|
|
listenable: _gridHorizontalController,
|
|
builder: (context, _) {
|
|
const basePadding = 6.0;
|
|
final scrollOffset = _gridHorizontalController.hasClients ? _gridHorizontalController.offset : 0.0;
|
|
final maxInset = (tileWidth - 2 * basePadding - 20).clamp(0.0, double.infinity);
|
|
final leftInset = (scrollOffset - tileLeft).clamp(0.0, maxInset);
|
|
return Container(
|
|
color: isFocused ? null : materialColor,
|
|
padding: EdgeInsets.fromLTRB(basePadding + leftInset, 4, basePadding, 4),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Text(
|
|
program.grandparentTitle ?? program.title,
|
|
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600, color: titleColor),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
if (program.grandparentTitle != null)
|
|
Text(
|
|
'${program.parentIndex != null && program.index != null ? 'S${program.parentIndex}E${program.index} · ' : ''}${program.title}',
|
|
style: theme.textTheme.labelSmall?.copyWith(color: subtitleColor),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
if (program.startTime != null)
|
|
Text(
|
|
'${formatClockTime(program.startTime!, is24Hour: MediaQuery.alwaysUse24HourFormatOf(context))} · ${formatDurationTextual(program.durationMinutes * 60000)}',
|
|
style: theme.textTheme.labelSmall?.copyWith(color: subtitleColor),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showProgramDetails(LiveTvChannel channel, LiveTvProgram program) {
|
|
final multiServer = context.read<MultiServerProvider>();
|
|
final client = multiServer.getClientForServer(channel.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,
|
|
);
|
|
}
|
|
}
|
|
|
|
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 showAction = _hovered || widget.isFocused;
|
|
|
|
return MouseRegion(
|
|
onEnter: (_) => setState(() => _hovered = true),
|
|
onExit: (_) => setState(() => _hovered = false),
|
|
child: GestureDetector(
|
|
onSecondaryTap: widget.onLongPress,
|
|
child: Material(
|
|
color: widget.isFocused ? theme.colorScheme.primary.withValues(alpha: 0.15) : Colors.transparent,
|
|
child: InkWell(
|
|
canRequestFocus: false,
|
|
onTap: widget.onTap,
|
|
onLongPress: widget.onLongPress,
|
|
child: Container(
|
|
height: widget.rowHeight,
|
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
|
decoration: BoxDecoration(
|
|
border: Border(
|
|
bottom: BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)),
|
|
right: BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)),
|
|
),
|
|
),
|
|
child: Stack(
|
|
alignment: Alignment.center,
|
|
children: [
|
|
AnimatedOpacity(
|
|
opacity: showAction ? 0.3 : 1.0,
|
|
duration: const Duration(milliseconds: 150),
|
|
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: theme.colorScheme.onSurface),
|
|
if (widget.isFavorite)
|
|
Positioned(
|
|
top: 2,
|
|
right: 0,
|
|
child: AppIcon(Symbols.star_rounded, size: 14, color: theme.colorScheme.primary),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|