fix(tv): focus

This commit is contained in:
edde746
2026-02-12 21:23:12 +01:00
parent daff76363b
commit 7745b5a2f8
6 changed files with 1264 additions and 538 deletions
+80 -105
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import '../../focus/focusable_wrapper.dart';
import '../../focus/key_event_utils.dart';
import '../../i18n/strings.g.dart';
import '../../models/livetv_scheduled_recording.dart';
@@ -102,14 +103,8 @@ class _DvrRecordingsScreenState extends State<DvrRecordingsScreen> with SingleTi
title: Text(t.liveTv.deleteSubscription),
content: Text(t.liveTv.deleteSubscriptionConfirm),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(t.common.cancel),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(t.common.delete),
),
TextButton(onPressed: () => Navigator.of(context).pop(false), child: Text(t.common.cancel)),
FilledButton(onPressed: () => Navigator.of(context).pop(true), child: Text(t.common.delete)),
],
),
);
@@ -117,9 +112,7 @@ class _DvrRecordingsScreenState extends State<DvrRecordingsScreen> with SingleTi
if (confirmed != true || !mounted) return;
final multiServer = context.read<MultiServerProvider>();
final client = subscription.serverId != null
? multiServer.getClientForServer(subscription.serverId!)
: null;
final client = subscription.serverId != null ? multiServer.getClientForServer(subscription.serverId!) : null;
if (client != null) {
final success = await client.deleteSubscription(subscription.key);
@@ -132,9 +125,7 @@ class _DvrRecordingsScreenState extends State<DvrRecordingsScreen> with SingleTi
Future<void> _editSubscription(LiveTvSubscription subscription) async {
// Filter to visible settings only
final editableSettings = subscription.settings
.where((s) => s.hidden != true)
.toList();
final editableSettings = subscription.settings.where((s) => s.hidden != true).toList();
if (editableSettings.isEmpty) return;
@@ -145,19 +136,14 @@ class _DvrRecordingsScreenState extends State<DvrRecordingsScreen> with SingleTi
final result = await showDialog<Map<String, String>?>(
context: context,
builder: (dialogContext) => _SubscriptionEditDialog(
subscription: subscription,
settings: editableSettings,
initialPrefs: prefs,
),
builder: (dialogContext) =>
_SubscriptionEditDialog(subscription: subscription, settings: editableSettings, initialPrefs: prefs),
);
if (result == null || !mounted) return;
final multiServer = context.read<MultiServerProvider>();
final client = subscription.serverId != null
? multiServer.getClientForServer(subscription.serverId!)
: null;
final client = subscription.serverId != null ? multiServer.getClientForServer(subscription.serverId!) : null;
if (client != null) {
final success = await client.editSubscription(subscription.key, result);
@@ -192,27 +178,24 @@ class _DvrRecordingsScreenState extends State<DvrRecordingsScreen> with SingleTi
body: _isLoading
? const Center(child: CircularProgressIndicator())
: _error != null
? Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(_error!, style: theme.textTheme.bodyLarge),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: _loadData,
icon: const AppIcon(Symbols.refresh_rounded),
label: Text(t.common.retry),
),
],
? Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(_error!, style: theme.textTheme.bodyLarge),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: _loadData,
icon: const AppIcon(Symbols.refresh_rounded),
label: Text(t.common.retry),
),
)
: TabBarView(
controller: _tabController,
children: [
_buildSubscriptionsTab(theme),
_buildScheduledTab(theme),
],
),
],
),
)
: TabBarView(
controller: _tabController,
children: [_buildSubscriptionsTab(theme), _buildScheduledTab(theme)],
),
),
),
);
@@ -228,42 +211,45 @@ class _DvrRecordingsScreenState extends State<DvrRecordingsScreen> with SingleTi
itemCount: _subscriptions.length,
itemBuilder: (context, index) {
final sub = _subscriptions[index];
return _buildSubscriptionCard(sub, theme);
return FocusableWrapper(
autofocus: index == 0,
autoScroll: true,
useComfortableZone: true,
onSelect: () => _editSubscription(sub),
onBack: () => Navigator.pop(context),
child: _buildSubscriptionCard(sub, theme),
);
},
);
}
Widget _buildSubscriptionCard(LiveTvSubscription subscription, ThemeData theme) {
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: ListTile(
leading: const AppIcon(Symbols.fiber_dvr_rounded, size: 32),
title: Text(
subscription.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: subscription.type != null
? Text(
subscription.type!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
return ExcludeFocus(
child: Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: ListTile(
leading: const AppIcon(Symbols.fiber_dvr_rounded, size: 32),
title: Text(subscription.title, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: subscription.type != null
? Text(
subscription.type!,
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
)
: null,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (subscription.settings.isNotEmpty)
IconButton(
icon: const AppIcon(Symbols.settings_rounded),
onPressed: () => _editSubscription(subscription),
),
)
: null,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (subscription.settings.isNotEmpty)
IconButton(
icon: const AppIcon(Symbols.settings_rounded),
onPressed: () => _editSubscription(subscription),
icon: AppIcon(Symbols.delete_rounded, color: theme.colorScheme.error),
onPressed: () => _deleteSubscription(subscription),
),
IconButton(
icon: AppIcon(Symbols.delete_rounded, color: theme.colorScheme.error),
onPressed: () => _deleteSubscription(subscription),
),
],
],
),
),
),
);
@@ -279,7 +265,13 @@ class _DvrRecordingsScreenState extends State<DvrRecordingsScreen> with SingleTi
itemCount: _scheduled.length,
itemBuilder: (context, index) {
final recording = _scheduled[index];
return _buildScheduledCard(recording, theme);
return FocusableWrapper(
autofocus: index == 0,
autoScroll: true,
useComfortableZone: true,
onBack: () => Navigator.pop(context),
child: _buildScheduledCard(recording, theme),
);
},
);
}
@@ -290,23 +282,19 @@ class _DvrRecordingsScreenState extends State<DvrRecordingsScreen> with SingleTi
? '${startTime.month}/${startTime.day} ${startTime.hour.toString().padLeft(2, '0')}:${startTime.minute.toString().padLeft(2, '0')}'
: '';
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: ListTile(
leading: const AppIcon(Symbols.fiber_manual_record_rounded, size: 32, color: Colors.red),
title: Text(
recording.displayTitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
[
if (recording.channelCallSign != null) recording.channelCallSign!,
timeStr,
if (recording.durationMinutes > 0) formatDurationTextual(recording.durationMinutes * 60000),
].join(' · '),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
return ExcludeFocus(
child: Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: ListTile(
leading: const AppIcon(Symbols.fiber_manual_record_rounded, size: 32, color: Colors.red),
title: Text(recording.displayTitle, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text(
[
if (recording.channelCallSign != null) recording.channelCallSign!,
timeStr,
if (recording.durationMinutes > 0) formatDurationTextual(recording.durationMinutes * 60000),
].join(' · '),
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
),
),
@@ -320,11 +308,7 @@ class _SubscriptionEditDialog extends StatefulWidget {
final List<SubscriptionSetting> settings;
final Map<String, String> initialPrefs;
const _SubscriptionEditDialog({
required this.subscription,
required this.settings,
required this.initialPrefs,
});
const _SubscriptionEditDialog({required this.subscription, required this.settings, required this.initialPrefs});
@override
State<_SubscriptionEditDialog> createState() => _SubscriptionEditDialogState();
@@ -364,14 +348,8 @@ class _SubscriptionEditDialogState extends State<_SubscriptionEditDialog> {
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(null),
child: Text(t.common.cancel),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(_prefs),
child: Text(t.common.save),
),
TextButton(onPressed: () => Navigator.of(context).pop(null), child: Text(t.common.cancel)),
FilledButton(onPressed: () => Navigator.of(context).pop(_prefs), child: Text(t.common.save)),
],
);
}
@@ -413,10 +391,7 @@ class _SubscriptionEditDialogState extends State<_SubscriptionEditDialog> {
}
// Default: text field
final controller = _textControllers.putIfAbsent(
setting.id,
() => TextEditingController(text: value),
);
final controller = _textControllers.putIfAbsent(setting.id, () => TextEditingController(text: value));
return ListTile(
title: Text(setting.label ?? setting.id),
subtitle: TextField(
+178 -57
View File
@@ -1,7 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import '../../focus/dpad_navigator.dart';
import '../../i18n/strings.g.dart';
import '../../models/livetv_channel.dart';
import '../../mixins/tab_navigation_mixin.dart';
@@ -21,10 +23,17 @@ class LiveTvScreen extends StatefulWidget {
State<LiveTvScreen> createState() => _LiveTvScreenState();
}
class _LiveTvScreenState extends State<LiveTvScreen>
with SingleTickerProviderStateMixin, TabNavigationMixin {
class _LiveTvScreenState extends State<LiveTvScreen> with SingleTickerProviderStateMixin, TabNavigationMixin {
final _guideTabFocusNode = FocusNode(debugLabel: 'tab_chip_guide');
final _whatsOnTabFocusNode = FocusNode(debugLabel: 'tab_chip_whats_on');
final _guideTabKey = GlobalKey<GuideTabState>();
final _whatsOnTabKey = GlobalKey<WhatsOnTabState>();
// App bar action button focus
final _refreshButtonFocusNode = FocusNode(debugLabel: 'RefreshButton');
final _dvrButtonFocusNode = FocusNode(debugLabel: 'DvrButton');
bool _isRefreshFocused = false;
bool _isDvrFocused = false;
List<LiveTvChannel> _channels = [];
bool _isLoading = true;
@@ -38,6 +47,8 @@ class _LiveTvScreenState extends State<LiveTvScreen>
super.initState();
suppressAutoFocus = true;
initTabNavigation();
_refreshButtonFocusNode.addListener(_onRefreshFocusChange);
_dvrButtonFocusNode.addListener(_onDvrFocusChange);
_loadChannels();
}
@@ -45,10 +56,22 @@ class _LiveTvScreenState extends State<LiveTvScreen>
void dispose() {
_guideTabFocusNode.dispose();
_whatsOnTabFocusNode.dispose();
_refreshButtonFocusNode.removeListener(_onRefreshFocusChange);
_refreshButtonFocusNode.dispose();
_dvrButtonFocusNode.removeListener(_onDvrFocusChange);
_dvrButtonFocusNode.dispose();
disposeTabNavigation();
super.dispose();
}
void _onRefreshFocusChange() {
if (mounted) setState(() => _isRefreshFocused = _refreshButtonFocusNode.hasFocus);
}
void _onDvrFocusChange() {
if (mounted) setState(() => _isDvrFocused = _dvrButtonFocusNode.hasFocus);
}
@override
void onTabChanged() {
if (!tabController.indexIsChanging) {
@@ -99,6 +122,12 @@ class _LiveTvScreenState extends State<LiveTvScreen>
_channels = allChannels;
_isLoading = false;
});
if (allChannels.isNotEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _focusCurrentTab();
});
}
} catch (e) {
appLogger.e('Failed to load Live TV channels', error: e);
if (mounted) {
@@ -111,17 +140,76 @@ class _LiveTvScreenState extends State<LiveTvScreen>
}
void _openRecordings() {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const DvrRecordingsScreen()),
);
Navigator.of(context).push(MaterialPageRoute(builder: (_) => const DvrRecordingsScreen()));
}
void _focusCurrentTab() {
if (tabController.index == 0) {
_guideTabKey.currentState?.focusContent();
} else if (tabController.index == 1) {
_whatsOnTabKey.currentState?.focusFirstHub();
}
setState(() {
suppressAutoFocus = false;
});
}
// ---------------------------------------------------------------------------
// Action button key handlers
// ---------------------------------------------------------------------------
KeyEventResult _handleRefreshKeyEvent(FocusNode node, KeyEvent event) {
if (!event.isActionable) return KeyEventResult.ignored;
final key = event.logicalKey;
if (key.isLeftKey) {
getTabChipFocusNode(tabCount - 1).requestFocus();
return KeyEventResult.handled;
}
if (key.isRightKey) {
_dvrButtonFocusNode.requestFocus();
return KeyEventResult.handled;
}
if (key.isDownKey) {
_focusCurrentTab();
return KeyEventResult.handled;
}
if (key.isUpKey) {
return KeyEventResult.handled;
}
if (key.isSelectKey) {
_loadChannels();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
KeyEventResult _handleDvrKeyEvent(FocusNode node, KeyEvent event) {
if (!event.isActionable) return KeyEventResult.ignored;
final key = event.logicalKey;
if (key.isLeftKey) {
_refreshButtonFocusNode.requestFocus();
return KeyEventResult.handled;
}
if (key.isRightKey || key.isUpKey) {
return KeyEventResult.handled;
}
if (key.isDownKey) {
_focusCurrentTab();
return KeyEventResult.handled;
}
if (key.isSelectKey) {
_openRecordings();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
// ---------------------------------------------------------------------------
// Tab chips
// ---------------------------------------------------------------------------
Widget _buildTabChip(String label, int index) {
final isSelected = tabController.index == index;
@@ -157,12 +245,16 @@ class _LiveTvScreenState extends State<LiveTvScreen>
});
getTabChipFocusNode(newIndex).requestFocus();
}
: null,
: () => _refreshButtonFocusNode.requestFocus(),
onNavigateDown: _focusCurrentTab,
onBack: onTabBarBack,
);
}
// ---------------------------------------------------------------------------
// Build
// ---------------------------------------------------------------------------
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
@@ -180,68 +272,97 @@ class _LiveTvScreenState extends State<LiveTvScreen>
)
: Text(t.liveTv.title),
actions: [
IconButton(
icon: const AppIcon(Symbols.refresh_rounded),
tooltip: t.liveTv.reloadGuide,
onPressed: _loadChannels,
Focus(
focusNode: _refreshButtonFocusNode,
onKeyEvent: _handleRefreshKeyEvent,
child: Container(
decoration: BoxDecoration(
color: _isRefreshFocused ? Colors.white.withValues(alpha: 0.2) : Colors.transparent,
borderRadius: BorderRadius.circular(20),
),
child: IconButton(
icon: const AppIcon(Symbols.refresh_rounded),
tooltip: t.liveTv.reloadGuide,
onPressed: _loadChannels,
),
),
),
IconButton(
icon: const AppIcon(Symbols.fiber_dvr_rounded),
tooltip: t.liveTv.recordings,
onPressed: _openRecordings,
Focus(
focusNode: _dvrButtonFocusNode,
onKeyEvent: _handleDvrKeyEvent,
child: Container(
decoration: BoxDecoration(
color: _isDvrFocused ? Colors.white.withValues(alpha: 0.2) : Colors.transparent,
borderRadius: BorderRadius.circular(20),
),
child: IconButton(
icon: const AppIcon(Symbols.fiber_dvr_rounded),
tooltip: t.liveTv.recordings,
onPressed: _openRecordings,
),
),
),
],
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: _error != null
? Center(
child: Column(
mainAxisSize: MainAxisSize.min,
? Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AppIcon(Symbols.error_rounded, size: 48, color: theme.colorScheme.error),
const SizedBox(height: 16),
Text(_error!, style: theme.textTheme.bodyLarge),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: _loadChannels,
icon: const AppIcon(Symbols.refresh_rounded),
label: Text(t.common.retry),
),
],
),
)
: _channels.isEmpty
? Center(child: Text(t.liveTv.noChannels))
: Column(
children: [
if (!useSideNav)
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
alignment: Alignment.centerLeft,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_buildTabChip(t.liveTv.guide, 0),
const SizedBox(width: 8),
_buildTabChip(t.liveTv.whatsOn, 1),
],
),
),
),
Expanded(
child: TabBarView(
controller: tabController,
children: [
AppIcon(Symbols.error_rounded,
size: 48, color: theme.colorScheme.error),
const SizedBox(height: 16),
Text(_error!, style: theme.textTheme.bodyLarge),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: _loadChannels,
icon: const AppIcon(Symbols.refresh_rounded),
label: Text(t.common.retry),
GuideTab(
key: _guideTabKey,
channels: _channels,
onNavigateUp: focusTabBar,
onBack: onTabBarBack,
),
WhatsOnTab(
key: _whatsOnTabKey,
channels: _channels,
onNavigateUp: focusTabBar,
onBack: onTabBarBack,
),
],
),
)
: _channels.isEmpty
? Center(child: Text(t.liveTv.noChannels))
: Column(
children: [
if (!useSideNav)
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
alignment: Alignment.centerLeft,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_buildTabChip(t.liveTv.guide, 0),
const SizedBox(width: 8),
_buildTabChip(t.liveTv.whatsOn, 1),
],
),
),
),
Expanded(
child: TabBarView(
controller: tabController,
children: [
GuideTab(channels: _channels),
WhatsOnTab(channels: _channels),
],
),
),
],
),
),
],
),
);
}
}
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import '../../focus/focusable_wrapper.dart';
import '../../i18n/strings.g.dart';
import '../../models/livetv_channel.dart';
import '../../models/livetv_program.dart';
import '../../providers/multi_server_provider.dart';
@@ -9,6 +11,7 @@ import '../../theme/mono_tokens.dart';
import '../../utils/formatters.dart';
import '../../utils/live_tv_player_navigation.dart';
import '../../utils/plex_image_helper.dart';
import '../../widgets/app_icon.dart';
import '../../widgets/focused_scroll_scaffold.dart';
import 'program_details_sheet.dart';
@@ -23,12 +26,7 @@ class LiveTvShowScheduleScreen extends StatefulWidget {
/// Full channel list for tuning.
final List<LiveTvChannel> channels;
const LiveTvShowScheduleScreen({
super.key,
required this.showTitle,
required this.serverId,
required this.channels,
});
const LiveTvShowScheduleScreen({super.key, required this.showTitle, required this.serverId, required this.channels});
@override
State<LiveTvShowScheduleScreen> createState() => _LiveTvShowScheduleScreenState();
@@ -86,9 +84,8 @@ class _LiveTvShowScheduleScreenState extends State<LiveTvShowScheduleScreen> {
Future<void> _tuneChannel(LiveTvChannel channel) async {
final multiServer = context.read<MultiServerProvider>();
final serverInfo = multiServer.liveTvServers
.where((s) => s.serverId == channel.serverId)
.firstOrNull ??
final serverInfo =
multiServer.liveTvServers.where((s) => s.serverId == channel.serverId).firstOrNull ??
multiServer.liveTvServers.firstOrNull;
if (serverInfo == null) return;
@@ -139,24 +136,27 @@ class _LiveTvShowScheduleScreenState extends State<LiveTvShowScheduleScreen> {
SliverFillRemaining(child: Center(child: Text(t.liveTv.noPrograms)))
else
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final program = _programs[index];
final channel = _findChannel(program.channelIdentifier);
return _ScheduleListTile(
program: program,
channel: channel,
onTap: () {
if (program.isCurrentlyAiring && channel != null) {
_tuneChannel(channel);
} else {
_showProgramDetails(program, channel);
}
},
);
},
childCount: _programs.length,
),
delegate: SliverChildBuilderDelegate((context, index) {
final program = _programs[index];
final channel = _findChannel(program.channelIdentifier);
final onTap = () {
if (program.isCurrentlyAiring && channel != null) {
_tuneChannel(channel);
} else {
_showProgramDetails(program, channel);
}
};
return FocusableWrapper(
autofocus: index == 0,
autoScroll: true,
useComfortableZone: true,
useBackgroundFocus: true,
disableScale: true,
onSelect: onTap,
onBack: () => Navigator.pop(context),
child: _ScheduleListTile(program: program, channel: channel, onTap: onTap),
);
}, childCount: _programs.length),
),
],
);
@@ -168,11 +168,7 @@ class _ScheduleListTile extends StatelessWidget {
final LiveTvChannel? channel;
final VoidCallback onTap;
const _ScheduleListTile({
required this.program,
required this.channel,
required this.onTap,
});
const _ScheduleListTile({required this.program, required this.channel, required this.onTap});
String _formatTimeInfo() {
final now = DateTime.now();
@@ -228,14 +224,13 @@ class _ScheduleListTile extends StatelessWidget {
].join('');
return InkWell(
canRequestFocus: false,
onTap: onTap,
child: Container(
decoration: isLive
? BoxDecoration(
color: theme.colorScheme.primary.withValues(alpha: 0.08),
border: Border(
left: BorderSide(color: theme.colorScheme.primary, width: 3),
),
border: Border(left: BorderSide(color: theme.colorScheme.primary, width: 3)),
)
: null,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
@@ -247,17 +242,14 @@ class _ScheduleListTile extends StatelessWidget {
Expanded(
child: Text(
titleText,
style: theme.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
),
style: theme.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (isLive) ...[
const SizedBox(width: 8),
AppIcon(Symbols.play_circle_rounded,
size: 20, color: theme.colorScheme.primary),
AppIcon(Symbols.play_circle_rounded, size: 20, color: theme.colorScheme.primary),
],
],
),
@@ -265,21 +257,14 @@ class _ScheduleListTile extends StatelessWidget {
const SizedBox(height: 4),
Text(
subtitle,
style: theme.textTheme.bodySmall?.copyWith(
color: tokens(context).textMuted,
),
style: theme.textTheme.bodySmall?.copyWith(color: tokens(context).textMuted),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
if (channel != null) ...[
const SizedBox(height: 2),
Text(
channel!.displayName,
style: theme.textTheme.labelSmall?.copyWith(
color: tokens(context).textMuted,
),
),
Text(channel!.displayName, style: theme.textTheme.labelSmall?.copyWith(color: tokens(context).textMuted)),
],
],
),
+181 -59
View File
@@ -1,11 +1,13 @@
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../focus/focusable_wrapper.dart';
import '../../i18n/strings.g.dart';
import '../../models/livetv_channel.dart';
import '../../models/livetv_program.dart';
import '../../utils/formatters.dart';
import '../../widgets/app_icon.dart';
import '../../widgets/focusable_bottom_sheet.dart';
/// Shows a bottom sheet with program details and actions (Record, Watch Channel, Play).
void showProgramDetailsSheet(
@@ -15,12 +17,178 @@ void showProgramDetailsSheet(
required String? posterUrl,
required VoidCallback? onTuneChannel,
}) {
final theme = Theme.of(context);
showModalBottomSheet(
context: context,
builder: (sheetContext) {
return Padding(
return _ProgramDetailsSheetContent(
program: program,
channel: channel,
posterUrl: posterUrl,
onTuneChannel: onTuneChannel,
);
},
);
}
class _ProgramDetailsSheetContent extends StatefulWidget {
final LiveTvProgram program;
final LiveTvChannel? channel;
final String? posterUrl;
final VoidCallback? onTuneChannel;
const _ProgramDetailsSheetContent({
required this.program,
required this.channel,
required this.posterUrl,
required this.onTuneChannel,
});
@override
State<_ProgramDetailsSheetContent> createState() => _ProgramDetailsSheetContentState();
}
class _ProgramDetailsSheetContentState extends State<_ProgramDetailsSheetContent> {
final List<FocusNode> _buttonFocusNodes = [];
FocusNode get _initialFocusNode => _buttonFocusNodes.isNotEmpty ? _buttonFocusNodes.first : FocusNode();
@override
void initState() {
super.initState();
_buildButtonFocusNodes();
}
@override
void dispose() {
for (final node in _buttonFocusNodes) {
node.dispose();
}
super.dispose();
}
void _buildButtonFocusNodes() {
int count = 0;
if (widget.program.isCurrentlyAiring && widget.onTuneChannel != null) count++;
count++; // Record button always present
if (!widget.program.isCurrentlyAiring && widget.onTuneChannel != null) count++;
for (int i = 0; i < count; i++) {
_buttonFocusNodes.add(FocusNode(debugLabel: 'program_sheet_btn_$i'));
}
}
void _focusButton(int index) {
if (index >= 0 && index < _buttonFocusNodes.length) {
_buttonFocusNodes[index].requestFocus();
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final program = widget.program;
final channel = widget.channel;
// Build the list of action buttons with their focus wrappers
final buttons = <Widget>[];
int buttonIndex = 0;
if (program.isCurrentlyAiring && widget.onTuneChannel != null) {
final idx = buttonIndex;
buttons.add(
FocusableWrapper(
focusNode: _buttonFocusNodes[idx],
onSelect: () {
Navigator.of(context).pop();
widget.onTuneChannel!();
},
onNavigateLeft: idx > 0 ? () => _focusButton(idx - 1) : null,
onNavigateRight: idx < _buttonFocusNodes.length - 1 ? () => _focusButton(idx + 1) : null,
onBack: () => Navigator.of(context).pop(),
borderRadius: 100,
useBackgroundFocus: true,
disableScale: true,
child: FilledButton.icon(
style: FilledButton.styleFrom(tapTargetSize: MaterialTapTargetSize.shrinkWrap),
onPressed: () {
Navigator.of(context).pop();
widget.onTuneChannel!();
},
icon: const AppIcon(Symbols.play_arrow_rounded),
label: Text(t.common.play),
),
),
);
buttonIndex++;
}
if (program.isCurrentlyAiring && widget.onTuneChannel != null) {
buttons.add(const SizedBox(width: 8));
}
// Record button
{
final idx = buttonIndex;
buttons.add(
FocusableWrapper(
focusNode: _buttonFocusNodes[idx],
onSelect: () {
Navigator.of(context).pop();
// TODO: Record action
},
onNavigateLeft: idx > 0 ? () => _focusButton(idx - 1) : null,
onNavigateRight: idx < _buttonFocusNodes.length - 1 ? () => _focusButton(idx + 1) : null,
onBack: () => Navigator.of(context).pop(),
borderRadius: 100,
useBackgroundFocus: true,
disableScale: true,
child: OutlinedButton.icon(
style: OutlinedButton.styleFrom(tapTargetSize: MaterialTapTargetSize.shrinkWrap),
onPressed: () {
Navigator.of(context).pop();
// TODO: Record action
},
icon: const AppIcon(Symbols.fiber_manual_record_rounded),
label: Text(t.liveTv.record),
),
),
);
buttonIndex++;
}
if (!program.isCurrentlyAiring && widget.onTuneChannel != null) {
buttons.add(const SizedBox(width: 8));
final idx = buttonIndex;
buttons.add(
FocusableWrapper(
focusNode: _buttonFocusNodes[idx],
onSelect: () {
Navigator.of(context).pop();
widget.onTuneChannel!();
},
onNavigateLeft: idx > 0 ? () => _focusButton(idx - 1) : null,
onNavigateRight: idx < _buttonFocusNodes.length - 1 ? () => _focusButton(idx + 1) : null,
onBack: () => Navigator.of(context).pop(),
borderRadius: 100,
useBackgroundFocus: true,
disableScale: true,
child: OutlinedButton.icon(
style: OutlinedButton.styleFrom(tapTargetSize: MaterialTapTargetSize.shrinkWrap),
onPressed: () {
Navigator.of(context).pop();
widget.onTuneChannel!();
},
icon: const AppIcon(Symbols.live_tv_rounded),
label: Text(t.liveTv.watchChannel),
),
),
);
buttonIndex++;
}
return FocusableBottomSheet(
initialFocusNode: _initialFocusNode,
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
@@ -29,11 +197,11 @@ void showProgramDetailsSheet(
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (posterUrl != null) ...[
if (widget.posterUrl != null) ...[
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Image.network(
posterUrl,
widget.posterUrl!,
width: 80,
height: 120,
fit: BoxFit.cover,
@@ -48,26 +216,14 @@ void showProgramDetailsSheet(
children: [
Row(
children: [
Expanded(
child: Text(
program.displayTitle,
style: theme.textTheme.titleMedium,
),
),
Expanded(child: Text(program.displayTitle, style: theme.textTheme.titleMedium)),
if (program.isCurrentlyAiring)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(4),
),
decoration: BoxDecoration(color: Colors.red, borderRadius: BorderRadius.circular(4)),
child: Text(
t.liveTv.live,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 11,
),
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 11),
),
),
],
@@ -80,9 +236,7 @@ void showProgramDetailsSheet(
'${program.startTime!.hour.toString().padLeft(2, '0')}:${program.startTime!.minute.toString().padLeft(2, '0')} - ${program.endTime!.hour.toString().padLeft(2, '0')}:${program.endTime!.minute.toString().padLeft(2, '0')}',
if (program.durationMinutes > 0) formatDurationTextual(program.durationMinutes * 60000),
].join(' · '),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
if (program.summary != null && program.summary!.isNotEmpty) ...[
const SizedBox(height: 12),
@@ -99,42 +253,10 @@ void showProgramDetailsSheet(
],
),
const SizedBox(height: 16),
Row(
children: [
if (program.isCurrentlyAiring && onTuneChannel != null)
FilledButton.icon(
onPressed: () {
Navigator.of(sheetContext).pop();
onTuneChannel();
},
icon: const AppIcon(Symbols.play_arrow_rounded),
label: Text(t.common.play),
),
if (program.isCurrentlyAiring) const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: () {
Navigator.of(sheetContext).pop();
// TODO: Record action
},
icon: const AppIcon(Symbols.fiber_manual_record_rounded),
label: Text(t.liveTv.record),
),
if (!program.isCurrentlyAiring && onTuneChannel != null) ...[
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: () {
Navigator.of(sheetContext).pop();
onTuneChannel();
},
icon: const AppIcon(Symbols.live_tv_rounded),
label: Text(t.liveTv.watchChannel),
),
],
],
),
Row(children: buttons),
],
),
);
},
);
),
);
}
}
+464 -203
View File
@@ -1,9 +1,12 @@
import 'dart:async';
import 'package:flutter/material.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/key_event_utils.dart';
import '../../../i18n/strings.g.dart';
import '../../../models/livetv_channel.dart';
import '../../../models/livetv_program.dart';
@@ -13,17 +16,22 @@ import '../../../utils/formatters.dart';
import '../../../utils/plex_image_helper.dart';
import '../../../utils/live_tv_player_navigation.dart';
import '../../../widgets/app_icon.dart';
import '../program_details_sheet.dart';
class GuideTab extends StatefulWidget {
final List<LiveTvChannel> channels;
final VoidCallback? onNavigateUp;
final VoidCallback? onBack;
const GuideTab({super.key, required this.channels});
const GuideTab({super.key, required this.channels, this.onNavigateUp, this.onBack});
@override
State<GuideTab> createState() => _GuideTabState();
State<GuideTab> createState() => GuideTabState();
}
class _GuideTabState extends State<GuideTab> {
enum _GuideZone { timeNav, grid }
class GuideTabState extends State<GuideTab> {
static const _slotWidth = 180.0;
static const _channelColumnWidth = 140.0;
static const _rowHeight = 64.0;
@@ -39,11 +47,44 @@ class _GuideTabState extends State<GuideTab> {
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;
LiveTvProgram? _focusedProgram;
bool _pendingFocus = false;
/// Focus into the guide content (called from tab bar navigation or initial load).
void focusContent() {
// 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();
@@ -58,6 +99,27 @@ class _GuideTabState extends State<GuideTab> {
});
}
@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();
super.dispose();
}
void _syncGridToHeader() {
if (_syncingScroll) return;
_syncingScroll = true;
@@ -76,17 +138,6 @@ class _GuideTabState extends State<GuideTab> {
_syncingScroll = false;
}
@override
void dispose() {
_gridHorizontalController.removeListener(_syncGridToHeader);
_headerHorizontalController.removeListener(_syncHeaderToGrid);
_headerHorizontalController.dispose();
_gridHorizontalController.dispose();
_channelVerticalController.dispose();
_timeIndicatorTimer?.cancel();
super.dispose();
}
void _initTimeRange() {
final now = DateTime.now();
_gridStart = DateTime(now.year, now.month, now.day, now.hour);
@@ -154,12 +205,20 @@ class _GuideTabState extends State<GuideTab> {
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) {
@@ -215,6 +274,213 @@ class _GuideTabState extends State<GuideTab> {
);
}
// ---------------------------------------------------------------------------
// Focus key handling
// ---------------------------------------------------------------------------
KeyEventResult _handleKeyEvent(FocusNode node, 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;
if (_focusZone == _GuideZone.timeNav) {
return _handleTimeNavKey(key);
} else {
return _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);
}
}
return KeyEventResult.handled;
}
if (key.isLeftKey) {
if (_gridColumn == 1) {
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;
}
return KeyEventResult.ignored;
}
// ---------------------------------------------------------------------------
// Focus helpers
// ---------------------------------------------------------------------------
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;
}
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.jumpTo(clamped);
if (_channelVerticalController.hasClients) {
_channelVerticalController.jumpTo(
clamped.clamp(0.0, _channelVerticalController.position.maxScrollExtent),
);
}
}
}
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));
}
}
// ---------------------------------------------------------------------------
// Build
// ---------------------------------------------------------------------------
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
@@ -223,7 +489,12 @@ class _GuideTabState extends State<GuideTab> {
return const Center(child: CircularProgressIndicator());
}
return _buildGuideGrid(theme);
return Focus(
focusNode: _guideFocusNode,
onFocusChange: (hasFocus) => setState(() => _hasFocus = hasFocus),
onKeyEvent: _handleKeyEvent,
child: _buildGuideGrid(theme),
);
}
Widget _buildGuideGrid(ThemeData theme) {
@@ -257,7 +528,7 @@ class _GuideTabState extends State<GuideTab> {
itemCount: widget.channels.length,
itemExtent: _rowHeight,
itemBuilder: (context, index) =>
_buildChannelCell(widget.channels[index], theme),
_buildChannelCell(widget.channels[index], theme, index: index),
),
),
Expanded(
@@ -279,12 +550,13 @@ class _GuideTabState extends State<GuideTab> {
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);
return _buildProgramRow(channel, programs, theme, channelIndex: index);
},
),
),
@@ -382,9 +654,13 @@ class _GuideTabState extends State<GuideTab> {
}),
],
).then((value) {
if (value == null) return;
if (value == null) {
_guideFocusNode.requestFocus();
return;
}
if (value is String && value == 'now') {
_jumpToNow();
_guideFocusNode.requestFocus();
} else if (value is DateTime) {
_showTimeSlotPicker(value);
}
@@ -421,7 +697,10 @@ class _GuideTabState extends State<GuideTab> {
}),
],
).then((value) {
if (value == null) return;
if (value == null) {
_guideFocusNode.requestFocus();
return;
}
if (value == -1) {
_showDayPicker();
return;
@@ -431,9 +710,26 @@ class _GuideTabState extends State<GuideTab> {
_gridEnd = _gridStart.add(const Duration(hours: 6));
});
_loadPrograms();
_guideFocusNode.requestFocus();
});
}
// ---------------------------------------------------------------------------
// Time navigation bar
// ---------------------------------------------------------------------------
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: BorderRadius.circular(8),
),
child: child,
);
}
Widget _buildTimeNavigation(ThemeData theme) {
final format = MaterialLocalizations.of(context);
final timeLabel =
@@ -449,30 +745,41 @@ class _GuideTabState extends State<GuideTab> {
),
child: Row(
children: [
IconButton(
icon: const AppIcon(Symbols.chevron_left_rounded),
onPressed: () => _shiftTimeRange(-2),
iconSize: 20,
visualDensity: VisualDensity.compact,
_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: [
GestureDetector(
key: _dayPickerKey,
onTap: _showDayPicker,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
dayLabel,
style: theme.textTheme.labelLarge,
_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: 2),
AppIcon(Symbols.arrow_drop_down_rounded,
size: 18, color: theme.colorScheme.onSurface),
],
),
),
),
const SizedBox(width: 8),
@@ -483,17 +790,25 @@ class _GuideTabState extends State<GuideTab> {
],
),
),
IconButton(
icon: const AppIcon(Symbols.chevron_right_rounded),
onPressed: () => _shiftTimeRange(2),
iconSize: 20,
visualDensity: VisualDensity.compact,
_timeNavFocusWrap(
index: 2,
theme: theme,
child: IconButton(
icon: const AppIcon(Symbols.chevron_right_rounded),
onPressed: () => _shiftTimeRange(2),
iconSize: 20,
visualDensity: VisualDensity.compact,
),
),
],
),
);
}
// ---------------------------------------------------------------------------
// Time header & now indicator
// ---------------------------------------------------------------------------
Widget _buildTimeHeader(ThemeData theme) {
final slots = <Widget>[];
var current = _gridStart;
@@ -545,7 +860,11 @@ class _GuideTabState extends State<GuideTab> {
);
}
Widget _buildChannelCell(LiveTvChannel channel, ThemeData theme) {
// ---------------------------------------------------------------------------
// Channel column
// ---------------------------------------------------------------------------
Widget _buildChannelCell(LiveTvChannel channel, ThemeData theme, {required int index}) {
final multiServer = context.read<MultiServerProvider>();
final client = multiServer.getClientForServer(channel.serverId ?? '');
@@ -561,6 +880,8 @@ class _GuideTabState extends State<GuideTab> {
);
}
final isFocused = _hasFocus && _focusZone == _GuideZone.grid && _gridColumn == 0 && _gridChannelIndex == index;
return _ChannelCell(
rowHeight: _rowHeight,
channelColumnWidth: _channelColumnWidth,
@@ -568,6 +889,7 @@ class _GuideTabState extends State<GuideTab> {
channel: channel,
theme: theme,
onTap: () => _tuneChannel(channel),
isFocused: isFocused,
fallbackBuilder: () => _buildChannelNameFallback(channel, theme),
);
}
@@ -595,8 +917,13 @@ class _GuideTabState extends State<GuideTab> {
);
}
// ---------------------------------------------------------------------------
// Program grid
// ---------------------------------------------------------------------------
Widget _buildProgramRow(
LiveTvChannel channel, List<LiveTvProgram> programs, ThemeData theme) {
LiveTvChannel channel, List<LiveTvProgram> programs, ThemeData theme,
{required int channelIndex}) {
if (programs.isEmpty) {
return Container(
height: _rowHeight,
@@ -620,6 +947,14 @@ class _GuideTabState extends State<GuideTab> {
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);
@@ -639,7 +974,11 @@ class _GuideTabState extends State<GuideTab> {
width: width.clamp(2.0, double.infinity),
top: 0,
bottom: 0,
child: _buildProgramBlock(channel, program, theme, isLast: program == programs.last),
child: _buildProgramBlock(
channel, program, theme,
isLast: program == programs.last,
isFocused: identical(program, focusProg),
),
),
);
}
@@ -661,7 +1000,8 @@ class _GuideTabState extends State<GuideTab> {
}
Widget _buildProgramBlock(
LiveTvChannel channel, LiveTvProgram program, ThemeData theme, {bool isLast = false}) {
LiveTvChannel channel, LiveTvProgram program, ThemeData theme,
{bool isLast = false, bool isFocused = false}) {
final isCurrentlyAiring = program.isCurrentlyAiring;
final isPast = program.endsAt != null &&
program.endsAt! < DateTime.now().millisecondsSinceEpoch ~/ 1000;
@@ -669,71 +1009,83 @@ class _GuideTabState extends State<GuideTab> {
return Opacity(
opacity: isPast ? 0.5 : 1.0,
child: Material(
color: isCurrentlyAiring
? theme.colorScheme.primaryContainer
: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(4),
child: InkWell(
borderRadius: BorderRadius.circular(4),
onTap: () => _showProgramDetails(channel, program),
child: Container(
decoration: BoxDecoration(
border: Border(
left: BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)),
right: isLast ? BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)) : BorderSide.none,
),
),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
program.grandparentTitle ?? program.title,
style: theme.textTheme.bodySmall?.copyWith(
fontWeight:
isCurrentlyAiring ? FontWeight.w600 : FontWeight.normal,
color: isCurrentlyAiring
? theme.colorScheme.onPrimaryContainer
: theme.colorScheme.onSurface,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
color: isFocused
? theme.colorScheme.primary.withValues(alpha: 0.25)
: isCurrentlyAiring
? theme.colorScheme.primaryContainer
: theme.colorScheme.surfaceContainerHigh,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
side: isFocused
? BorderSide(color: theme.colorScheme.primary, width: 2)
: BorderSide.none,
),
child: InkWell(
canRequestFocus: false,
borderRadius: BorderRadius.circular(4),
onTap: () => _showProgramDetails(channel, program),
child: Container(
decoration: BoxDecoration(
border: Border(
left: BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)),
right: isLast ? BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)) : BorderSide.none,
),
if (program.grandparentTitle != null)
),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'${program.parentIndex != null && program.index != null ? 'S${program.parentIndex}E${program.index} · ' : ''}${program.title}',
style: theme.textTheme.labelSmall?.copyWith(
color: isCurrentlyAiring
? theme.colorScheme.onPrimaryContainer
.withValues(alpha: 0.7)
: theme.colorScheme.onSurfaceVariant,
program.grandparentTitle ?? program.title,
style: theme.textTheme.bodySmall?.copyWith(
fontWeight:
isCurrentlyAiring ? FontWeight.w600 : FontWeight.normal,
color: isFocused
? theme.colorScheme.primary
: isCurrentlyAiring
? theme.colorScheme.onPrimaryContainer
: theme.colorScheme.onSurface,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (program.startTime != null)
Text(
'${program.startTime!.hour.toString().padLeft(2, '0')}:${program.startTime!.minute.toString().padLeft(2, '0')} · ${formatDurationTextual(program.durationMinutes * 60000)}',
style: theme.textTheme.labelSmall?.copyWith(
color: isCurrentlyAiring
? theme.colorScheme.onPrimaryContainer
.withValues(alpha: 0.7)
: theme.colorScheme.onSurfaceVariant,
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: isFocused
? theme.colorScheme.primary.withValues(alpha: 0.7)
: isCurrentlyAiring
? theme.colorScheme.onPrimaryContainer
.withValues(alpha: 0.7)
: theme.colorScheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
maxLines: 1,
),
],
if (program.startTime != null)
Text(
'${program.startTime!.hour.toString().padLeft(2, '0')}:${program.startTime!.minute.toString().padLeft(2, '0')} · ${formatDurationTextual(program.durationMinutes * 60000)}',
style: theme.textTheme.labelSmall?.copyWith(
color: isFocused
? theme.colorScheme.primary.withValues(alpha: 0.7)
: isCurrentlyAiring
? theme.colorScheme.onPrimaryContainer
.withValues(alpha: 0.7)
: theme.colorScheme.onSurfaceVariant,
),
maxLines: 1,
),
],
),
),
),
),
),
);
}
void _showProgramDetails(LiveTvChannel channel, LiveTvProgram program) {
final theme = Theme.of(context);
final multiServer = context.read<MultiServerProvider>();
final client = multiServer.getClientForServer(channel.serverId ?? '');
String? posterUrl;
@@ -748,109 +1100,12 @@ class _GuideTabState extends State<GuideTab> {
);
}
showModalBottomSheet(
context: context,
builder: (sheetContext) {
return Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (posterUrl != null) ...[
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Image.network(
posterUrl,
width: 80,
height: 120,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
),
),
const SizedBox(width: 14),
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
program.displayTitle,
style: theme.textTheme.titleMedium,
),
),
if (program.isCurrentlyAiring)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(4),
),
child: Text(
t.liveTv.live,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 11),
),
),
],
),
const SizedBox(height: 4),
Text(
'${channel.displayName} · ${program.startTime?.hour.toString().padLeft(2, '0')}:${program.startTime?.minute.toString().padLeft(2, '0')} - ${program.endTime?.hour.toString().padLeft(2, '0')}:${program.endTime?.minute.toString().padLeft(2, '0')} · ${formatDurationTextual(program.durationMinutes * 60000)}',
style: theme.textTheme.bodySmall
?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
if (program.summary != null &&
program.summary!.isNotEmpty) ...[
const SizedBox(height: 12),
Text(
program.summary!,
style: theme.textTheme.bodyMedium,
maxLines: 4,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
],
),
const SizedBox(height: 16),
Row(
children: [
if (program.isCurrentlyAiring)
FilledButton.icon(
onPressed: () {
Navigator.of(sheetContext).pop();
_tuneChannel(channel);
},
icon: const AppIcon(Symbols.play_arrow_rounded),
label: Text(t.common.play),
),
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: () {
Navigator.of(sheetContext).pop();
// TODO: Record action
},
icon: const AppIcon(Symbols.fiber_manual_record_rounded),
label: Text(t.liveTv.record),
),
],
),
],
),
);
},
showProgramDetailsSheet(
context,
program: program,
channel: channel,
posterUrl: posterUrl,
onTuneChannel: () => _tuneChannel(channel),
);
}
}
@@ -862,6 +1117,7 @@ class _ChannelCell extends StatefulWidget {
final LiveTvChannel channel;
final ThemeData theme;
final VoidCallback onTap;
final bool isFocused;
final Widget Function() fallbackBuilder;
const _ChannelCell({
@@ -871,6 +1127,7 @@ class _ChannelCell extends StatefulWidget {
required this.channel,
required this.theme,
required this.onTap,
required this.isFocused,
required this.fallbackBuilder,
});
@@ -884,13 +1141,17 @@ class _ChannelCellState extends State<_ChannelCell> {
@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: Material(
color: Colors.transparent,
color: widget.isFocused
? theme.colorScheme.primary.withValues(alpha: 0.15)
: Colors.transparent,
child: InkWell(
canRequestFocus: false,
onTap: widget.onTap,
child: Container(
height: widget.rowHeight,
@@ -907,7 +1168,7 @@ class _ChannelCellState extends State<_ChannelCell> {
alignment: Alignment.center,
children: [
AnimatedOpacity(
opacity: _hovered ? 0.3 : 1.0,
opacity: showAction ? 0.3 : 1.0,
duration: const Duration(milliseconds: 150),
child: widget.imageUrl != null && widget.imageUrl!.isNotEmpty
? Image.network(
@@ -920,7 +1181,7 @@ class _ChannelCellState extends State<_ChannelCell> {
)
: widget.fallbackBuilder(),
),
if (_hovered)
if (showAction)
AppIcon(
Symbols.play_arrow_rounded,
size: 32,
+327 -65
View File
@@ -1,9 +1,14 @@
import 'dart:async';
import 'package:flutter/material.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/key_event_utils.dart';
import '../../../focus/locked_hub_controller.dart';
import '../../../i18n/strings.g.dart';
import '../../../models/livetv_channel.dart';
import '../../../models/livetv_hub_result.dart';
import '../../../providers/multi_server_provider.dart';
@@ -16,6 +21,7 @@ import '../../../utils/live_tv_player_navigation.dart';
import '../../../utils/plex_image_helper.dart';
import '../../../utils/provider_extensions.dart';
import '../../../widgets/app_icon.dart';
import '../../../widgets/focus_builders.dart';
import '../../../widgets/horizontal_scroll_with_arrows.dart';
import '../../../widgets/plex_optimized_image.dart';
import '../live_tv_show_schedule_screen.dart';
@@ -23,17 +29,20 @@ import '../program_details_sheet.dart';
class WhatsOnTab extends StatefulWidget {
final List<LiveTvChannel> channels;
final VoidCallback? onNavigateUp;
final VoidCallback? onBack;
const WhatsOnTab({super.key, required this.channels});
const WhatsOnTab({super.key, required this.channels, this.onNavigateUp, this.onBack});
@override
State<WhatsOnTab> createState() => _WhatsOnTabState();
State<WhatsOnTab> createState() => WhatsOnTabState();
}
class _WhatsOnTabState extends State<WhatsOnTab> {
class WhatsOnTabState extends State<WhatsOnTab> {
List<LiveTvHubResult> _hubs = [];
bool _isLoading = true;
Timer? _refreshTimer;
List<GlobalKey<_LiveTvHubSectionState>> _hubKeys = [];
@override
void initState() {
@@ -70,6 +79,7 @@ class _WhatsOnTabState extends State<WhatsOnTab> {
if (!mounted) return;
setState(() {
_hubs = allHubs;
_hubKeys = List.generate(allHubs.length, (_) => GlobalKey<_LiveTvHubSectionState>());
_isLoading = false;
});
} catch (e) {
@@ -78,6 +88,36 @@ class _WhatsOnTabState extends State<WhatsOnTab> {
}
}
/// Focus the first hub (called from parent when tab bar navigates down)
void focusFirstHub() {
if (_hubKeys.isNotEmpty) {
_hubKeys[0].currentState?.requestFocusFromMemory();
}
}
bool _handleVerticalNavigation(int hubIndex, bool isUp) {
if (_hubKeys.isEmpty) return false;
if (isUp && hubIndex == 0) {
widget.onNavigateUp?.call();
return true;
}
final targetIndex = isUp ? hubIndex - 1 : hubIndex + 1;
if (targetIndex < 0 || targetIndex >= _hubKeys.length) {
return true; // At boundary, consume the event
}
final targetState = _hubKeys[targetIndex].currentState;
if (targetState != null) {
targetState.requestFocusFromMemory();
return true;
}
return false;
}
/// Find a channel by its identifier from the channel list.
LiveTvChannel? _findChannel(String? channelIdentifier) {
if (channelIdentifier == null) return null;
@@ -88,9 +128,8 @@ class _WhatsOnTabState extends State<WhatsOnTab> {
Future<void> _tuneChannel(LiveTvChannel channel) async {
final multiServer = context.read<MultiServerProvider>();
final serverInfo = multiServer.liveTvServers
.where((s) => s.serverId == channel.serverId)
.firstOrNull ??
final serverInfo =
multiServer.liveTvServers.where((s) => s.serverId == channel.serverId).firstOrNull ??
multiServer.liveTvServers.firstOrNull;
if (serverInfo == null) return;
@@ -173,9 +212,12 @@ class _WhatsOnTabState extends State<WhatsOnTab> {
itemCount: _hubs.length,
itemBuilder: (context, index) {
return _LiveTvHubSection(
key: _hubKeys[index],
hub: _hubs[index],
onTap: _onItemTap,
onLongPress: (entry) => _showProgramDetails(entry, _findChannel(entry.program.channelIdentifier)),
onVerticalNavigation: (isUp) => _handleVerticalNavigation(index, isUp),
onBack: widget.onBack,
);
},
);
@@ -184,21 +226,228 @@ class _WhatsOnTabState extends State<WhatsOnTab> {
// ---------------------------------------------------------------------------
// Hub section — horizontal scrolling row of poster cards (always 2:3 aspect)
// Uses locked focus pattern: single Focus node at hub level, visual index in state.
// ---------------------------------------------------------------------------
class _LiveTvHubSection extends StatelessWidget {
class _LiveTvHubSection extends StatefulWidget {
final LiveTvHubResult hub;
final void Function(LiveTvHubEntry) onTap;
final void Function(LiveTvHubEntry) onLongPress;
final bool Function(bool isUp)? onVerticalNavigation;
final VoidCallback? onBack;
const _LiveTvHubSection({
super.key,
required this.hub,
required this.onTap,
required this.onLongPress,
this.onVerticalNavigation,
this.onBack,
});
@override
State<_LiveTvHubSection> createState() => _LiveTvHubSectionState();
}
class _LiveTvHubSectionState extends State<_LiveTvHubSection> {
static const _longPressDuration = Duration(milliseconds: 500);
late FocusNode _hubFocusNode;
final ScrollController _scrollController = ScrollController();
int _focusedIndex = 0;
double _itemExtent = 0;
static const double _leadingPadding = 12.0;
Timer? _longPressTimer;
bool _isSelectKeyDown = false;
bool _longPressTriggered = false;
@override
void initState() {
super.initState();
_hubFocusNode = FocusNode(debugLabel: 'livetv_hub_${widget.hub.hubKey}');
_hubFocusNode.addListener(_onFocusChange);
}
@override
void didUpdateWidget(_LiveTvHubSection oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.hub.entries.length != oldWidget.hub.entries.length) {
final maxIndex = widget.hub.entries.isEmpty ? 0 : widget.hub.entries.length - 1;
if (_focusedIndex > maxIndex) {
_focusedIndex = maxIndex;
}
}
}
@override
void dispose() {
_longPressTimer?.cancel();
_hubFocusNode.removeListener(_onFocusChange);
_hubFocusNode.dispose();
_scrollController.dispose();
super.dispose();
}
void _onFocusChange() {
if (!_hubFocusNode.hasFocus) {
_longPressTimer?.cancel();
_isSelectKeyDown = false;
_longPressTriggered = false;
}
if (mounted) setState(() {});
}
void requestFocusAt(int index) {
if (widget.hub.entries.isEmpty) return;
final clamped = index.clamp(0, widget.hub.entries.length - 1);
_focusedIndex = clamped;
HubFocusMemory.setForHub(widget.hub.hubKey, clamped);
_scrollToIndex(clamped);
_hubFocusNode.requestFocus();
if (mounted) setState(() {});
_scrollHubIntoView();
}
void requestFocusFromMemory() {
final index = HubFocusMemory.getForHub(widget.hub.hubKey, widget.hub.entries.length);
requestFocusAt(index);
}
void _scrollHubIntoView() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
Scrollable.ensureVisible(
context,
alignment: 0.3,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
});
}
void _scrollToIndex(int index, {bool animate = true}) {
if (!_scrollController.hasClients || _itemExtent <= 0) return;
final viewport = _scrollController.position.viewportDimension;
final targetCenter = _leadingPadding + (index * _itemExtent) + (_itemExtent / 2);
final desiredOffset = (targetCenter - (viewport / 2)).clamp(0.0, _scrollController.position.maxScrollExtent);
if (animate) {
_scrollController.animateTo(desiredOffset, duration: const Duration(milliseconds: 150), curve: Curves.easeOut);
} else {
_scrollController.jumpTo(desiredOffset);
}
}
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
final key = event.logicalKey;
if (key.isSelectKey) {
if (event is KeyDownEvent) {
if (!_isSelectKeyDown) {
_isSelectKeyDown = true;
_longPressTriggered = false;
_longPressTimer?.cancel();
_longPressTimer = Timer(_longPressDuration, () {
if (!mounted) return;
if (_isSelectKeyDown) {
_longPressTriggered = true;
SelectKeyUpSuppressor.suppressSelectUntilKeyUp();
_activateLongPress();
}
});
}
return KeyEventResult.handled;
} else if (event is KeyRepeatEvent) {
return KeyEventResult.handled;
} else if (event is KeyUpEvent) {
final timerWasActive = _longPressTimer?.isActive ?? false;
_longPressTimer?.cancel();
if (!_longPressTriggered && timerWasActive && _isSelectKeyDown) {
_activateCurrentItem();
}
_isSelectKeyDown = false;
_longPressTriggered = false;
return KeyEventResult.handled;
}
}
if (widget.onBack != null) {
final backResult = handleBackKeyAction(event, widget.onBack!);
if (backResult != KeyEventResult.ignored) {
return backResult;
}
}
if (!event.isActionable) {
return KeyEventResult.ignored;
}
final itemCount = widget.hub.entries.length;
if (itemCount == 0) return KeyEventResult.ignored;
if (key.isLeftKey) {
if (_focusedIndex > 0) {
_focusedIndex--;
HubFocusMemory.setForHub(widget.hub.hubKey, _focusedIndex);
_scrollToIndex(_focusedIndex);
setState(() {});
} else {
widget.onBack?.call();
}
return KeyEventResult.handled;
}
if (key.isRightKey) {
if (_focusedIndex < itemCount - 1) {
_focusedIndex++;
HubFocusMemory.setForHub(widget.hub.hubKey, _focusedIndex);
_scrollToIndex(_focusedIndex);
setState(() {});
}
return KeyEventResult.handled;
}
if (key.isUpKey) {
widget.onVerticalNavigation?.call(true);
return KeyEventResult.handled;
}
if (key.isDownKey) {
widget.onVerticalNavigation?.call(false);
return KeyEventResult.handled;
}
if (key.isContextMenuKey) {
_activateLongPress();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
void _activateCurrentItem() {
if (_focusedIndex >= widget.hub.entries.length) return;
widget.onTap(widget.hub.entries[_focusedIndex]);
}
void _activateLongPress() {
if (_focusedIndex >= widget.hub.entries.length) return;
widget.onLongPress(widget.hub.entries[_focusedIndex]);
}
void _onItemTapped(int index) {
_focusedIndex = index;
HubFocusMemory.setForHub(widget.hub.hubKey, index);
_hubFocusNode.requestFocus();
setState(() {});
}
@override
Widget build(BuildContext context) {
final hasFocus = _hubFocusNode.hasFocus;
final settings = context.watch<SettingsProvider>();
final densityScale = switch (settings.libraryDensity) {
LibraryDensity.compact => 0.8,
@@ -220,7 +469,7 @@ class _LiveTvHubSection extends StatelessWidget {
const SizedBox(width: 8),
Flexible(
child: Text(
hub.title,
widget.hub.title,
style: Theme.of(context).textTheme.titleLarge,
overflow: TextOverflow.ellipsis,
maxLines: 1,
@@ -230,50 +479,67 @@ class _LiveTvHubSection extends StatelessWidget {
),
),
// Horizontal cards — always poster (2:3) aspect
LayoutBuilder(
builder: (context, constraints) {
final screenWidth = constraints.maxWidth;
final baseCardWidth = (ScreenBreakpoints.isLargeDesktop(screenWidth)
? 220.0
: ScreenBreakpoints.isDesktop(screenWidth)
// Horizontal cards with locked focus control
if (widget.hub.entries.isNotEmpty)
Focus(
focusNode: _hubFocusNode,
onKeyEvent: _handleKeyEvent,
child: LayoutBuilder(
builder: (context, constraints) {
final screenWidth = constraints.maxWidth;
final baseCardWidth =
(ScreenBreakpoints.isLargeDesktop(screenWidth)
? 220.0
: ScreenBreakpoints.isDesktop(screenWidth)
? 200.0
: ScreenBreakpoints.isWideTablet(screenWidth)
? 190.0
: 160.0) *
densityScale;
? 190.0
: 160.0) *
densityScale;
final cardWidth = baseCardWidth;
final posterWidth = cardWidth - 16;
final posterHeight = posterWidth * 1.5; // 2:3 aspect
final containerHeight = posterHeight + 66;
final cardWidth = baseCardWidth;
final posterWidth = cardWidth - 16;
final posterHeight = posterWidth * 1.5; // 2:3 aspect
final containerHeight = posterHeight + 66;
_itemExtent = cardWidth + 4;
return SizedBox(
height: containerHeight,
child: HorizontalScrollWithArrows(
builder: (scrollController) => ListView.builder(
controller: scrollController,
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
itemCount: hub.entries.length,
itemBuilder: (context, index) {
final entry = hub.entries[index];
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: _LiveTvPosterCard(
entry: entry,
width: cardWidth,
posterHeight: posterHeight,
onTap: () => onTap(entry),
onLongPress: () => onLongPress(entry),
),
);
},
),
),
);
},
),
return SizedBox(
height: containerHeight,
child: HorizontalScrollWithArrows(
controller: _scrollController,
builder: (scrollController) => ListView.builder(
controller: scrollController,
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
itemCount: widget.hub.entries.length,
itemBuilder: (context, index) {
final entry = widget.hub.entries[index];
final isItemFocused = hasFocus && index == _focusedIndex;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: _LiveTvPosterCard(
entry: entry,
width: cardWidth,
posterHeight: posterHeight,
isFocused: isItemFocused,
onTap: () {
_onItemTapped(index);
widget.onTap(entry);
},
onLongPress: () {
_onItemTapped(index);
widget.onLongPress(entry);
},
),
);
},
),
),
);
},
),
),
],
);
}
@@ -287,6 +553,7 @@ class _LiveTvPosterCard extends StatelessWidget {
final LiveTvHubEntry entry;
final double width;
final double posterHeight;
final bool isFocused;
final VoidCallback onTap;
final VoidCallback onLongPress;
@@ -294,6 +561,7 @@ class _LiveTvPosterCard extends StatelessWidget {
required this.entry,
required this.width,
required this.posterHeight,
required this.isFocused,
required this.onTap,
required this.onLongPress,
});
@@ -304,13 +572,13 @@ class _LiveTvPosterCard extends StatelessWidget {
// Always use poster image: show poster for episodes, thumb for others
final posterImage = metadata.grandparentThumb ?? metadata.thumb;
return SizedBox(
width: width,
child: InkWell(
canRequestFocus: false,
onTap: onTap,
onLongPress: onLongPress,
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
return FocusBuilders.buildLockedFocusWrapper(
context: context,
isFocused: isFocused,
onTap: onTap,
onLongPress: onLongPress,
child: SizedBox(
width: width,
child: Padding(
padding: const EdgeInsets.all(8),
child: Column(
@@ -337,11 +605,7 @@ class _LiveTvPosterCard extends StatelessWidget {
metadata.displayTitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 13,
height: 1.1,
),
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13, height: 1.1),
),
// Subtitle
if (metadata.displaySubtitle != null)
@@ -349,11 +613,9 @@ class _LiveTvPosterCard extends StatelessWidget {
metadata.displaySubtitle!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: tokens(context).textMuted,
fontSize: 11,
height: 1.1,
),
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: tokens(context).textMuted, fontSize: 11, height: 1.1),
),
],
),