fix(tv): focus
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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)),
|
||||
],
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user