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:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../focus/focusable_wrapper.dart';
import '../../focus/key_event_utils.dart'; import '../../focus/key_event_utils.dart';
import '../../i18n/strings.g.dart'; import '../../i18n/strings.g.dart';
import '../../models/livetv_scheduled_recording.dart'; import '../../models/livetv_scheduled_recording.dart';
@@ -102,14 +103,8 @@ class _DvrRecordingsScreenState extends State<DvrRecordingsScreen> with SingleTi
title: Text(t.liveTv.deleteSubscription), title: Text(t.liveTv.deleteSubscription),
content: Text(t.liveTv.deleteSubscriptionConfirm), content: Text(t.liveTv.deleteSubscriptionConfirm),
actions: [ actions: [
TextButton( TextButton(onPressed: () => Navigator.of(context).pop(false), child: Text(t.common.cancel)),
onPressed: () => Navigator.of(context).pop(false), FilledButton(onPressed: () => Navigator.of(context).pop(true), child: Text(t.common.delete)),
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; if (confirmed != true || !mounted) return;
final multiServer = context.read<MultiServerProvider>(); final multiServer = context.read<MultiServerProvider>();
final client = subscription.serverId != null final client = subscription.serverId != null ? multiServer.getClientForServer(subscription.serverId!) : null;
? multiServer.getClientForServer(subscription.serverId!)
: null;
if (client != null) { if (client != null) {
final success = await client.deleteSubscription(subscription.key); final success = await client.deleteSubscription(subscription.key);
@@ -132,9 +125,7 @@ class _DvrRecordingsScreenState extends State<DvrRecordingsScreen> with SingleTi
Future<void> _editSubscription(LiveTvSubscription subscription) async { Future<void> _editSubscription(LiveTvSubscription subscription) async {
// Filter to visible settings only // Filter to visible settings only
final editableSettings = subscription.settings final editableSettings = subscription.settings.where((s) => s.hidden != true).toList();
.where((s) => s.hidden != true)
.toList();
if (editableSettings.isEmpty) return; if (editableSettings.isEmpty) return;
@@ -145,19 +136,14 @@ class _DvrRecordingsScreenState extends State<DvrRecordingsScreen> with SingleTi
final result = await showDialog<Map<String, String>?>( final result = await showDialog<Map<String, String>?>(
context: context, context: context,
builder: (dialogContext) => _SubscriptionEditDialog( builder: (dialogContext) =>
subscription: subscription, _SubscriptionEditDialog(subscription: subscription, settings: editableSettings, initialPrefs: prefs),
settings: editableSettings,
initialPrefs: prefs,
),
); );
if (result == null || !mounted) return; if (result == null || !mounted) return;
final multiServer = context.read<MultiServerProvider>(); final multiServer = context.read<MultiServerProvider>();
final client = subscription.serverId != null final client = subscription.serverId != null ? multiServer.getClientForServer(subscription.serverId!) : null;
? multiServer.getClientForServer(subscription.serverId!)
: null;
if (client != null) { if (client != null) {
final success = await client.editSubscription(subscription.key, result); final success = await client.editSubscription(subscription.key, result);
@@ -192,27 +178,24 @@ class _DvrRecordingsScreenState extends State<DvrRecordingsScreen> with SingleTi
body: _isLoading body: _isLoading
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: _error != null : _error != null
? Center( ? Center(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text(_error!, style: theme.textTheme.bodyLarge), Text(_error!, style: theme.textTheme.bodyLarge),
const SizedBox(height: 16), const SizedBox(height: 16),
FilledButton.icon( FilledButton.icon(
onPressed: _loadData, onPressed: _loadData,
icon: const AppIcon(Symbols.refresh_rounded), icon: const AppIcon(Symbols.refresh_rounded),
label: Text(t.common.retry), label: Text(t.common.retry),
),
],
), ),
) ],
: TabBarView( ),
controller: _tabController, )
children: [ : TabBarView(
_buildSubscriptionsTab(theme), controller: _tabController,
_buildScheduledTab(theme), children: [_buildSubscriptionsTab(theme), _buildScheduledTab(theme)],
], ),
),
), ),
), ),
); );
@@ -228,42 +211,45 @@ class _DvrRecordingsScreenState extends State<DvrRecordingsScreen> with SingleTi
itemCount: _subscriptions.length, itemCount: _subscriptions.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final sub = _subscriptions[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) { Widget _buildSubscriptionCard(LiveTvSubscription subscription, ThemeData theme) {
return Card( return ExcludeFocus(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), child: Card(
child: ListTile( margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
leading: const AppIcon(Symbols.fiber_dvr_rounded, size: 32), child: ListTile(
title: Text( leading: const AppIcon(Symbols.fiber_dvr_rounded, size: 32),
subscription.title, title: Text(subscription.title, maxLines: 1, overflow: TextOverflow.ellipsis),
maxLines: 1, subtitle: subscription.type != null
overflow: TextOverflow.ellipsis, ? Text(
), subscription.type!,
subtitle: subscription.type != null style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
? Text( )
subscription.type!, : null,
style: theme.textTheme.bodySmall?.copyWith( trailing: Row(
color: theme.colorScheme.onSurfaceVariant, 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( IconButton(
icon: const AppIcon(Symbols.settings_rounded), icon: AppIcon(Symbols.delete_rounded, color: theme.colorScheme.error),
onPressed: () => _editSubscription(subscription), 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, itemCount: _scheduled.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final recording = _scheduled[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')}' ? '${startTime.month}/${startTime.day} ${startTime.hour.toString().padLeft(2, '0')}:${startTime.minute.toString().padLeft(2, '0')}'
: ''; : '';
return Card( return ExcludeFocus(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), child: Card(
child: ListTile( margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
leading: const AppIcon(Symbols.fiber_manual_record_rounded, size: 32, color: Colors.red), child: ListTile(
title: Text( leading: const AppIcon(Symbols.fiber_manual_record_rounded, size: 32, color: Colors.red),
recording.displayTitle, title: Text(recording.displayTitle, maxLines: 1, overflow: TextOverflow.ellipsis),
maxLines: 1, subtitle: Text(
overflow: TextOverflow.ellipsis, [
), if (recording.channelCallSign != null) recording.channelCallSign!,
subtitle: Text( timeStr,
[ if (recording.durationMinutes > 0) formatDurationTextual(recording.durationMinutes * 60000),
if (recording.channelCallSign != null) recording.channelCallSign!, ].join(' · '),
timeStr, style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
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 List<SubscriptionSetting> settings;
final Map<String, String> initialPrefs; final Map<String, String> initialPrefs;
const _SubscriptionEditDialog({ const _SubscriptionEditDialog({required this.subscription, required this.settings, required this.initialPrefs});
required this.subscription,
required this.settings,
required this.initialPrefs,
});
@override @override
State<_SubscriptionEditDialog> createState() => _SubscriptionEditDialogState(); State<_SubscriptionEditDialog> createState() => _SubscriptionEditDialogState();
@@ -364,14 +348,8 @@ class _SubscriptionEditDialogState extends State<_SubscriptionEditDialog> {
), ),
), ),
actions: [ actions: [
TextButton( TextButton(onPressed: () => Navigator.of(context).pop(null), child: Text(t.common.cancel)),
onPressed: () => Navigator.of(context).pop(null), FilledButton(onPressed: () => Navigator.of(context).pop(_prefs), child: Text(t.common.save)),
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 // Default: text field
final controller = _textControllers.putIfAbsent( final controller = _textControllers.putIfAbsent(setting.id, () => TextEditingController(text: value));
setting.id,
() => TextEditingController(text: value),
);
return ListTile( return ListTile(
title: Text(setting.label ?? setting.id), title: Text(setting.label ?? setting.id),
subtitle: TextField( subtitle: TextField(
+178 -57
View File
@@ -1,7 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../focus/dpad_navigator.dart';
import '../../i18n/strings.g.dart'; import '../../i18n/strings.g.dart';
import '../../models/livetv_channel.dart'; import '../../models/livetv_channel.dart';
import '../../mixins/tab_navigation_mixin.dart'; import '../../mixins/tab_navigation_mixin.dart';
@@ -21,10 +23,17 @@ class LiveTvScreen extends StatefulWidget {
State<LiveTvScreen> createState() => _LiveTvScreenState(); State<LiveTvScreen> createState() => _LiveTvScreenState();
} }
class _LiveTvScreenState extends State<LiveTvScreen> class _LiveTvScreenState extends State<LiveTvScreen> with SingleTickerProviderStateMixin, TabNavigationMixin {
with SingleTickerProviderStateMixin, TabNavigationMixin {
final _guideTabFocusNode = FocusNode(debugLabel: 'tab_chip_guide'); final _guideTabFocusNode = FocusNode(debugLabel: 'tab_chip_guide');
final _whatsOnTabFocusNode = FocusNode(debugLabel: 'tab_chip_whats_on'); 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 = []; List<LiveTvChannel> _channels = [];
bool _isLoading = true; bool _isLoading = true;
@@ -38,6 +47,8 @@ class _LiveTvScreenState extends State<LiveTvScreen>
super.initState(); super.initState();
suppressAutoFocus = true; suppressAutoFocus = true;
initTabNavigation(); initTabNavigation();
_refreshButtonFocusNode.addListener(_onRefreshFocusChange);
_dvrButtonFocusNode.addListener(_onDvrFocusChange);
_loadChannels(); _loadChannels();
} }
@@ -45,10 +56,22 @@ class _LiveTvScreenState extends State<LiveTvScreen>
void dispose() { void dispose() {
_guideTabFocusNode.dispose(); _guideTabFocusNode.dispose();
_whatsOnTabFocusNode.dispose(); _whatsOnTabFocusNode.dispose();
_refreshButtonFocusNode.removeListener(_onRefreshFocusChange);
_refreshButtonFocusNode.dispose();
_dvrButtonFocusNode.removeListener(_onDvrFocusChange);
_dvrButtonFocusNode.dispose();
disposeTabNavigation(); disposeTabNavigation();
super.dispose(); super.dispose();
} }
void _onRefreshFocusChange() {
if (mounted) setState(() => _isRefreshFocused = _refreshButtonFocusNode.hasFocus);
}
void _onDvrFocusChange() {
if (mounted) setState(() => _isDvrFocused = _dvrButtonFocusNode.hasFocus);
}
@override @override
void onTabChanged() { void onTabChanged() {
if (!tabController.indexIsChanging) { if (!tabController.indexIsChanging) {
@@ -99,6 +122,12 @@ class _LiveTvScreenState extends State<LiveTvScreen>
_channels = allChannels; _channels = allChannels;
_isLoading = false; _isLoading = false;
}); });
if (allChannels.isNotEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _focusCurrentTab();
});
}
} catch (e) { } catch (e) {
appLogger.e('Failed to load Live TV channels', error: e); appLogger.e('Failed to load Live TV channels', error: e);
if (mounted) { if (mounted) {
@@ -111,17 +140,76 @@ class _LiveTvScreenState extends State<LiveTvScreen>
} }
void _openRecordings() { void _openRecordings() {
Navigator.of(context).push( Navigator.of(context).push(MaterialPageRoute(builder: (_) => const DvrRecordingsScreen()));
MaterialPageRoute(builder: (_) => const DvrRecordingsScreen()),
);
} }
void _focusCurrentTab() { void _focusCurrentTab() {
if (tabController.index == 0) {
_guideTabKey.currentState?.focusContent();
} else if (tabController.index == 1) {
_whatsOnTabKey.currentState?.focusFirstHub();
}
setState(() { setState(() {
suppressAutoFocus = false; 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) { Widget _buildTabChip(String label, int index) {
final isSelected = tabController.index == index; final isSelected = tabController.index == index;
@@ -157,12 +245,16 @@ class _LiveTvScreenState extends State<LiveTvScreen>
}); });
getTabChipFocusNode(newIndex).requestFocus(); getTabChipFocusNode(newIndex).requestFocus();
} }
: null, : () => _refreshButtonFocusNode.requestFocus(),
onNavigateDown: _focusCurrentTab, onNavigateDown: _focusCurrentTab,
onBack: onTabBarBack, onBack: onTabBarBack,
); );
} }
// ---------------------------------------------------------------------------
// Build
// ---------------------------------------------------------------------------
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
@@ -180,68 +272,97 @@ class _LiveTvScreenState extends State<LiveTvScreen>
) )
: Text(t.liveTv.title), : Text(t.liveTv.title),
actions: [ actions: [
IconButton( Focus(
icon: const AppIcon(Symbols.refresh_rounded), focusNode: _refreshButtonFocusNode,
tooltip: t.liveTv.reloadGuide, onKeyEvent: _handleRefreshKeyEvent,
onPressed: _loadChannels, 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( Focus(
icon: const AppIcon(Symbols.fiber_dvr_rounded), focusNode: _dvrButtonFocusNode,
tooltip: t.liveTv.recordings, onKeyEvent: _handleDvrKeyEvent,
onPressed: _openRecordings, 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 body: _isLoading
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: _error != null : _error != null
? Center( ? Center(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, 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: [ children: [
AppIcon(Symbols.error_rounded, GuideTab(
size: 48, color: theme.colorScheme.error), key: _guideTabKey,
const SizedBox(height: 16), channels: _channels,
Text(_error!, style: theme.textTheme.bodyLarge), onNavigateUp: focusTabBar,
const SizedBox(height: 16), onBack: onTabBarBack,
FilledButton.icon( ),
onPressed: _loadChannels, WhatsOnTab(
icon: const AppIcon(Symbols.refresh_rounded), key: _whatsOnTabKey,
label: Text(t.common.retry), 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:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../focus/focusable_wrapper.dart';
import '../../i18n/strings.g.dart';
import '../../models/livetv_channel.dart'; import '../../models/livetv_channel.dart';
import '../../models/livetv_program.dart'; import '../../models/livetv_program.dart';
import '../../providers/multi_server_provider.dart'; import '../../providers/multi_server_provider.dart';
@@ -9,6 +11,7 @@ import '../../theme/mono_tokens.dart';
import '../../utils/formatters.dart'; import '../../utils/formatters.dart';
import '../../utils/live_tv_player_navigation.dart'; import '../../utils/live_tv_player_navigation.dart';
import '../../utils/plex_image_helper.dart'; import '../../utils/plex_image_helper.dart';
import '../../widgets/app_icon.dart';
import '../../widgets/focused_scroll_scaffold.dart'; import '../../widgets/focused_scroll_scaffold.dart';
import 'program_details_sheet.dart'; import 'program_details_sheet.dart';
@@ -23,12 +26,7 @@ class LiveTvShowScheduleScreen extends StatefulWidget {
/// Full channel list for tuning. /// Full channel list for tuning.
final List<LiveTvChannel> channels; final List<LiveTvChannel> channels;
const LiveTvShowScheduleScreen({ const LiveTvShowScheduleScreen({super.key, required this.showTitle, required this.serverId, required this.channels});
super.key,
required this.showTitle,
required this.serverId,
required this.channels,
});
@override @override
State<LiveTvShowScheduleScreen> createState() => _LiveTvShowScheduleScreenState(); State<LiveTvShowScheduleScreen> createState() => _LiveTvShowScheduleScreenState();
@@ -86,9 +84,8 @@ class _LiveTvShowScheduleScreenState extends State<LiveTvShowScheduleScreen> {
Future<void> _tuneChannel(LiveTvChannel channel) async { Future<void> _tuneChannel(LiveTvChannel channel) async {
final multiServer = context.read<MultiServerProvider>(); final multiServer = context.read<MultiServerProvider>();
final serverInfo = multiServer.liveTvServers final serverInfo =
.where((s) => s.serverId == channel.serverId) multiServer.liveTvServers.where((s) => s.serverId == channel.serverId).firstOrNull ??
.firstOrNull ??
multiServer.liveTvServers.firstOrNull; multiServer.liveTvServers.firstOrNull;
if (serverInfo == null) return; if (serverInfo == null) return;
@@ -139,24 +136,27 @@ class _LiveTvShowScheduleScreenState extends State<LiveTvShowScheduleScreen> {
SliverFillRemaining(child: Center(child: Text(t.liveTv.noPrograms))) SliverFillRemaining(child: Center(child: Text(t.liveTv.noPrograms)))
else else
SliverList( SliverList(
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate((context, index) {
(context, index) { final program = _programs[index];
final program = _programs[index]; final channel = _findChannel(program.channelIdentifier);
final channel = _findChannel(program.channelIdentifier); final onTap = () {
return _ScheduleListTile( if (program.isCurrentlyAiring && channel != null) {
program: program, _tuneChannel(channel);
channel: channel, } else {
onTap: () { _showProgramDetails(program, channel);
if (program.isCurrentlyAiring && channel != null) { }
_tuneChannel(channel); };
} else { return FocusableWrapper(
_showProgramDetails(program, channel); autofocus: index == 0,
} autoScroll: true,
}, useComfortableZone: true,
); useBackgroundFocus: true,
}, disableScale: true,
childCount: _programs.length, 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 LiveTvChannel? channel;
final VoidCallback onTap; final VoidCallback onTap;
const _ScheduleListTile({ const _ScheduleListTile({required this.program, required this.channel, required this.onTap});
required this.program,
required this.channel,
required this.onTap,
});
String _formatTimeInfo() { String _formatTimeInfo() {
final now = DateTime.now(); final now = DateTime.now();
@@ -228,14 +224,13 @@ class _ScheduleListTile extends StatelessWidget {
].join(''); ].join('');
return InkWell( return InkWell(
canRequestFocus: false,
onTap: onTap, onTap: onTap,
child: Container( child: Container(
decoration: isLive decoration: isLive
? BoxDecoration( ? BoxDecoration(
color: theme.colorScheme.primary.withValues(alpha: 0.08), color: theme.colorScheme.primary.withValues(alpha: 0.08),
border: Border( border: Border(left: BorderSide(color: theme.colorScheme.primary, width: 3)),
left: BorderSide(color: theme.colorScheme.primary, width: 3),
),
) )
: null, : null,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
@@ -247,17 +242,14 @@ class _ScheduleListTile extends StatelessWidget {
Expanded( Expanded(
child: Text( child: Text(
titleText, titleText,
style: theme.textTheme.bodyLarge?.copyWith( style: theme.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500),
fontWeight: FontWeight.w500,
),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
), ),
if (isLive) ...[ if (isLive) ...[
const SizedBox(width: 8), const SizedBox(width: 8),
AppIcon(Symbols.play_circle_rounded, AppIcon(Symbols.play_circle_rounded, size: 20, color: theme.colorScheme.primary),
size: 20, color: theme.colorScheme.primary),
], ],
], ],
), ),
@@ -265,21 +257,14 @@ class _ScheduleListTile extends StatelessWidget {
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
subtitle, subtitle,
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(color: tokens(context).textMuted),
color: tokens(context).textMuted,
),
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
], ],
if (channel != null) ...[ if (channel != null) ...[
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(channel!.displayName, style: theme.textTheme.labelSmall?.copyWith(color: tokens(context).textMuted)),
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:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import '../../focus/focusable_wrapper.dart';
import '../../i18n/strings.g.dart'; import '../../i18n/strings.g.dart';
import '../../models/livetv_channel.dart'; import '../../models/livetv_channel.dart';
import '../../models/livetv_program.dart'; import '../../models/livetv_program.dart';
import '../../utils/formatters.dart'; import '../../utils/formatters.dart';
import '../../widgets/app_icon.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). /// Shows a bottom sheet with program details and actions (Record, Watch Channel, Play).
void showProgramDetailsSheet( void showProgramDetailsSheet(
@@ -15,12 +17,178 @@ void showProgramDetailsSheet(
required String? posterUrl, required String? posterUrl,
required VoidCallback? onTuneChannel, required VoidCallback? onTuneChannel,
}) { }) {
final theme = Theme.of(context);
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
builder: (sheetContext) { 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), padding: const EdgeInsets.all(20),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -29,11 +197,11 @@ void showProgramDetailsSheet(
Row( Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (posterUrl != null) ...[ if (widget.posterUrl != null) ...[
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(6),
child: Image.network( child: Image.network(
posterUrl, widget.posterUrl!,
width: 80, width: 80,
height: 120, height: 120,
fit: BoxFit.cover, fit: BoxFit.cover,
@@ -48,26 +216,14 @@ void showProgramDetailsSheet(
children: [ children: [
Row( Row(
children: [ children: [
Expanded( Expanded(child: Text(program.displayTitle, style: theme.textTheme.titleMedium)),
child: Text(
program.displayTitle,
style: theme.textTheme.titleMedium,
),
),
if (program.isCurrentlyAiring) if (program.isCurrentlyAiring)
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration( decoration: BoxDecoration(color: Colors.red, borderRadius: BorderRadius.circular(4)),
color: Colors.red,
borderRadius: BorderRadius.circular(4),
),
child: Text( child: Text(
t.liveTv.live, t.liveTv.live,
style: const TextStyle( style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 11),
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')}', '${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), if (program.durationMinutes > 0) formatDurationTextual(program.durationMinutes * 60000),
].join(' · '), ].join(' · '),
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
color: theme.colorScheme.onSurfaceVariant,
),
), ),
if (program.summary != null && program.summary!.isNotEmpty) ...[ if (program.summary != null && program.summary!.isNotEmpty) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
@@ -99,42 +253,10 @@ void showProgramDetailsSheet(
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Row( Row(children: buttons),
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),
),
],
],
),
], ],
), ),
); ),
}, );
); }
} }
+464 -203
View File
@@ -1,9 +1,12 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../../focus/dpad_navigator.dart';
import '../../../focus/key_event_utils.dart';
import '../../../i18n/strings.g.dart'; import '../../../i18n/strings.g.dart';
import '../../../models/livetv_channel.dart'; import '../../../models/livetv_channel.dart';
import '../../../models/livetv_program.dart'; import '../../../models/livetv_program.dart';
@@ -13,17 +16,22 @@ import '../../../utils/formatters.dart';
import '../../../utils/plex_image_helper.dart'; import '../../../utils/plex_image_helper.dart';
import '../../../utils/live_tv_player_navigation.dart'; import '../../../utils/live_tv_player_navigation.dart';
import '../../../widgets/app_icon.dart'; import '../../../widgets/app_icon.dart';
import '../program_details_sheet.dart';
class GuideTab extends StatefulWidget { class GuideTab extends StatefulWidget {
final List<LiveTvChannel> channels; 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 @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 _slotWidth = 180.0;
static const _channelColumnWidth = 140.0; static const _channelColumnWidth = 140.0;
static const _rowHeight = 64.0; static const _rowHeight = 64.0;
@@ -39,11 +47,44 @@ class _GuideTabState extends State<GuideTab> {
final ScrollController _headerHorizontalController = ScrollController(); final ScrollController _headerHorizontalController = ScrollController();
final ScrollController _gridHorizontalController = ScrollController(); final ScrollController _gridHorizontalController = ScrollController();
final ScrollController _channelVerticalController = ScrollController(); final ScrollController _channelVerticalController = ScrollController();
final ScrollController _gridVerticalController = ScrollController();
bool _syncingScroll = false; bool _syncingScroll = false;
Timer? _timeIndicatorTimer; Timer? _timeIndicatorTimer;
final _dayPickerKey = GlobalKey(); 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 @override
void initState() { void initState() {
super.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() { void _syncGridToHeader() {
if (_syncingScroll) return; if (_syncingScroll) return;
_syncingScroll = true; _syncingScroll = true;
@@ -76,17 +138,6 @@ class _GuideTabState extends State<GuideTab> {
_syncingScroll = false; _syncingScroll = false;
} }
@override
void dispose() {
_gridHorizontalController.removeListener(_syncGridToHeader);
_headerHorizontalController.removeListener(_syncHeaderToGrid);
_headerHorizontalController.dispose();
_gridHorizontalController.dispose();
_channelVerticalController.dispose();
_timeIndicatorTimer?.cancel();
super.dispose();
}
void _initTimeRange() { void _initTimeRange() {
final now = DateTime.now(); final now = DateTime.now();
_gridStart = DateTime(now.year, now.month, now.day, now.hour); _gridStart = DateTime(now.year, now.month, now.day, now.hour);
@@ -154,12 +205,20 @@ class _GuideTabState extends State<GuideTab> {
if (!mounted) return; if (!mounted) return;
final shouldFocus = _pendingFocus;
setState(() { setState(() {
_programs = allPrograms; _programs = allPrograms;
_isLoading = false; _isLoading = false;
}); });
_scrollToNow(); _scrollToNow();
if (shouldFocus) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) focusContent();
});
}
} catch (e) { } catch (e) {
appLogger.e('Failed to load guide programs', error: e); appLogger.e('Failed to load guide programs', error: e);
if (mounted) { 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
@@ -223,7 +489,12 @@ class _GuideTabState extends State<GuideTab> {
return const Center(child: CircularProgressIndicator()); 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) { Widget _buildGuideGrid(ThemeData theme) {
@@ -257,7 +528,7 @@ class _GuideTabState extends State<GuideTab> {
itemCount: widget.channels.length, itemCount: widget.channels.length,
itemExtent: _rowHeight, itemExtent: _rowHeight,
itemBuilder: (context, index) => itemBuilder: (context, index) =>
_buildChannelCell(widget.channels[index], theme), _buildChannelCell(widget.channels[index], theme, index: index),
), ),
), ),
Expanded( Expanded(
@@ -279,12 +550,13 @@ class _GuideTabState extends State<GuideTab> {
child: SizedBox( child: SizedBox(
width: _totalGridWidth(), width: _totalGridWidth(),
child: ListView.builder( child: ListView.builder(
controller: _gridVerticalController,
itemCount: widget.channels.length, itemCount: widget.channels.length,
itemExtent: _rowHeight, itemExtent: _rowHeight,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final channel = widget.channels[index]; final channel = widget.channels[index];
final programs = _getProgramsForChannel(channel); 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) { ).then((value) {
if (value == null) return; if (value == null) {
_guideFocusNode.requestFocus();
return;
}
if (value is String && value == 'now') { if (value is String && value == 'now') {
_jumpToNow(); _jumpToNow();
_guideFocusNode.requestFocus();
} else if (value is DateTime) { } else if (value is DateTime) {
_showTimeSlotPicker(value); _showTimeSlotPicker(value);
} }
@@ -421,7 +697,10 @@ class _GuideTabState extends State<GuideTab> {
}), }),
], ],
).then((value) { ).then((value) {
if (value == null) return; if (value == null) {
_guideFocusNode.requestFocus();
return;
}
if (value == -1) { if (value == -1) {
_showDayPicker(); _showDayPicker();
return; return;
@@ -431,9 +710,26 @@ class _GuideTabState extends State<GuideTab> {
_gridEnd = _gridStart.add(const Duration(hours: 6)); _gridEnd = _gridStart.add(const Duration(hours: 6));
}); });
_loadPrograms(); _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) { Widget _buildTimeNavigation(ThemeData theme) {
final format = MaterialLocalizations.of(context); final format = MaterialLocalizations.of(context);
final timeLabel = final timeLabel =
@@ -449,30 +745,41 @@ class _GuideTabState extends State<GuideTab> {
), ),
child: Row( child: Row(
children: [ children: [
IconButton( _timeNavFocusWrap(
icon: const AppIcon(Symbols.chevron_left_rounded), index: 0,
onPressed: () => _shiftTimeRange(-2), theme: theme,
iconSize: 20, child: IconButton(
visualDensity: VisualDensity.compact, icon: const AppIcon(Symbols.chevron_left_rounded),
onPressed: () => _shiftTimeRange(-2),
iconSize: 20,
visualDensity: VisualDensity.compact,
),
), ),
Expanded( Expanded(
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
GestureDetector( _timeNavFocusWrap(
key: _dayPickerKey, index: 1,
onTap: _showDayPicker, theme: theme,
child: Row( child: GestureDetector(
mainAxisSize: MainAxisSize.min, key: _dayPickerKey,
children: [ onTap: _showDayPicker,
Text( child: Padding(
dayLabel, padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
style: theme.textTheme.labelLarge, 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), const SizedBox(width: 8),
@@ -483,17 +790,25 @@ class _GuideTabState extends State<GuideTab> {
], ],
), ),
), ),
IconButton( _timeNavFocusWrap(
icon: const AppIcon(Symbols.chevron_right_rounded), index: 2,
onPressed: () => _shiftTimeRange(2), theme: theme,
iconSize: 20, child: IconButton(
visualDensity: VisualDensity.compact, icon: const AppIcon(Symbols.chevron_right_rounded),
onPressed: () => _shiftTimeRange(2),
iconSize: 20,
visualDensity: VisualDensity.compact,
),
), ),
], ],
), ),
); );
} }
// ---------------------------------------------------------------------------
// Time header & now indicator
// ---------------------------------------------------------------------------
Widget _buildTimeHeader(ThemeData theme) { Widget _buildTimeHeader(ThemeData theme) {
final slots = <Widget>[]; final slots = <Widget>[];
var current = _gridStart; 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 multiServer = context.read<MultiServerProvider>();
final client = multiServer.getClientForServer(channel.serverId ?? ''); 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( return _ChannelCell(
rowHeight: _rowHeight, rowHeight: _rowHeight,
channelColumnWidth: _channelColumnWidth, channelColumnWidth: _channelColumnWidth,
@@ -568,6 +889,7 @@ class _GuideTabState extends State<GuideTab> {
channel: channel, channel: channel,
theme: theme, theme: theme,
onTap: () => _tuneChannel(channel), onTap: () => _tuneChannel(channel),
isFocused: isFocused,
fallbackBuilder: () => _buildChannelNameFallback(channel, theme), fallbackBuilder: () => _buildChannelNameFallback(channel, theme),
); );
} }
@@ -595,8 +917,13 @@ class _GuideTabState extends State<GuideTab> {
); );
} }
// ---------------------------------------------------------------------------
// Program grid
// ---------------------------------------------------------------------------
Widget _buildProgramRow( Widget _buildProgramRow(
LiveTvChannel channel, List<LiveTvProgram> programs, ThemeData theme) { LiveTvChannel channel, List<LiveTvProgram> programs, ThemeData theme,
{required int channelIndex}) {
if (programs.isEmpty) { if (programs.isEmpty) {
return Container( return Container(
height: _rowHeight, height: _rowHeight,
@@ -620,6 +947,14 @@ class _GuideTabState extends State<GuideTab> {
final gridStartEpoch = _gridStart.millisecondsSinceEpoch ~/ 1000; final gridStartEpoch = _gridStart.millisecondsSinceEpoch ~/ 1000;
final gridEndEpoch = _gridEnd.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) { for (final program in programs) {
final progStart = final progStart =
(program.beginsAt ?? gridStartEpoch).clamp(gridStartEpoch, gridEndEpoch); (program.beginsAt ?? gridStartEpoch).clamp(gridStartEpoch, gridEndEpoch);
@@ -639,7 +974,11 @@ class _GuideTabState extends State<GuideTab> {
width: width.clamp(2.0, double.infinity), width: width.clamp(2.0, double.infinity),
top: 0, top: 0,
bottom: 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( 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 isCurrentlyAiring = program.isCurrentlyAiring;
final isPast = program.endsAt != null && final isPast = program.endsAt != null &&
program.endsAt! < DateTime.now().millisecondsSinceEpoch ~/ 1000; program.endsAt! < DateTime.now().millisecondsSinceEpoch ~/ 1000;
@@ -669,71 +1009,83 @@ class _GuideTabState extends State<GuideTab> {
return Opacity( return Opacity(
opacity: isPast ? 0.5 : 1.0, opacity: isPast ? 0.5 : 1.0,
child: Material( child: Material(
color: isCurrentlyAiring color: isFocused
? theme.colorScheme.primaryContainer ? theme.colorScheme.primary.withValues(alpha: 0.25)
: theme.colorScheme.surfaceContainerHigh, : isCurrentlyAiring
borderRadius: BorderRadius.circular(4), ? theme.colorScheme.primaryContainer
child: InkWell( : theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(4), shape: RoundedRectangleBorder(
onTap: () => _showProgramDetails(channel, program), borderRadius: BorderRadius.circular(4),
child: Container( side: isFocused
decoration: BoxDecoration( ? BorderSide(color: theme.colorScheme.primary, width: 2)
border: Border( : BorderSide.none,
left: BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)), ),
right: isLast ? BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)) : BorderSide.none, child: InkWell(
), canRequestFocus: false,
), borderRadius: BorderRadius.circular(4),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), onTap: () => _showProgramDetails(channel, program),
child: Column( child: Container(
crossAxisAlignment: CrossAxisAlignment.start, decoration: BoxDecoration(
mainAxisAlignment: MainAxisAlignment.center, border: Border(
children: [ left: BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)),
Text( right: isLast ? BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)) : BorderSide.none,
program.grandparentTitle ?? program.title,
style: theme.textTheme.bodySmall?.copyWith(
fontWeight:
isCurrentlyAiring ? FontWeight.w600 : FontWeight.normal,
color: isCurrentlyAiring
? theme.colorScheme.onPrimaryContainer
: theme.colorScheme.onSurface,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
), ),
if (program.grandparentTitle != null) ),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text( Text(
'${program.parentIndex != null && program.index != null ? 'S${program.parentIndex}E${program.index} · ' : ''}${program.title}', program.grandparentTitle ?? program.title,
style: theme.textTheme.labelSmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: isCurrentlyAiring fontWeight:
? theme.colorScheme.onPrimaryContainer isCurrentlyAiring ? FontWeight.w600 : FontWeight.normal,
.withValues(alpha: 0.7) color: isFocused
: theme.colorScheme.onSurfaceVariant, ? theme.colorScheme.primary
: isCurrentlyAiring
? theme.colorScheme.onPrimaryContainer
: theme.colorScheme.onSurface,
), ),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
if (program.startTime != null) if (program.grandparentTitle != null)
Text( Text(
'${program.startTime!.hour.toString().padLeft(2, '0')}:${program.startTime!.minute.toString().padLeft(2, '0')} · ${formatDurationTextual(program.durationMinutes * 60000)}', '${program.parentIndex != null && program.index != null ? 'S${program.parentIndex}E${program.index} · ' : ''}${program.title}',
style: theme.textTheme.labelSmall?.copyWith( style: theme.textTheme.labelSmall?.copyWith(
color: isCurrentlyAiring color: isFocused
? theme.colorScheme.onPrimaryContainer ? theme.colorScheme.primary.withValues(alpha: 0.7)
.withValues(alpha: 0.7) : isCurrentlyAiring
: theme.colorScheme.onSurfaceVariant, ? 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) { void _showProgramDetails(LiveTvChannel channel, LiveTvProgram program) {
final theme = Theme.of(context);
final multiServer = context.read<MultiServerProvider>(); final multiServer = context.read<MultiServerProvider>();
final client = multiServer.getClientForServer(channel.serverId ?? ''); final client = multiServer.getClientForServer(channel.serverId ?? '');
String? posterUrl; String? posterUrl;
@@ -748,109 +1100,12 @@ class _GuideTabState extends State<GuideTab> {
); );
} }
showModalBottomSheet( showProgramDetailsSheet(
context: context, context,
builder: (sheetContext) { program: program,
return Padding( channel: channel,
padding: const EdgeInsets.all(20), posterUrl: posterUrl,
child: Column( onTuneChannel: () => _tuneChannel(channel),
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),
),
],
),
],
),
);
},
); );
} }
} }
@@ -862,6 +1117,7 @@ class _ChannelCell extends StatefulWidget {
final LiveTvChannel channel; final LiveTvChannel channel;
final ThemeData theme; final ThemeData theme;
final VoidCallback onTap; final VoidCallback onTap;
final bool isFocused;
final Widget Function() fallbackBuilder; final Widget Function() fallbackBuilder;
const _ChannelCell({ const _ChannelCell({
@@ -871,6 +1127,7 @@ class _ChannelCell extends StatefulWidget {
required this.channel, required this.channel,
required this.theme, required this.theme,
required this.onTap, required this.onTap,
required this.isFocused,
required this.fallbackBuilder, required this.fallbackBuilder,
}); });
@@ -884,13 +1141,17 @@ class _ChannelCellState extends State<_ChannelCell> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = widget.theme; final theme = widget.theme;
final showAction = _hovered || widget.isFocused;
return MouseRegion( return MouseRegion(
onEnter: (_) => setState(() => _hovered = true), onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false), onExit: (_) => setState(() => _hovered = false),
child: Material( child: Material(
color: Colors.transparent, color: widget.isFocused
? theme.colorScheme.primary.withValues(alpha: 0.15)
: Colors.transparent,
child: InkWell( child: InkWell(
canRequestFocus: false,
onTap: widget.onTap, onTap: widget.onTap,
child: Container( child: Container(
height: widget.rowHeight, height: widget.rowHeight,
@@ -907,7 +1168,7 @@ class _ChannelCellState extends State<_ChannelCell> {
alignment: Alignment.center, alignment: Alignment.center,
children: [ children: [
AnimatedOpacity( AnimatedOpacity(
opacity: _hovered ? 0.3 : 1.0, opacity: showAction ? 0.3 : 1.0,
duration: const Duration(milliseconds: 150), duration: const Duration(milliseconds: 150),
child: widget.imageUrl != null && widget.imageUrl!.isNotEmpty child: widget.imageUrl != null && widget.imageUrl!.isNotEmpty
? Image.network( ? Image.network(
@@ -920,7 +1181,7 @@ class _ChannelCellState extends State<_ChannelCell> {
) )
: widget.fallbackBuilder(), : widget.fallbackBuilder(),
), ),
if (_hovered) if (showAction)
AppIcon( AppIcon(
Symbols.play_arrow_rounded, Symbols.play_arrow_rounded,
size: 32, size: 32,
+327 -65
View File
@@ -1,9 +1,14 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.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_channel.dart';
import '../../../models/livetv_hub_result.dart'; import '../../../models/livetv_hub_result.dart';
import '../../../providers/multi_server_provider.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/plex_image_helper.dart';
import '../../../utils/provider_extensions.dart'; import '../../../utils/provider_extensions.dart';
import '../../../widgets/app_icon.dart'; import '../../../widgets/app_icon.dart';
import '../../../widgets/focus_builders.dart';
import '../../../widgets/horizontal_scroll_with_arrows.dart'; import '../../../widgets/horizontal_scroll_with_arrows.dart';
import '../../../widgets/plex_optimized_image.dart'; import '../../../widgets/plex_optimized_image.dart';
import '../live_tv_show_schedule_screen.dart'; import '../live_tv_show_schedule_screen.dart';
@@ -23,17 +29,20 @@ import '../program_details_sheet.dart';
class WhatsOnTab extends StatefulWidget { class WhatsOnTab extends StatefulWidget {
final List<LiveTvChannel> channels; 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 @override
State<WhatsOnTab> createState() => _WhatsOnTabState(); State<WhatsOnTab> createState() => WhatsOnTabState();
} }
class _WhatsOnTabState extends State<WhatsOnTab> { class WhatsOnTabState extends State<WhatsOnTab> {
List<LiveTvHubResult> _hubs = []; List<LiveTvHubResult> _hubs = [];
bool _isLoading = true; bool _isLoading = true;
Timer? _refreshTimer; Timer? _refreshTimer;
List<GlobalKey<_LiveTvHubSectionState>> _hubKeys = [];
@override @override
void initState() { void initState() {
@@ -70,6 +79,7 @@ class _WhatsOnTabState extends State<WhatsOnTab> {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_hubs = allHubs; _hubs = allHubs;
_hubKeys = List.generate(allHubs.length, (_) => GlobalKey<_LiveTvHubSectionState>());
_isLoading = false; _isLoading = false;
}); });
} catch (e) { } 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. /// Find a channel by its identifier from the channel list.
LiveTvChannel? _findChannel(String? channelIdentifier) { LiveTvChannel? _findChannel(String? channelIdentifier) {
if (channelIdentifier == null) return null; if (channelIdentifier == null) return null;
@@ -88,9 +128,8 @@ class _WhatsOnTabState extends State<WhatsOnTab> {
Future<void> _tuneChannel(LiveTvChannel channel) async { Future<void> _tuneChannel(LiveTvChannel channel) async {
final multiServer = context.read<MultiServerProvider>(); final multiServer = context.read<MultiServerProvider>();
final serverInfo = multiServer.liveTvServers final serverInfo =
.where((s) => s.serverId == channel.serverId) multiServer.liveTvServers.where((s) => s.serverId == channel.serverId).firstOrNull ??
.firstOrNull ??
multiServer.liveTvServers.firstOrNull; multiServer.liveTvServers.firstOrNull;
if (serverInfo == null) return; if (serverInfo == null) return;
@@ -173,9 +212,12 @@ class _WhatsOnTabState extends State<WhatsOnTab> {
itemCount: _hubs.length, itemCount: _hubs.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
return _LiveTvHubSection( return _LiveTvHubSection(
key: _hubKeys[index],
hub: _hubs[index], hub: _hubs[index],
onTap: _onItemTap, onTap: _onItemTap,
onLongPress: (entry) => _showProgramDetails(entry, _findChannel(entry.program.channelIdentifier)), 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) // 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 LiveTvHubResult hub;
final void Function(LiveTvHubEntry) onTap; final void Function(LiveTvHubEntry) onTap;
final void Function(LiveTvHubEntry) onLongPress; final void Function(LiveTvHubEntry) onLongPress;
final bool Function(bool isUp)? onVerticalNavigation;
final VoidCallback? onBack;
const _LiveTvHubSection({ const _LiveTvHubSection({
super.key,
required this.hub, required this.hub,
required this.onTap, required this.onTap,
required this.onLongPress, 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final hasFocus = _hubFocusNode.hasFocus;
final settings = context.watch<SettingsProvider>(); final settings = context.watch<SettingsProvider>();
final densityScale = switch (settings.libraryDensity) { final densityScale = switch (settings.libraryDensity) {
LibraryDensity.compact => 0.8, LibraryDensity.compact => 0.8,
@@ -220,7 +469,7 @@ class _LiveTvHubSection extends StatelessWidget {
const SizedBox(width: 8), const SizedBox(width: 8),
Flexible( Flexible(
child: Text( child: Text(
hub.title, widget.hub.title,
style: Theme.of(context).textTheme.titleLarge, style: Theme.of(context).textTheme.titleLarge,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
maxLines: 1, maxLines: 1,
@@ -230,50 +479,67 @@ class _LiveTvHubSection extends StatelessWidget {
), ),
), ),
// Horizontal cards — always poster (2:3) aspect // Horizontal cards with locked focus control
LayoutBuilder( if (widget.hub.entries.isNotEmpty)
builder: (context, constraints) { Focus(
final screenWidth = constraints.maxWidth; focusNode: _hubFocusNode,
final baseCardWidth = (ScreenBreakpoints.isLargeDesktop(screenWidth) onKeyEvent: _handleKeyEvent,
? 220.0 child: LayoutBuilder(
: ScreenBreakpoints.isDesktop(screenWidth) builder: (context, constraints) {
final screenWidth = constraints.maxWidth;
final baseCardWidth =
(ScreenBreakpoints.isLargeDesktop(screenWidth)
? 220.0
: ScreenBreakpoints.isDesktop(screenWidth)
? 200.0 ? 200.0
: ScreenBreakpoints.isWideTablet(screenWidth) : ScreenBreakpoints.isWideTablet(screenWidth)
? 190.0 ? 190.0
: 160.0) * : 160.0) *
densityScale; densityScale;
final cardWidth = baseCardWidth; final cardWidth = baseCardWidth;
final posterWidth = cardWidth - 16; final posterWidth = cardWidth - 16;
final posterHeight = posterWidth * 1.5; // 2:3 aspect final posterHeight = posterWidth * 1.5; // 2:3 aspect
final containerHeight = posterHeight + 66; final containerHeight = posterHeight + 66;
_itemExtent = cardWidth + 4;
return SizedBox( return SizedBox(
height: containerHeight, height: containerHeight,
child: HorizontalScrollWithArrows( child: HorizontalScrollWithArrows(
builder: (scrollController) => ListView.builder( controller: _scrollController,
controller: scrollController, builder: (scrollController) => ListView.builder(
scrollDirection: Axis.horizontal, controller: scrollController,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), scrollDirection: Axis.horizontal,
itemCount: hub.entries.length, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
itemBuilder: (context, index) { itemCount: widget.hub.entries.length,
final entry = hub.entries[index]; itemBuilder: (context, index) {
return Padding( final entry = widget.hub.entries[index];
padding: const EdgeInsets.symmetric(horizontal: 2), final isItemFocused = hasFocus && index == _focusedIndex;
child: _LiveTvPosterCard(
entry: entry, return Padding(
width: cardWidth, padding: const EdgeInsets.symmetric(horizontal: 2),
posterHeight: posterHeight, child: _LiveTvPosterCard(
onTap: () => onTap(entry), entry: entry,
onLongPress: () => onLongPress(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 LiveTvHubEntry entry;
final double width; final double width;
final double posterHeight; final double posterHeight;
final bool isFocused;
final VoidCallback onTap; final VoidCallback onTap;
final VoidCallback onLongPress; final VoidCallback onLongPress;
@@ -294,6 +561,7 @@ class _LiveTvPosterCard extends StatelessWidget {
required this.entry, required this.entry,
required this.width, required this.width,
required this.posterHeight, required this.posterHeight,
required this.isFocused,
required this.onTap, required this.onTap,
required this.onLongPress, required this.onLongPress,
}); });
@@ -304,13 +572,13 @@ class _LiveTvPosterCard extends StatelessWidget {
// Always use poster image: show poster for episodes, thumb for others // Always use poster image: show poster for episodes, thumb for others
final posterImage = metadata.grandparentThumb ?? metadata.thumb; final posterImage = metadata.grandparentThumb ?? metadata.thumb;
return SizedBox( return FocusBuilders.buildLockedFocusWrapper(
width: width, context: context,
child: InkWell( isFocused: isFocused,
canRequestFocus: false, onTap: onTap,
onTap: onTap, onLongPress: onLongPress,
onLongPress: onLongPress, child: SizedBox(
borderRadius: BorderRadius.circular(tokens(context).radiusSm), width: width,
child: Padding( child: Padding(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
child: Column( child: Column(
@@ -337,11 +605,7 @@ class _LiveTvPosterCard extends StatelessWidget {
metadata.displayTitle, metadata.displayTitle,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle( style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13, height: 1.1),
fontWeight: FontWeight.w600,
fontSize: 13,
height: 1.1,
),
), ),
// Subtitle // Subtitle
if (metadata.displaySubtitle != null) if (metadata.displaySubtitle != null)
@@ -349,11 +613,9 @@ class _LiveTvPosterCard extends StatelessWidget {
metadata.displaySubtitle!, metadata.displaySubtitle!,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith( style: Theme.of(
color: tokens(context).textMuted, context,
fontSize: 11, ).textTheme.bodySmall?.copyWith(color: tokens(context).textMuted, fontSize: 11, height: 1.1),
height: 1.1,
),
), ),
], ],
), ),