feat: keyboard navigation

This commit is contained in:
edde746
2025-11-26 13:51:25 +01:00
parent 0d0388f7a8
commit 01bb1126ef
28 changed files with 5233 additions and 2496 deletions
+109
View File
@@ -0,0 +1,109 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
/// A mixin that provides keyboard long-press detection for focusable widgets.
///
/// This allows keyboard/gamepad users to access context menus by holding
/// the activation key (Enter, Space, Select, or GameButtonA) for 1 second.
///
/// Usage:
/// ```dart
/// class _MyWidgetState extends State<MyWidget> with KeyboardLongPressMixin {
/// @override
/// void onKeyboardTap() {
/// // Handle short press (normal tap)
/// }
///
/// @override
/// void onKeyboardLongPress() {
/// // Handle long press (show context menu)
/// }
///
/// KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
/// final result = handleKeyboardLongPress(event);
/// if (result == KeyEventResult.handled) return result;
/// // Handle other keys...
/// return KeyEventResult.ignored;
/// }
/// }
/// ```
mixin KeyboardLongPressMixin<T extends StatefulWidget> on State<T> {
Timer? _longPressTimer;
LogicalKeyboardKey? _pressedKey;
bool _longPressTriggered = false;
static const _longPressDuration = Duration(seconds: 1);
/// Override to handle tap action (short press)
void onKeyboardTap();
/// Override to handle long press action (e.g., show context menu)
void onKeyboardLongPress();
/// Check if the given key is an activation key
bool _isActivationKey(LogicalKeyboardKey key) {
return key == LogicalKeyboardKey.enter ||
key == LogicalKeyboardKey.space ||
key == LogicalKeyboardKey.select ||
key == LogicalKeyboardKey.gameButtonA;
}
/// Call this from your onKeyEvent handler to enable long-press detection.
/// Returns [KeyEventResult.handled] if the event was an activation key,
/// [KeyEventResult.ignored] otherwise.
KeyEventResult handleKeyboardLongPress(KeyEvent event) {
if (!_isActivationKey(event.logicalKey)) {
return KeyEventResult.ignored;
}
if (event is KeyDownEvent) {
// Only start timer on initial press (not key repeat)
if (_pressedKey == null) {
_pressedKey = event.logicalKey;
_longPressTriggered = false;
_longPressTimer = Timer(_longPressDuration, () {
_longPressTriggered = true;
onKeyboardLongPress();
});
}
return KeyEventResult.handled;
}
// Handle key repeat events (suppress system sound on macOS)
if (event is KeyRepeatEvent) {
return KeyEventResult.handled;
}
if (event is KeyUpEvent && event.logicalKey == _pressedKey) {
_longPressTimer?.cancel();
_longPressTimer = null;
_pressedKey = null;
// Only trigger tap if long press wasn't already triggered
if (!_longPressTriggered) {
onKeyboardTap();
}
_longPressTriggered = false;
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
/// Cancel any pending long-press timer. Call this if focus is lost
/// or the widget is being disposed while a key is held.
void cancelKeyboardLongPress() {
_longPressTimer?.cancel();
_longPressTimer = null;
_pressedKey = null;
_longPressTriggered = false;
}
@override
void dispose() {
_longPressTimer?.cancel();
super.dispose();
}
}
+395 -269
View File
@@ -1,5 +1,6 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:provider/provider.dart';
import '../client/plex_client.dart';
@@ -14,6 +15,8 @@ import '../widgets/desktop_app_bar.dart';
import '../widgets/user_avatar_widget.dart';
import '../widgets/horizontal_scroll_with_arrows.dart';
import '../widgets/hub_section.dart';
import '../widgets/hub_navigation_controller.dart';
import '../widgets/focus/focus_indicator.dart';
import 'profile_switch_screen.dart';
import '../providers/user_profile_provider.dart';
import '../providers/settings_provider.dart';
@@ -21,10 +24,12 @@ import '../mixins/refreshable.dart';
import '../i18n/strings.g.dart';
import '../mixins/item_updatable.dart';
import '../utils/app_logger.dart';
import '../utils/keyboard_utils.dart';
import '../utils/provider_extensions.dart';
import '../utils/video_player_navigation.dart';
import '../utils/content_rating_formatter.dart';
import 'auth_screen.dart';
import 'main_screen.dart';
class DiscoverScreen extends StatefulWidget {
final VoidCallback? onBecameVisible;
@@ -56,6 +61,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
List<PlexMetadata> _onDeck = [];
List<PlexHub> _hubs = [];
bool _isLoading = true;
bool _isInitialLoad = true;
String? _errorMessage;
final PageController _heroController = PageController();
final ScrollController _scrollController = ScrollController();
@@ -63,6 +69,10 @@ class _DiscoverScreenState extends State<DiscoverScreen>
Timer? _autoScrollTimer;
late AnimationController _indicatorAnimationController;
bool _isAutoScrollPaused = false;
final HubNavigationController _hubNavigationController =
HubNavigationController();
late final FocusNode _heroFocusNode;
bool _heroIsFocused = false;
/// Get the correct PlexClient for an item's server
PlexClient _getClientForItem(PlexMetadata? item) {
@@ -90,16 +100,92 @@ class _DiscoverScreenState extends State<DiscoverScreen>
vsync: this,
duration: _heroAutoScrollDuration,
);
_heroFocusNode = FocusNode(debugLabel: 'HeroSection');
_heroFocusNode.addListener(_handleHeroFocusChange);
_loadContent();
_startAutoScroll();
}
void _handleHeroFocusChange() {
if (_heroIsFocused != _heroFocusNode.hasFocus) {
setState(() {
_heroIsFocused = _heroFocusNode.hasFocus;
});
if (_heroFocusNode.hasFocus) {
// Scroll to the very top when hero is focused
_scrollController.animateTo(
0,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
);
}
}
}
/// Handle back key press - focus bottom navigation
KeyEventResult _handleBackKey(FocusNode node, KeyEvent event) {
if (isBackKeyEvent(event)) {
BackNavigationScope.of(context)?.focusBottomNav();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
KeyEventResult _handleHeroKeyEvent(FocusNode node, KeyEvent event) {
if (event is KeyDownEvent) {
// Enter/Space to play current hero item
if (event.logicalKey == LogicalKeyboardKey.enter ||
event.logicalKey == LogicalKeyboardKey.space ||
event.logicalKey == LogicalKeyboardKey.select ||
event.logicalKey == LogicalKeyboardKey.gameButtonA) {
if (_onDeck.isNotEmpty && _currentHeroIndex < _onDeck.length) {
navigateToVideoPlayer(context, metadata: _onDeck[_currentHeroIndex]);
return KeyEventResult.handled;
}
}
// Left arrow to go to previous hero item
if (event.logicalKey == LogicalKeyboardKey.arrowLeft) {
if (_onDeck.isNotEmpty && _currentHeroIndex > 0) {
_heroController.previousPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
return KeyEventResult.handled;
}
}
// Right arrow to go to next hero item
if (event.logicalKey == LogicalKeyboardKey.arrowRight) {
if (_onDeck.isNotEmpty && _currentHeroIndex < _onDeck.length - 1) {
_heroController.nextPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
return KeyEventResult.handled;
}
}
// Down arrow to navigate to first hub section
if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
// Try to navigate to the first hub section
if (_hubNavigationController.navigateToAdjacentHub('_hero_', 1)) {
return KeyEventResult.handled;
}
}
}
return KeyEventResult.ignored;
}
@override
void dispose() {
_autoScrollTimer?.cancel();
_heroController.dispose();
_scrollController.dispose();
_indicatorAnimationController.dispose();
_hubNavigationController.dispose();
_heroFocusNode.removeListener(_handleHeroFocusChange);
_heroFocusNode.dispose();
super.dispose();
}
@@ -246,6 +332,16 @@ class _DiscoverScreenState extends State<DiscoverScreen>
_heroController.jumpToPage(0);
}
// Focus the hero on initial load
if (_isInitialLoad && onDeck.isNotEmpty) {
_isInitialLoad = false;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_heroFocusNode.requestFocus();
}
});
}
appLogger.d('Discover content loaded successfully');
} catch (e) {
appLogger.e('Failed to load discover content', error: e);
@@ -305,6 +401,13 @@ class _DiscoverScreenState extends State<DiscoverScreen>
_loadContent();
}
/// Focus the hero section (for keyboard navigation)
void focusHero() {
if (_onDeck.isNotEmpty) {
_heroFocusNode.requestFocus();
}
}
/// Get icon for hub based on its title
IconData _getHubIcon(String title) {
final lowerTitle = title.toLowerCase();
@@ -485,291 +588,314 @@ class _DiscoverScreenState extends State<DiscoverScreen>
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: CustomScrollView(
controller: _scrollController,
slivers: [
DesktopSliverAppBar(
title: Text(t.discover.title),
floating: true,
pinned: true,
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
surfaceTintColor: Colors.transparent,
shadowColor: Colors.transparent,
scrolledUnderElevation: 0,
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _loadContent,
),
Consumer<UserProfileProvider>(
builder: (context, userProvider, child) {
return PopupMenuButton<String>(
icon: userProvider.currentUser?.thumb != null
? UserAvatarWidget(
user: userProvider.currentUser!,
size: 32,
showIndicators: false,
)
: const Icon(Icons.account_circle, size: 32),
onSelected: (value) {
if (value == 'switch_profile') {
_handleSwitchProfile(context);
} else if (value == 'logout') {
_handleLogout();
}
},
itemBuilder: (context) => [
// Only show Switch Profile if multiple users available
if (userProvider.hasMultipleUsers)
PopupMenuItem(
value: 'switch_profile',
child: Row(
children: [
Icon(Icons.people),
SizedBox(width: 8),
Text(t.discover.switchProfile),
],
child: Focus(
onKeyEvent: _handleBackKey,
child: HubNavigationScope(
controller: _hubNavigationController,
child: CustomScrollView(
controller: _scrollController,
slivers: [
DesktopSliverAppBar(
title: Text(t.discover.title),
floating: true,
pinned: true,
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
surfaceTintColor: Colors.transparent,
shadowColor: Colors.transparent,
scrolledUnderElevation: 0,
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _loadContent,
),
Consumer<UserProfileProvider>(
builder: (context, userProvider, child) {
return PopupMenuButton<String>(
icon: userProvider.currentUser?.thumb != null
? UserAvatarWidget(
user: userProvider.currentUser!,
size: 32,
showIndicators: false,
)
: const Icon(Icons.account_circle, size: 32),
onSelected: (value) {
if (value == 'switch_profile') {
_handleSwitchProfile(context);
} else if (value == 'logout') {
_handleLogout();
}
},
itemBuilder: (context) => [
// Only show Switch Profile if multiple users available
if (userProvider.hasMultipleUsers)
PopupMenuItem(
value: 'switch_profile',
child: Row(
children: [
Icon(Icons.people),
SizedBox(width: 8),
Text(t.discover.switchProfile),
],
),
),
PopupMenuItem(
value: 'logout',
child: Row(
children: [
Icon(Icons.logout),
SizedBox(width: 8),
Text(t.discover.logout),
],
),
),
),
PopupMenuItem(
value: 'logout',
child: Row(
children: [
Icon(Icons.logout),
SizedBox(width: 8),
Text(t.discover.logout),
],
),
),
],
);
},
],
);
},
),
],
),
if (_isLoading)
const SliverFillRemaining(
child: Center(child: CircularProgressIndicator()),
),
if (_errorMessage != null)
SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.error_outline,
size: 48,
color: Colors.red,
),
const SizedBox(height: 16),
Text(_errorMessage!),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loadContent,
child: Text(t.common.retry),
),
],
),
),
),
if (!_isLoading && _errorMessage == null) ...[
// Hero Section (Continue Watching)
Consumer<SettingsProvider>(
builder: (context, settingsProvider, child) {
if (_onDeck.isNotEmpty &&
settingsProvider.showHeroSection) {
return _buildHeroSection();
}
return const SliverToBoxAdapter(child: SizedBox.shrink());
},
),
// On Deck / Continue Watching
if (_onDeck.isNotEmpty)
SliverToBoxAdapter(
child: HubSection(
hub: PlexHub(
hubKey: 'continue_watching',
title: t.discover.continueWatching,
type: 'mixed',
hubIdentifier: '_continue_watching_',
size: _onDeck.length,
more: false,
items: _onDeck,
),
icon: Icons.play_circle_outline,
onRefresh: updateItem,
onRemoveFromContinueWatching: _refreshContinueWatching,
isInContinueWatching: true,
navigationOrder: 1, // After hero
),
),
// Recommendation Hubs (Trending, Top in Genre, etc.)
for (int i = 0; i < _hubs.length; i++)
SliverToBoxAdapter(
child: HubSection(
hub: _hubs[i],
icon: _getHubIcon(_hubs[i].title),
onRefresh: updateItem,
navigationOrder: 2 + i, // After continue watching
),
),
if (_onDeck.isEmpty && _hubs.isEmpty)
SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.movie_outlined,
size: 64,
color: Colors.grey,
),
SizedBox(height: 16),
Text(t.discover.noContentAvailable),
SizedBox(height: 8),
Text(
t.discover.addMediaToLibraries,
style: TextStyle(color: Colors.grey),
),
],
),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 24)),
],
],
),
if (_isLoading)
const SliverFillRemaining(
child: Center(child: CircularProgressIndicator()),
),
if (_errorMessage != null)
SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.error_outline,
size: 48,
color: Colors.red,
),
const SizedBox(height: 16),
Text(_errorMessage!),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loadContent,
child: Text(t.common.retry),
),
],
),
),
),
if (!_isLoading && _errorMessage == null) ...[
// Hero Section (Continue Watching)
Consumer<SettingsProvider>(
builder: (context, settingsProvider, child) {
if (_onDeck.isNotEmpty && settingsProvider.showHeroSection) {
return _buildHeroSection();
}
return const SliverToBoxAdapter(child: SizedBox.shrink());
},
),
// On Deck / Continue Watching
if (_onDeck.isNotEmpty) ...[
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 8),
child: Row(
children: [
const Icon(Icons.play_circle_outline),
const SizedBox(width: 8),
Text(
t.discover.continueWatching,
style: Theme.of(context).textTheme.titleLarge,
),
],
),
),
),
_buildHorizontalList(
_onDeck,
isLarge: false,
isInContinueWatching: true,
),
],
// Recommendation Hubs (Trending, Top in Genre, etc.)
for (final hub in _hubs)
SliverToBoxAdapter(
child: HubSection(
hub: hub,
icon: _getHubIcon(hub.title),
onRefresh: updateItem,
),
),
if (_onDeck.isEmpty && _hubs.isEmpty)
SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.movie_outlined,
size: 64,
color: Colors.grey,
),
SizedBox(height: 16),
Text(t.discover.noContentAvailable),
SizedBox(height: 8),
Text(
t.discover.addMediaToLibraries,
style: TextStyle(color: Colors.grey),
),
],
),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 24)),
],
],
),
),
),
);
}
Widget _buildHeroSection() {
return SliverToBoxAdapter(
child: SizedBox(
height: 500,
child: Stack(
children: [
PageView.builder(
controller: _heroController,
itemCount: _onDeck.length,
onPageChanged: (index) {
// Validate index is within bounds before updating
if (index >= 0 && index < _onDeck.length) {
setState(() {
_currentHeroIndex = index;
});
_resetAutoScrollTimer();
}
},
itemBuilder: (context, index) {
return _buildHeroItem(_onDeck[index]);
},
),
// Page indicators with animated progress and pause/play button
Positioned(
bottom: 16,
left: -26,
right: 0,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Pause/Play button
GestureDetector(
onTap: () {
if (_isAutoScrollPaused) {
_resumeAutoScroll();
} else {
_pauseAutoScroll();
}
},
child: Icon(
_isAutoScrollPaused ? Icons.play_arrow : Icons.pause,
color: Colors.white,
size: 18,
semanticLabel:
'${_isAutoScrollPaused ? t.discover.play : t.discover.pause} auto-scroll',
),
),
// Spacer to separate indicators from button
const SizedBox(width: 8),
// Page indicators (limited to 5 dots)
...() {
final range = _getVisibleDotRange();
return List.generate(range.end - range.start + 1, (i) {
final index = range.start + i;
final isActive = _currentHeroIndex == index;
final dotSize = _getDotSize(
index,
range.start,
range.end,
);
// Register hero section with navigation controller
// This allows pressing up from first hub to return to hero
_hubNavigationController.register(
HubSectionRegistration(
hubId: '_hero_',
itemCount: 1,
focusItem: (_) => _heroFocusNode.requestFocus(),
order: 0, // Hero is first
),
);
if (isActive) {
// Animated progress indicator for active page
return AnimatedBuilder(
animation: _indicatorAnimationController,
builder: (context, child) {
// Fill width animates based on dot size
final maxWidth =
dotSize * 3; // 24px for normal, 15px for small
final fillWidth =
dotSize +
((maxWidth - dotSize) *
_indicatorAnimationController.value);
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
margin: const EdgeInsets.symmetric(horizontal: 4),
width: maxWidth,
height: dotSize,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(
dotSize / 2,
return SliverToBoxAdapter(
child: Focus(
focusNode: _heroFocusNode,
onKeyEvent: _handleHeroKeyEvent,
child: SizedBox(
height: 500,
child: Stack(
children: [
PageView.builder(
controller: _heroController,
itemCount: _onDeck.length,
onPageChanged: (index) {
// Validate index is within bounds before updating
if (index >= 0 && index < _onDeck.length) {
setState(() {
_currentHeroIndex = index;
});
_resetAutoScrollTimer();
}
},
itemBuilder: (context, index) {
return _buildHeroItem(_onDeck[index]);
},
),
// Page indicators with animated progress and pause/play button
Positioned(
bottom: 16,
left: -26,
right: 0,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Pause/Play button
GestureDetector(
onTap: () {
if (_isAutoScrollPaused) {
_resumeAutoScroll();
} else {
_pauseAutoScroll();
}
},
child: Icon(
_isAutoScrollPaused ? Icons.play_arrow : Icons.pause,
color: Colors.white,
size: 18,
semanticLabel:
'${_isAutoScrollPaused ? t.discover.play : t.discover.pause} auto-scroll',
),
),
// Spacer to separate indicators from button
const SizedBox(width: 8),
// Page indicators (limited to 5 dots)
...() {
final range = _getVisibleDotRange();
return List.generate(range.end - range.start + 1, (i) {
final index = range.start + i;
final isActive = _currentHeroIndex == index;
final dotSize = _getDotSize(
index,
range.start,
range.end,
);
if (isActive) {
// Animated progress indicator for active page
return AnimatedBuilder(
animation: _indicatorAnimationController,
builder: (context, child) {
// Fill width animates based on dot size
final maxWidth =
dotSize *
3; // 24px for normal, 15px for small
final fillWidth =
dotSize +
((maxWidth - dotSize) *
_indicatorAnimationController.value);
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
margin: const EdgeInsets.symmetric(
horizontal: 4,
),
),
child: Align(
alignment: Alignment.centerLeft,
child: Container(
width: fillWidth,
height: dotSize,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(
dotSize / 2,
width: maxWidth,
height: dotSize,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(
dotSize / 2,
),
),
child: Align(
alignment: Alignment.centerLeft,
child: Container(
width: fillWidth,
height: dotSize,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(
dotSize / 2,
),
),
),
),
),
);
},
);
} else {
// Static indicator for inactive pages
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
margin: const EdgeInsets.symmetric(horizontal: 4),
width: dotSize,
height: dotSize,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(dotSize / 2),
),
);
}
});
}(),
],
);
},
);
} else {
// Static indicator for inactive pages
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
margin: const EdgeInsets.symmetric(horizontal: 4),
width: dotSize,
height: dotSize,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(dotSize / 2),
),
);
}
});
}(),
],
),
),
),
],
],
),
),
),
);
File diff suppressed because it is too large Load Diff
@@ -139,6 +139,19 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>>
}
}
/// Focus the first item in the tab content
/// Subclasses can override this for custom focus behavior
void focusFirstItem() {
// Default implementation: try to focus the first focusable item
if (_items.isNotEmpty && mounted) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
FocusScope.of(context).nextFocus();
}
});
}
}
@override
Widget build(BuildContext context) {
super.build(context); // Required for AutomaticKeepAliveClientMixin
+149 -87
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:dio/dio.dart';
import '../../client/plex_client.dart';
@@ -9,6 +10,7 @@ import '../../models/plex_sort.dart';
import '../../providers/settings_provider.dart';
import '../../utils/error_message_utils.dart';
import '../../utils/grid_size_calculator.dart';
import '../../utils/keyboard_utils.dart';
import '../../widgets/media_card.dart';
import '../../widgets/folder_tree_view.dart';
import '../../widgets/filters_bottom_sheet.dart';
@@ -87,6 +89,11 @@ class _LibraryBrowseTabState extends State<LibraryBrowseTab>
int _requestId = 0;
static const int _pageSize = 500;
/// Focus node for the first item in the list/grid
final FocusNode _firstItemFocusNode = FocusNode(
debugLabel: 'BrowseFirstItem',
);
@override
void initState() {
super.initState();
@@ -105,9 +112,17 @@ class _LibraryBrowseTabState extends State<LibraryBrowseTab>
@override
void dispose() {
_cancelToken?.cancel();
_firstItemFocusNode.dispose();
super.dispose();
}
/// Focus the first item in the list/grid
void focusFirstItem() {
if (_items.isNotEmpty) {
_firstItemFocusNode.requestFocus();
}
}
Future<void> _loadContent() async {
// Cancel any pending request
_cancelToken?.cancel();
@@ -312,36 +327,50 @@ class _LibraryBrowseTabState extends State<LibraryBrowseTab>
void _showGroupingBottomSheet() {
showModalBottomSheet(
context: context,
builder: (context) {
return ListView(
shrinkWrap: true,
children: _getGroupingOptions().map((grouping) {
return RadioListTile<String>(
title: Text(_getGroupingLabel(grouping)),
value: grouping,
// ignore: deprecated_member_use
groupValue: _selectedGrouping,
// ignore: deprecated_member_use
onChanged: (value) async {
if (value != null) {
setState(() {
_selectedGrouping = value;
});
builder: (sheetContext) {
final options = _getGroupingOptions();
return Focus(
autofocus: true,
onKeyEvent: (node, event) {
if (isBackKeyEvent(event)) {
Navigator.pop(sheetContext);
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
child: FocusTraversalGroup(
child: ListView.builder(
shrinkWrap: true,
itemCount: options.length,
itemBuilder: (context, index) {
final grouping = options[index];
return RadioListTile<String>(
autofocus: index == 0,
title: Text(_getGroupingLabel(grouping)),
value: grouping,
groupValue: _selectedGrouping,
onChanged: (value) async {
if (value != null) {
setState(() {
_selectedGrouping = value;
});
final storage = await StorageService.getInstance();
await storage.saveLibraryGrouping(
widget.library.globalKey,
value,
);
final storage = await StorageService.getInstance();
await storage.saveLibraryGrouping(
widget.library.globalKey,
value,
);
if (!context.mounted) return;
if (!sheetContext.mounted) return;
Navigator.pop(context);
_loadItems();
}
Navigator.pop(sheetContext);
_loadItems();
}
},
);
},
);
}).toList(),
),
),
);
},
);
@@ -407,32 +436,55 @@ class _LibraryBrowseTabState extends State<LibraryBrowseTab>
required String label,
required VoidCallback onPressed,
}) {
return InkWell(
onTap: onPressed,
borderRadius: BorderRadius.circular(20),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 16,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(width: 6),
Text(
label,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
return Focus(
onKeyEvent: (node, event) {
if (event is KeyDownEvent) {
if (event.logicalKey == LogicalKeyboardKey.enter ||
event.logicalKey == LogicalKeyboardKey.space ||
event.logicalKey == LogicalKeyboardKey.select ||
event.logicalKey == LogicalKeyboardKey.gameButtonA) {
onPressed();
return KeyEventResult.handled;
}
}
return KeyEventResult.ignored;
},
child: Builder(
builder: (context) {
final isFocused = Focus.of(context).hasFocus;
final colorScheme = Theme.of(context).colorScheme;
// When focused: inverse colors (white bg + dark text in dark mode, dark bg + light text in light mode)
final backgroundColor = isFocused
? colorScheme.onSurface
: colorScheme.surfaceContainerHighest;
final foregroundColor = isFocused
? colorScheme.surface
: colorScheme.onSurfaceVariant;
return InkWell(
onTap: onPressed,
borderRadius: BorderRadius.circular(20),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 16, color: foregroundColor),
const SizedBox(width: 6),
Text(
label,
style: Theme.of(
context,
).textTheme.labelMedium?.copyWith(color: foregroundColor),
),
],
),
),
],
),
);
},
),
);
}
@@ -533,48 +585,58 @@ class _LibraryBrowseTabState extends State<LibraryBrowseTab>
child: Consumer<SettingsProvider>(
builder: (context, settingsProvider, child) {
if (settingsProvider.viewMode == ViewMode.list) {
return ListView.builder(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
itemCount: _items.length + (_hasMoreItems && _isLoading ? 1 : 0),
itemBuilder: (context, index) {
if (index >= _items.length) {
return const Padding(
padding: EdgeInsets.all(16.0),
child: Center(child: CircularProgressIndicator()),
return ClipRect(
child: ListView.builder(
clipBehavior: Clip.none, // Allow focus indicator to overflow
padding: const EdgeInsets.all(8),
itemCount:
_items.length + (_hasMoreItems && _isLoading ? 1 : 0),
itemBuilder: (context, index) {
if (index >= _items.length) {
return const Padding(
padding: EdgeInsets.all(16.0),
child: Center(child: CircularProgressIndicator()),
);
}
final item = _items[index];
return MediaCard(
key: Key(item.ratingKey),
item: item,
onRefresh: updateItem,
focusNode: index == 0 ? _firstItemFocusNode : null,
);
}
final item = _items[index];
return MediaCard(
key: Key(item.ratingKey),
item: item,
onRefresh: updateItem,
);
},
},
),
);
} else {
return GridView.builder(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent(
context,
settingsProvider.libraryDensity,
return ClipRect(
child: GridView.builder(
clipBehavior: Clip.none, // Allow focus indicator to overflow
padding: const EdgeInsets.all(8),
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent(
context,
settingsProvider.libraryDensity,
),
childAspectRatio: 2 / 3.3,
crossAxisSpacing: 0,
mainAxisSpacing: 0,
),
childAspectRatio: 2 / 3.3,
crossAxisSpacing: 0,
mainAxisSpacing: 0,
itemCount:
_items.length + (_hasMoreItems && _isLoading ? 1 : 0),
itemBuilder: (context, index) {
if (index >= _items.length) {
return const Center(child: CircularProgressIndicator());
}
final item = _items[index];
return MediaCard(
key: Key(item.ratingKey),
item: item,
onRefresh: updateItem,
focusNode: index == 0 ? _firstItemFocusNode : null,
);
},
),
itemCount: _items.length + (_hasMoreItems && _isLoading ? 1 : 0),
itemBuilder: (context, index) {
if (index >= _items.length) {
return const Center(child: CircularProgressIndicator());
}
final item = _items[index];
return MediaCard(
key: Key(item.ratingKey),
item: item,
onRefresh: updateItem,
);
},
);
}
},
@@ -21,6 +21,17 @@ class LibraryCollectionsTab extends BaseLibraryTab<PlexMetadata> {
class _LibraryCollectionsTabState
extends BaseLibraryTabState<PlexMetadata, LibraryCollectionsTab> {
/// Focus node for the first item in the grid
final FocusNode _firstItemFocusNode = FocusNode(
debugLabel: 'CollectionsFirstItem',
);
@override
void dispose() {
_firstItemFocusNode.dispose();
super.dispose();
}
@override
IconData get emptyIcon => Icons.collections;
@@ -43,8 +54,19 @@ class _LibraryCollectionsTabState
return await client.getLibraryCollections(widget.library.key);
}
@override
void focusFirstItem() {
if (items.isNotEmpty) {
_firstItemFocusNode.requestFocus();
}
}
@override
Widget buildContent(List<PlexMetadata> items) {
return AdaptiveMediaGrid(items: items, onRefresh: loadItems);
return AdaptiveMediaGrid(
items: items,
onRefresh: loadItems,
firstItemFocusNode: _firstItemFocusNode,
);
}
}
@@ -25,6 +25,17 @@ class LibraryPlaylistsTab extends BaseLibraryTab<PlexPlaylist> {
class _LibraryPlaylistsTabState
extends BaseLibraryTabState<PlexPlaylist, LibraryPlaylistsTab> {
/// Focus node for the first item in the grid
final FocusNode _firstItemFocusNode = FocusNode(
debugLabel: 'PlaylistsFirstItem',
);
@override
void dispose() {
_firstItemFocusNode.dispose();
super.dispose();
}
@override
IconData get emptyIcon => Icons.playlist_play;
@@ -49,6 +60,13 @@ class _LibraryPlaylistsTabState
);
}
@override
void focusFirstItem() {
if (items.isNotEmpty) {
_firstItemFocusNode.requestFocus();
}
}
@override
Widget buildContent(List<PlexPlaylist> items) {
return Consumer<SettingsProvider>(
@@ -63,6 +81,7 @@ class _LibraryPlaylistsTabState
key: Key(playlist.ratingKey),
item: playlist,
onListRefresh: loadItems,
focusNode: index == 0 ? _firstItemFocusNode : null,
);
},
);
@@ -85,6 +104,7 @@ class _LibraryPlaylistsTabState
key: Key(playlist.ratingKey),
item: playlist,
onListRefresh: loadItems,
focusNode: index == 0 ? _firstItemFocusNode : null,
);
},
);
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import '../../models/plex_hub.dart';
import '../../widgets/hub_section.dart';
import '../../widgets/hub_navigation_controller.dart';
import '../../i18n/strings.g.dart';
import 'base_library_tab.dart';
@@ -15,6 +16,20 @@ class LibraryRecommendedTab extends BaseLibraryTab<PlexHub> {
class _LibraryRecommendedTabState
extends BaseLibraryTabState<PlexHub, LibraryRecommendedTab> {
final HubNavigationController _hubNavigationController =
HubNavigationController();
@override
void dispose() {
_hubNavigationController.dispose();
super.dispose();
}
/// Focus the first item in the first hub
void focusFirstItem() {
_hubNavigationController.focusHub(0, 0);
}
@override
IconData get emptyIcon => Icons.recommend;
@@ -35,13 +50,20 @@ class _LibraryRecommendedTabState
@override
Widget buildContent(List<PlexHub> items) {
return ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: items.length,
itemBuilder: (context, index) {
final hub = items[index];
return HubSection(hub: hub, icon: _getHubIcon(hub));
},
return HubNavigationScope(
controller: _hubNavigationController,
child: ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: items.length,
itemBuilder: (context, index) {
final hub = items[index];
return HubSection(
hub: hub,
icon: _getHubIcon(hub),
navigationOrder: index,
);
},
),
);
}
+163 -32
View File
@@ -1,8 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../client/plex_client.dart';
import '../i18n/strings.g.dart';
import '../utils/app_logger.dart';
import '../utils/keyboard_utils.dart';
import '../utils/provider_extensions.dart';
import '../main.dart';
import '../mixins/refreshable.dart';
@@ -17,6 +19,30 @@ import 'libraries_screen.dart';
import 'search_screen.dart';
import 'settings_screen.dart';
/// InheritedWidget that provides back navigation functionality to child screens
class BackNavigationScope extends InheritedWidget {
final VoidCallback focusBottomNav;
const BackNavigationScope({
super.key,
required this.focusBottomNav,
required super.child,
});
static BackNavigationScope? of(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<BackNavigationScope>();
}
static BackNavigationScope? maybeOf(BuildContext context) {
return context.getInheritedWidgetOfExactType<BackNavigationScope>();
}
@override
bool updateShouldNotify(BackNavigationScope oldWidget) {
return focusBottomNav != oldWidget.focusBottomNav;
}
}
class MainScreen extends StatefulWidget {
final PlexClient client;
@@ -35,9 +61,18 @@ class _MainScreenState extends State<MainScreen> with RouteAware {
final GlobalKey<State<SearchScreen>> _searchKey = GlobalKey();
final GlobalKey<State<SettingsScreen>> _settingsKey = GlobalKey();
/// Focus scope node for the bottom navigation bar
/// Using FocusScopeNode so requestFocus() focuses the first child
late final FocusScopeNode _bottomNavFocusScopeNode;
/// Focus scope node for the main content area
late final FocusScopeNode _contentFocusScopeNode;
@override
void initState() {
super.initState();
_bottomNavFocusScopeNode = FocusScopeNode(debugLabel: 'BottomNavigation');
_contentFocusScopeNode = FocusScopeNode(debugLabel: 'MainContent');
_screens = [
DiscoverScreen(
@@ -69,9 +104,38 @@ class _MainScreenState extends State<MainScreen> with RouteAware {
@override
void dispose() {
routeObserver.unsubscribe(this);
_bottomNavFocusScopeNode.dispose();
_contentFocusScopeNode.dispose();
super.dispose();
}
/// Focus the bottom navigation bar (called by child screens on back press)
void _focusBottomNav() {
// Request focus on the scope, then navigate to the currently selected tab
_bottomNavFocusScopeNode.requestFocus();
WidgetsBinding.instance.addPostFrameCallback((_) {
// Move to first item, then advance to current index
_bottomNavFocusScopeNode.nextFocus();
for (int i = 0; i < _currentIndex; i++) {
_bottomNavFocusScopeNode.nextFocus();
}
});
}
/// Focus the content area (called when back is pressed in navbar)
void _focusContent() {
_contentFocusScopeNode.requestFocus();
}
/// Handle back key in navbar - focus content area
KeyEventResult _handleNavBarBackKey(FocusNode node, KeyEvent event) {
if (isBackKeyEvent(event)) {
_focusContent();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
@override
void didPush() {
// Called when this route has been pushed (initial navigation)
@@ -150,44 +214,111 @@ class _MainScreenState extends State<MainScreen> with RouteAware {
}
}
void _selectTab(int index) {
// Check if selection came from keyboard/d-pad (bottom nav has focus)
final isKeyboardNavigation = _bottomNavFocusScopeNode.hasFocus;
setState(() {
_currentIndex = index;
});
// Notify discover screen when it becomes visible via tab switch
if (index == 0) {
_onDiscoverBecameVisible();
// Focus hero when selecting Home tab via keyboard/d-pad
if (isKeyboardNavigation) {
final discoverState = _discoverKey.currentState;
if (discoverState != null) {
(discoverState as dynamic).focusHero();
}
}
}
// Focus first content item when selecting Libraries tab via keyboard/d-pad
if (index == 1 && isKeyboardNavigation) {
final librariesState = _librariesKey.currentState;
if (librariesState != null) {
(librariesState as dynamic).focusFirstContentItem();
}
}
// Focus search input when selecting Search tab (for both click/tap and keyboard)
if (index == 2) {
final searchState = _searchKey.currentState;
if (searchState != null) {
(searchState as dynamic).focusSearchInput();
}
}
// Move focus to the content area when selecting a tab via keyboard
if (isKeyboardNavigation) {
_contentFocusScopeNode.requestFocus();
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: IndexedStack(index: _currentIndex, children: _screens),
bottomNavigationBar: NavigationBar(
selectedIndex: _currentIndex,
onDestinationSelected: (index) {
setState(() {
_currentIndex = index;
});
// Notify discover screen when it becomes visible via tab switch
if (index == 0) {
_onDiscoverBecameVisible();
}
return Shortcuts(
shortcuts: {
// Number keys 1-4 for quick tab switching
const SingleActivator(LogicalKeyboardKey.digit1): _TabIntent(0),
const SingleActivator(LogicalKeyboardKey.digit2): _TabIntent(1),
const SingleActivator(LogicalKeyboardKey.digit3): _TabIntent(2),
const SingleActivator(LogicalKeyboardKey.digit4): _TabIntent(3),
},
child: Actions(
actions: {
_TabIntent: CallbackAction<_TabIntent>(
onInvoke: (intent) {
_selectTab(intent.tabIndex);
return null;
},
),
},
destinations: [
NavigationDestination(
icon: const Icon(Icons.home_outlined),
selectedIcon: const Icon(Icons.home),
label: t.navigation.home,
child: Scaffold(
body: BackNavigationScope(
focusBottomNav: _focusBottomNav,
child: FocusScope(
node: _contentFocusScopeNode,
child: FocusTraversalGroup(
child: IndexedStack(index: _currentIndex, children: _screens),
),
),
),
NavigationDestination(
icon: const Icon(Icons.video_library_outlined),
selectedIcon: const Icon(Icons.video_library),
label: t.navigation.libraries,
bottomNavigationBar: FocusScope(
node: _bottomNavFocusScopeNode,
onKeyEvent: _handleNavBarBackKey,
child: NavigationBar(
selectedIndex: _currentIndex,
onDestinationSelected: _selectTab,
destinations: [
NavigationDestination(
icon: const Icon(Icons.home_outlined),
selectedIcon: const Icon(Icons.home),
label: t.navigation.home,
),
NavigationDestination(
icon: const Icon(Icons.video_library_outlined),
selectedIcon: const Icon(Icons.video_library),
label: t.navigation.libraries,
),
NavigationDestination(
icon: const Icon(Icons.search),
selectedIcon: const Icon(Icons.search),
label: t.navigation.search,
),
NavigationDestination(
icon: const Icon(Icons.settings_outlined),
selectedIcon: const Icon(Icons.settings),
label: t.navigation.settings,
),
],
),
),
NavigationDestination(
icon: const Icon(Icons.search),
selectedIcon: const Icon(Icons.search),
label: t.navigation.search,
),
NavigationDestination(
icon: const Icon(Icons.settings_outlined),
selectedIcon: const Icon(Icons.settings),
label: t.navigation.settings,
),
],
),
),
);
}
}
/// Intent for switching tabs via keyboard shortcuts
class _TabIntent extends Intent {
final int tabIndex;
const _TabIntent(this.tabIndex);
}
File diff suppressed because it is too large Load Diff
+1
View File
@@ -336,6 +336,7 @@ class _PlaylistDetailScreenState
index: index,
onRemove: () => _removeItem(index),
onTap: () => _playFromItem(index),
onRefresh: updateItem,
canReorder: !widget.playlist.smart,
);
},
+169 -115
View File
@@ -1,8 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:rate_limiter/rate_limiter.dart';
import '../i18n/strings.g.dart';
import 'main_screen.dart';
import '../mixins/refreshable.dart';
import '../models/plex_metadata.dart';
import '../providers/multi_server_provider.dart';
@@ -10,6 +12,7 @@ import '../providers/settings_provider.dart';
import '../services/settings_service.dart';
import '../utils/app_logger.dart';
import '../utils/grid_cross_axis_extent.dart';
import '../utils/keyboard_utils.dart';
import '../widgets/desktop_app_bar.dart';
import '../widgets/media_card.dart';
@@ -22,6 +25,7 @@ class SearchScreen extends StatefulWidget {
class _SearchScreenState extends State<SearchScreen> with Refreshable {
final _searchController = TextEditingController();
final _searchFocusNode = FocusNode(debugLabel: 'SearchInput');
List<PlexMetadata> _searchResults = [];
bool _isSearching = false;
bool _hasSearched = false;
@@ -36,6 +40,10 @@ class _SearchScreenState extends State<SearchScreen> with Refreshable {
const Duration(milliseconds: 500),
);
_searchController.addListener(_onSearchChanged);
// Focus the search input when the screen is shown
WidgetsBinding.instance.addPostFrameCallback((_) {
_searchFocusNode.requestFocus();
});
}
@override
@@ -43,6 +51,7 @@ class _SearchScreenState extends State<SearchScreen> with Refreshable {
_searchDebounce.cancel();
_searchController.removeListener(_onSearchChanged);
_searchController.dispose();
_searchFocusNode.dispose();
super.dispose();
}
@@ -122,6 +131,13 @@ class _SearchScreenState extends State<SearchScreen> with Refreshable {
}
}
/// Focus the search input field
void focusSearchInput() {
WidgetsBinding.instance.addPostFrameCallback((_) {
_searchFocusNode.requestFocus();
});
}
// Public method to fully reload all content (for profile switches)
void fullRefresh() {
appLogger.d(
@@ -144,133 +160,171 @@ class _SearchScreenState extends State<SearchScreen> with Refreshable {
}
}
/// Handle back key press - focus bottom navigation
KeyEventResult _handleBackKey(FocusNode node, KeyEvent event) {
if (isBackKeyEvent(event)) {
BackNavigationScope.of(context)?.focusBottomNav();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: CustomScrollView(
slivers: [
DesktopSliverAppBar(title: Text(t.screens.search), floating: true),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: SearchBar(
controller: _searchController,
hintText: t.search.hint,
leading: const Icon(Icons.search),
trailing: [
if (_searchController.text.isNotEmpty)
IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
// State update handled by listener
},
child: Focus(
onKeyEvent: _handleBackKey,
child: CustomScrollView(
slivers: [
DesktopSliverAppBar(
title: Text(t.screens.search),
floating: true,
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: TextField(
controller: _searchController,
focusNode: _searchFocusNode,
decoration: InputDecoration(
hintText: t.search.hint,
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
// State update handled by listener
},
)
: null,
filled: true,
fillColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(28),
borderSide: BorderSide.none,
),
],
autoFocus: false,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
),
),
),
),
if (_isSearching)
const SliverFillRemaining(
child: Center(child: CircularProgressIndicator()),
)
else if (!_hasSearched)
SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.search, size: 80, color: Colors.grey.shade400),
const SizedBox(height: 16),
Text(
t.search.searchYourMedia,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: Colors.grey.shade600,
if (_isSearching)
const SliverFillRemaining(
child: Center(child: CircularProgressIndicator()),
)
else if (!_hasSearched)
SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.search,
size: 80,
color: Colors.grey.shade400,
),
),
const SizedBox(height: 8),
Text(
t.search.enterTitleActorOrKeyword,
style: TextStyle(color: Colors.grey.shade600),
),
],
),
),
)
else if (_searchResults.isEmpty)
SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.search_off,
size: 80,
color: Colors.grey.shade400,
),
const SizedBox(height: 16),
Text(
t.messages.noResultsFound,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: Colors.grey.shade600,
const SizedBox(height: 16),
Text(
t.search.searchYourMedia,
style: Theme.of(context).textTheme.titleLarge
?.copyWith(color: Colors.grey.shade600),
),
),
const SizedBox(height: 8),
Text(
t.search.tryDifferentTerm,
style: TextStyle(color: Colors.grey.shade600),
),
],
const SizedBox(height: 8),
Text(
t.search.enterTitleActorOrKeyword,
style: TextStyle(color: Colors.grey.shade600),
),
],
),
),
),
)
else
Consumer<SettingsProvider>(
builder: (context, settingsProvider, child) {
if (settingsProvider.viewMode == ViewMode.list) {
return SliverPadding(
padding: const EdgeInsets.all(16),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final item = _searchResults[index];
return MediaCard(
key: Key(item.ratingKey),
item: item,
onRefresh: updateItem,
);
}, childCount: _searchResults.length),
),
);
} else {
return SliverPadding(
padding: const EdgeInsets.all(16),
sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: getMaxCrossAxisExtentWithPadding(
)
else if (_searchResults.isEmpty)
SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.search_off,
size: 80,
color: Colors.grey.shade400,
),
const SizedBox(height: 16),
Text(
t.messages.noResultsFound,
style: Theme.of(context).textTheme.titleLarge
?.copyWith(color: Colors.grey.shade600),
),
const SizedBox(height: 8),
Text(
t.search.tryDifferentTerm,
style: TextStyle(color: Colors.grey.shade600),
),
],
),
),
)
else
Consumer<SettingsProvider>(
builder: (context, settingsProvider, child) {
if (settingsProvider.viewMode == ViewMode.list) {
return SliverPadding(
padding: const EdgeInsets.all(16),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((
context,
settingsProvider.libraryDensity,
32,
),
childAspectRatio: 2 / 3.3,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
index,
) {
final item = _searchResults[index];
return MediaCard(
key: Key(item.ratingKey),
item: item,
onRefresh: updateItem,
);
}, childCount: _searchResults.length),
),
delegate: SliverChildBuilderDelegate((context, index) {
final item = _searchResults[index];
return MediaCard(
key: Key(item.ratingKey),
item: item,
onRefresh: updateItem,
);
}, childCount: _searchResults.length),
),
);
}
},
),
],
);
} else {
return SliverPadding(
padding: const EdgeInsets.all(16),
sliver: SliverGrid(
gridDelegate:
SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent:
getMaxCrossAxisExtentWithPadding(
context,
settingsProvider.libraryDensity,
32,
),
childAspectRatio: 2 / 3.3,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
delegate: SliverChildBuilderDelegate((
context,
index,
) {
final item = _searchResults[index];
return MediaCard(
key: Key(item.ratingKey),
item: item,
onRefresh: updateItem,
);
}, childCount: _searchResults.length),
),
);
}
},
),
],
),
),
),
);
+375 -246
View File
@@ -1,13 +1,17 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../client/plex_client.dart';
import '../widgets/focus/focus_indicator.dart';
import '../models/plex_metadata.dart';
import '../utils/keyboard_utils.dart';
import '../utils/provider_extensions.dart';
import '../utils/video_player_navigation.dart';
import '../utils/duration_formatter.dart';
import '../widgets/desktop_app_bar.dart';
import '../widgets/media_context_menu.dart';
import '../mixins/item_updatable.dart';
import '../mixins/keyboard_long_press_mixin.dart';
import '../theme/theme_helper.dart';
import '../i18n/strings.g.dart';
@@ -30,6 +34,9 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen>
List<PlexMetadata> _episodes = [];
bool _isLoadingEpisodes = false;
bool _watchStateChanged = false;
final FocusNode _firstEpisodeFocusNode = FocusNode(
debugLabel: 'FirstEpisode',
);
/// Get the correct PlexClient for this season's server
PlexClient _getClientForSeason(BuildContext context) {
@@ -46,6 +53,12 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen>
});
}
@override
void dispose() {
_firstEpisodeFocusNode.dispose();
super.dispose();
}
Future<void> _loadEpisodes() async {
setState(() {
_isLoadingEpisodes = true;
@@ -59,6 +72,13 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen>
_episodes = episodes;
_isLoadingEpisodes = false;
});
// Focus the first episode after loading
if (episodes.isNotEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_firstEpisodeFocusNode.requestFocus();
});
}
} catch (e) {
setState(() {
_isLoadingEpisodes = false;
@@ -83,52 +103,145 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen>
@override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
slivers: [
CustomAppBar(
title: Text(widget.season.title),
pinned: true,
onBackPressed: () => Navigator.pop(context, _watchStateChanged),
),
if (_isLoadingEpisodes)
const SliverFillRemaining(
child: Center(child: CircularProgressIndicator()),
)
else if (_episodes.isEmpty)
SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.movie_outlined,
size: 64,
color: tokens(context).textMuted,
),
const SizedBox(height: 16),
Text(
t.messages.noEpisodesFoundGeneral,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
body: Focus(
autofocus: true,
onKeyEvent: (node, event) {
if (isBackKeyEvent(event)) {
Navigator.pop(context, _watchStateChanged);
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
child: CustomScrollView(
slivers: [
CustomAppBar(
title: Text(widget.season.title),
pinned: true,
onBackPressed: () => Navigator.pop(context, _watchStateChanged),
),
if (_isLoadingEpisodes)
const SliverFillRemaining(
child: Center(child: CircularProgressIndicator()),
)
else if (_episodes.isEmpty)
SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.movie_outlined,
size: 64,
color: tokens(context).textMuted,
),
),
],
const SizedBox(height: 16),
Text(
t.messages.noEpisodesFoundGeneral,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: tokens(context).textMuted,
),
),
],
),
),
)
else
SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final episode = _episodes[index];
return _EpisodeCard(
episode: episode,
client: _client,
focusNode: index == 0 ? _firstEpisodeFocusNode : null,
onTap: () async {
await navigateToVideoPlayer(context, metadata: episode);
// Refresh episodes when returning from video player
_loadEpisodes();
},
onRefresh: updateItem,
);
}, childCount: _episodes.length),
),
)
else
SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final episode = _episodes[index];
return _buildEpisodeCard(episode);
}, childCount: _episodes.length),
),
],
],
),
),
);
}
}
Widget _buildEpisodeCard(PlexMetadata episode) {
/// Focusable episode card widget
class _EpisodeCard extends StatefulWidget {
final PlexMetadata episode;
final PlexClient client;
final VoidCallback onTap;
final Future<void> Function(String) onRefresh;
final FocusNode? focusNode;
const _EpisodeCard({
required this.episode,
required this.client,
required this.onTap,
required this.onRefresh,
this.focusNode,
});
@override
State<_EpisodeCard> createState() => _EpisodeCardState();
}
class _EpisodeCardState extends State<_EpisodeCard>
with KeyboardLongPressMixin {
FocusNode? _internalFocusNode;
FocusNode get _focusNode =>
widget.focusNode ?? (_internalFocusNode ??= FocusNode());
bool _isFocused = false;
final _contextMenuKey = GlobalKey<MediaContextMenuState>();
@override
void onKeyboardTap() => widget.onTap();
@override
void onKeyboardLongPress() {
_contextMenuKey.currentState?.showContextMenu(context);
}
@override
void initState() {
super.initState();
_focusNode.addListener(_handleFocusChange);
}
@override
void dispose() {
_focusNode.removeListener(_handleFocusChange);
_internalFocusNode?.dispose();
super.dispose();
}
void _handleFocusChange() {
if (_isFocused != _focusNode.hasFocus) {
setState(() {
_isFocused = _focusNode.hasFocus;
});
if (_focusNode.hasFocus) {
Scrollable.ensureVisible(
context,
alignment: 0.5,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
);
}
}
}
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
// Handle long-press detection for activation keys
return handleKeyboardLongPress(event);
}
@override
Widget build(BuildContext context) {
final episode = widget.episode;
final hasProgress =
episode.viewOffset != null &&
episode.duration != null &&
@@ -138,229 +251,245 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen>
: 0.0;
return MediaContextMenu(
key: _contextMenuKey,
item: episode,
onRefresh: updateItem,
onTap: () async {
await navigateToVideoPlayer(context, metadata: episode);
// Refresh episodes when returning from video player
_loadEpisodes();
},
child: InkWell(
key: Key(episode.ratingKey),
hoverColor: Theme.of(
context,
).colorScheme.surface.withValues(alpha: 0.05),
child: Container(
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(color: tokens(context).outline, width: 0.5),
),
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Episode thumbnail (16:9 aspect ratio, fixed width)
SizedBox(
width: 160,
child: Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: AspectRatio(
aspectRatio: 16 / 9,
child: episode.thumb != null
? Builder(
builder: (context) {
return CachedNetworkImage(
imageUrl: _client.getThumbnailUrl(
episode.thumb,
),
filterQuality: FilterQuality.medium,
fit: BoxFit.cover,
placeholder: (context, url) => Container(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
),
errorWidget: (context, url, error) =>
Container(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
child: const Icon(
Icons.movie,
size: 32,
),
),
);
},
)
: Container(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
child: const Icon(Icons.movie, size: 32),
),
),
),
// Play overlay
Positioned.fill(
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black.withValues(alpha: 0.2),
],
),
),
child: Center(
child: Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.6),
shape: BoxShape.circle,
),
child: const Icon(
Icons.play_arrow,
color: Colors.white,
size: 20,
),
),
),
),
),
// Progress bar at bottom
if (hasProgress && !episode.isWatched)
Positioned(
bottom: 0,
left: 0,
right: 0,
child: ClipRRect(
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(6),
bottomRight: Radius.circular(6),
),
child: LinearProgressIndicator(
value: progress,
backgroundColor: tokens(context).outline,
minHeight: 3,
),
),
),
],
onRefresh: widget.onRefresh,
onTap: widget.onTap,
child: Focus(
focusNode: _focusNode,
onKeyEvent: _handleKeyEvent,
child: FocusIndicator(
isFocused: _isFocused,
borderRadius: 0,
scale: 1.0, // No scale for full-width items to avoid clipping
child: InkWell(
key: Key(episode.ratingKey),
onTap: widget.onTap,
hoverColor: Theme.of(
context,
).colorScheme.surface.withValues(alpha: 0.05),
focusColor: Colors.transparent,
child: Container(
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: tokens(context).outline,
width: 0.5,
),
),
),
const SizedBox(width: 12),
// Episode info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Episode number and title
Row(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Episode thumbnail (16:9 aspect ratio, fixed width)
SizedBox(
width: 160,
child: Stack(
children: [
if (episode.index != null)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 3,
),
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: AspectRatio(
aspectRatio: 16 / 9,
child: episode.thumb != null
? Builder(
builder: (context) {
return CachedNetworkImage(
imageUrl: widget.client.getThumbnailUrl(
episode.thumb,
),
filterQuality: FilterQuality.medium,
fit: BoxFit.cover,
placeholder: (context, url) =>
Container(
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest,
),
errorWidget: (context, url, error) =>
Container(
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest,
child: const Icon(
Icons.movie,
size: 32,
),
),
);
},
)
: Container(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
child: const Icon(Icons.movie, size: 32),
),
),
),
// Play overlay
Positioned.fill(
child: Container(
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(3),
borderRadius: BorderRadius.circular(6),
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black.withValues(alpha: 0.2),
],
),
),
child: Text(
'E${episode.index}',
style: TextStyle(
color: Theme.of(
context,
).colorScheme.onPrimaryContainer,
fontSize: 11,
fontWeight: FontWeight.w600,
child: Center(
child: Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.6),
shape: BoxShape.circle,
),
child: const Icon(
Icons.play_arrow,
color: Colors.white,
size: 20,
),
),
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
episode.title,
style: Theme.of(context).textTheme.titleSmall
?.copyWith(fontWeight: FontWeight.bold),
maxLines: 2,
),
// Progress bar at bottom
if (hasProgress && !episode.isWatched)
Positioned(
bottom: 0,
left: 0,
right: 0,
child: ClipRRect(
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(6),
bottomRight: Radius.circular(6),
),
child: LinearProgressIndicator(
value: progress,
backgroundColor: tokens(context).outline,
minHeight: 3,
),
),
),
],
),
),
const SizedBox(width: 12),
// Episode info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Episode number and title
Row(
children: [
if (episode.index != null)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 3,
),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(3),
),
child: Text(
'E${episode.index}',
style: TextStyle(
color: Theme.of(
context,
).colorScheme.onPrimaryContainer,
fontSize: 11,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
episode.title,
style: Theme.of(context).textTheme.titleSmall
?.copyWith(fontWeight: FontWeight.bold),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
// Summary
if (episode.summary != null &&
episode.summary!.isNotEmpty) ...[
const SizedBox(height: 6),
Text(
episode.summary!,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: tokens(context).textMuted,
height: 1.3,
),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
),
],
),
// Summary
if (episode.summary != null &&
episode.summary!.isNotEmpty) ...[
const SizedBox(height: 6),
Text(
episode.summary!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: tokens(context).textMuted,
height: 1.3,
),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
],
// Metadata row (duration, watched status)
const SizedBox(height: 8),
Row(
children: [
if (episode.duration != null)
Text(
formatDurationTimestamp(
Duration(milliseconds: episode.duration!),
),
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: tokens(context).textMuted,
fontSize: 12,
),
),
if (episode.duration != null && episode.isWatched) ...[
Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: Text(
'',
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: tokens(context).textMuted,
fontSize: 12,
),
),
),
Text(
'${t.discover.watched}',
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: tokens(context).textMuted,
fontSize: 12,
),
),
],
// Metadata row (duration, watched status)
const SizedBox(height: 8),
Row(
children: [
if (episode.duration != null)
Text(
formatDurationTimestamp(
Duration(milliseconds: episode.duration!),
),
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: tokens(context).textMuted,
fontSize: 12,
),
),
if (episode.duration != null &&
episode.isWatched) ...[
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 6,
),
child: Text(
'',
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: tokens(context).textMuted,
fontSize: 12,
),
),
),
Text(
'${t.discover.watched}',
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: tokens(context).textMuted,
fontSize: 12,
),
),
],
],
),
],
),
],
),
),
],
),
],
),
),
),
),
+38 -23
View File
@@ -1,14 +1,17 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hotkey_manager/hotkey_manager.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import '../i18n/strings.g.dart';
import 'main_screen.dart';
import '../providers/settings_provider.dart';
import '../providers/theme_provider.dart';
import '../services/keyboard_shortcuts_service.dart';
import '../services/settings_service.dart' as settings;
import '../services/update_service.dart';
import '../utils/keyboard_utils.dart';
import '../widgets/desktop_app_bar.dart';
import '../widgets/hotkey_recorder_widget.dart';
import 'about_screen.dart';
@@ -61,6 +64,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
});
}
/// Handle back key press - focus bottom navigation
KeyEventResult _handleBackKey(FocusNode node, KeyEvent event) {
if (isBackKeyEvent(event)) {
BackNavigationScope.of(context)?.focusBottomNav();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
@@ -68,31 +80,34 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
return Scaffold(
body: CustomScrollView(
slivers: [
CustomAppBar(title: Text(t.settings.title), pinned: true),
SliverPadding(
padding: const EdgeInsets.all(16),
sliver: SliverList(
delegate: SliverChildListDelegate([
_buildAppearanceSection(),
const SizedBox(height: 24),
_buildVideoPlaybackSection(),
const SizedBox(height: 24),
_buildKeyboardShortcutsSection(),
const SizedBox(height: 24),
_buildAdvancedSection(),
const SizedBox(height: 24),
if (UpdateService.isUpdateCheckEnabled) ...[
_buildUpdateSection(),
body: Focus(
onKeyEvent: _handleBackKey,
child: CustomScrollView(
slivers: [
CustomAppBar(title: Text(t.settings.title), pinned: true),
SliverPadding(
padding: const EdgeInsets.all(16),
sliver: SliverList(
delegate: SliverChildListDelegate([
_buildAppearanceSection(),
const SizedBox(height: 24),
],
_buildAboutSection(),
const SizedBox(height: 24),
]),
_buildVideoPlaybackSection(),
const SizedBox(height: 24),
_buildKeyboardShortcutsSection(),
const SizedBox(height: 24),
_buildAdvancedSection(),
const SizedBox(height: 24),
if (UpdateService.isUpdateCheckEnabled) ...[
_buildUpdateSection(),
const SizedBox(height: 24),
],
_buildAboutSection(),
const SizedBox(height: 24),
]),
),
),
),
],
],
),
),
);
}
+10 -2
View File
@@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
import 'package:media_kit/media_kit.dart';
import 'package:hotkey_manager/hotkey_manager.dart';
import 'settings_service.dart';
import '../utils/keyboard_utils.dart';
import '../utils/player_utils.dart';
class KeyboardShortcutsService {
@@ -153,10 +154,17 @@ class KeyboardShortcutsService {
VoidCallback? onNextAudioTrack,
VoidCallback? onNextSubtitleTrack,
VoidCallback? onNextChapter,
VoidCallback? onPreviousChapter,
) {
VoidCallback? onPreviousChapter, {
VoidCallback? onBack,
}) {
if (event is! KeyDownEvent) return KeyEventResult.ignored;
// Handle back navigation keys first
if (isBackKey(event.logicalKey)) {
onBack?.call();
return KeyEventResult.handled;
}
final physicalKey = event.physicalKey;
final isShiftPressed = HardwareKeyboard.instance.isShiftPressed;
final isControlPressed = HardwareKeyboard.instance.isControlPressed;
+3 -1
View File
@@ -94,7 +94,9 @@ class MediaControlsManager {
try {
await OsMediaControls.setPlaybackState(
MediaPlaybackState(
state: params.isPlaying ? PlaybackState.playing : PlaybackState.paused,
state: params.isPlaying
? PlaybackState.playing
: PlaybackState.paused,
position: params.position,
speed: params.speed,
),
+41 -18
View File
@@ -123,15 +123,15 @@ class TrackSelectionService {
'Checking language variations for "$preferredLanguage": ${languageVariations.join(", ")}',
);
for (var track in availableTracks) {
final trackLang = track.language?.toLowerCase();
if (trackLang != null && languageVariations.any((lang) => trackLang.startsWith(lang))) {
appLogger.d(
'Found audio track matching profile language "$preferredLanguage" (matched: "$trackLang"): ${track.title ?? "Track ${track.id}"}',
);
return track;
}
}
final match = _findTrackByLanguageVariations<AudioTrack>(
availableTracks,
preferredLanguage,
languageVariations,
(t) => t.language,
(t) => t.title ?? 'Track ${t.id}',
'audio track',
);
if (match != null) return match;
}
appLogger.d(
@@ -258,15 +258,15 @@ class TrackSelectionService {
'Checking language variations for "$preferredLanguage": ${languageVariations.join(", ")}',
);
for (var track in candidateTracks) {
final trackLang = track.language?.toLowerCase();
if (trackLang != null && languageVariations.any((lang) => trackLang.startsWith(lang))) {
appLogger.d(
'Found subtitle matching profile language "$preferredLanguage" (matched: "$trackLang"): ${track.title ?? "Track ${track.id}"}',
);
return track;
}
}
final match = _findTrackByLanguageVariations<SubtitleTrack>(
candidateTracks,
preferredLanguage,
languageVariations,
(t) => t.language,
(t) => t.title ?? 'Track ${t.id}',
'subtitle',
);
if (match != null) return match;
}
appLogger.d(
@@ -370,6 +370,29 @@ class TrackSelectionService {
return title.contains('forced');
}
/// Find a track matching a preferred language from a list of tracks
/// Returns the first track whose language matches any variation of the preferred language
T? _findTrackByLanguageVariations<T>(
List<T> tracks,
String preferredLanguage,
List<String> languageVariations,
String? Function(T) getLanguage,
String Function(T) getTrackDescription,
String trackType,
) {
for (var track in tracks) {
final trackLang = getLanguage(track)?.toLowerCase();
if (trackLang != null &&
languageVariations.any((lang) => trackLang.startsWith(lang))) {
appLogger.d(
'Found $trackType matching profile language "$preferredLanguage" (matched: "$trackLang"): ${getTrackDescription(track)}',
);
return track;
}
}
return null;
}
/// Checks if a track language matches a preferred language
///
/// Handles both 2-letter (ISO 639-1) and 3-letter (ISO 639-2) codes
+15
View File
@@ -0,0 +1,15 @@
import 'package:flutter/services.dart';
/// Check if a logical key is a back navigation key
bool isBackKey(LogicalKeyboardKey key) {
return key == LogicalKeyboardKey.escape ||
key == LogicalKeyboardKey.backspace ||
key == LogicalKeyboardKey.goBack ||
key == LogicalKeyboardKey.gameButtonB;
}
/// Check if a key event is a back navigation key down event
bool isBackKeyEvent(KeyEvent event) {
if (event is! KeyDownEvent) return false;
return isBackKey(event.logicalKey);
}
+39 -29
View File
@@ -22,12 +22,16 @@ class AdaptiveMediaGrid extends StatelessWidget {
/// Child aspect ratio for grid items (width / height)
final double childAspectRatio;
/// Optional focus node for the first item (for keyboard navigation)
final FocusNode? firstItemFocusNode;
const AdaptiveMediaGrid({
super.key,
required this.items,
this.onRefresh,
this.padding = const EdgeInsets.fromLTRB(8, 8, 8, 8),
this.childAspectRatio = 2 / 3.3,
this.firstItemFocusNode,
});
@override
@@ -35,39 +39,45 @@ class AdaptiveMediaGrid extends StatelessWidget {
return Consumer<SettingsProvider>(
builder: (context, settingsProvider, child) {
if (settingsProvider.viewMode == ViewMode.list) {
return ListView.builder(
padding: padding,
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
return MediaCard(
key: Key(item.ratingKey),
item: item,
onListRefresh: onRefresh,
);
},
return FocusTraversalGroup(
child: ListView.builder(
padding: padding,
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
return MediaCard(
key: Key(item.ratingKey),
item: item,
onListRefresh: onRefresh,
focusNode: index == 0 ? firstItemFocusNode : null,
);
},
),
);
} else {
return GridView.builder(
padding: padding,
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent(
context,
settingsProvider.libraryDensity,
return FocusTraversalGroup(
child: GridView.builder(
padding: padding,
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent(
context,
settingsProvider.libraryDensity,
),
childAspectRatio: childAspectRatio,
crossAxisSpacing: 0,
mainAxisSpacing: 0,
),
childAspectRatio: childAspectRatio,
crossAxisSpacing: 0,
mainAxisSpacing: 0,
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
return MediaCard(
key: Key(item.ratingKey),
item: item,
onListRefresh: onRefresh,
focusNode: index == 0 ? firstItemFocusNode : null,
);
},
),
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
return MediaCard(
key: Key(item.ratingKey),
item: item,
onListRefresh: onRefresh,
);
},
);
}
},
+192 -147
View File
@@ -1,8 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../models/plex_filter.dart';
import '../widgets/app_bar_back_button.dart';
import '../widgets/bottom_sheet_header.dart';
import '../utils/provider_extensions.dart';
import '../utils/keyboard_utils.dart';
import '../i18n/strings.g.dart';
class FiltersBottomSheet extends StatefulWidget {
@@ -30,12 +32,29 @@ class _FiltersBottomSheetState extends State<FiltersBottomSheet> {
final Map<String, String> _tempSelectedFilters = {};
final Map<String, String> _filterDisplayNames = {}; // Cache for display names
late List<PlexFilter> _sortedFilters;
final FocusNode _firstItemFocusNode = FocusNode(
debugLabel: 'FilterFirstItem',
);
final FocusNode _filterValuesFocusNode = FocusNode(
debugLabel: 'FilterValuesFirstItem',
);
@override
void initState() {
super.initState();
_tempSelectedFilters.addAll(widget.selectedFilters);
_sortFilters();
// Focus the first item after build
WidgetsBinding.instance.addPostFrameCallback((_) {
_firstItemFocusNode.requestFocus();
});
}
@override
void dispose() {
_firstItemFocusNode.dispose();
_filterValuesFocusNode.dispose();
super.dispose();
}
void _sortFilters() {
@@ -69,6 +88,10 @@ class _FiltersBottomSheetState extends State<FiltersBottomSheet> {
_filterValues = values;
_isLoadingValues = false;
});
// Focus the first filter value after loading
WidgetsBinding.instance.addPostFrameCallback((_) {
_filterValuesFocusNode.requestFocus();
});
} catch (e) {
setState(() {
_filterValues = [];
@@ -89,6 +112,21 @@ class _FiltersBottomSheetState extends State<FiltersBottomSheet> {
Navigator.pop(context);
}
/// Handle back key - go back to main view or close sheet
KeyEventResult _handleBackKey(FocusNode node, KeyEvent event) {
if (isBackKeyEvent(event)) {
if (_currentFilter != null) {
// Go back to main filters view
_goBack();
} else {
// Close the bottom sheet
Navigator.pop(context);
}
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
String _extractFilterValue(String key, String filterName) {
if (key.contains('?')) {
final queryStart = key.indexOf('?');
@@ -103,174 +141,181 @@ class _FiltersBottomSheetState extends State<FiltersBottomSheet> {
@override
Widget build(BuildContext context) {
return DraggableScrollableSheet(
initialChildSize: 0.7,
minChildSize: 0.5,
maxChildSize: 0.95,
expand: false,
builder: (context, scrollController) {
if (_currentFilter != null) {
// Show filter options view
return Column(
children: [
// Header with back button
BottomSheetHeader(
title: _currentFilter!.title,
leading: AppBarBackButton(
style: BackButtonStyle.plain,
onPressed: _goBack,
return Focus(
autofocus: true,
onKeyEvent: _handleBackKey,
child: DraggableScrollableSheet(
initialChildSize: 0.7,
minChildSize: 0.5,
maxChildSize: 0.95,
expand: false,
builder: (context, scrollController) {
if (_currentFilter != null) {
// Show filter options view
return Column(
children: [
// Header with back button
BottomSheetHeader(
title: _currentFilter!.title,
leading: AppBarBackButton(
style: BackButtonStyle.plain,
onPressed: _goBack,
),
),
),
// Filter options list
if (_isLoadingValues)
const Expanded(
child: Center(child: CircularProgressIndicator()),
)
else
Expanded(
child: ListView.builder(
controller: scrollController,
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: _filterValues.length + 1,
itemBuilder: (context, index) {
if (index == 0) {
final isSelected = !_tempSelectedFilters.containsKey(
// Filter options list
if (_isLoadingValues)
const Expanded(
child: Center(child: CircularProgressIndicator()),
)
else
Expanded(
child: ListView.builder(
controller: scrollController,
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: _filterValues.length + 1,
itemBuilder: (context, index) {
if (index == 0) {
final isSelected = !_tempSelectedFilters.containsKey(
_currentFilter!.filter,
);
return ListTile(
focusNode: _filterValuesFocusNode,
title: Text(t.libraries.all),
selected: isSelected,
onTap: () {
setState(() {
_tempSelectedFilters.remove(
_currentFilter!.filter,
);
});
_applyFilters();
},
);
}
final value = _filterValues[index - 1];
final filterValue = _extractFilterValue(
value.key,
_currentFilter!.filter,
);
final isSelected =
_tempSelectedFilters[_currentFilter!.filter] ==
filterValue;
return ListTile(
title: Text(t.libraries.all),
title: Text(value.title),
selected: isSelected,
onTap: () {
setState(() {
_tempSelectedFilters.remove(
_currentFilter!.filter,
);
_tempSelectedFilters[_currentFilter!.filter] =
filterValue;
// Cache the display name for this filter value
_filterDisplayNames['${_currentFilter!.filter}:$filterValue'] =
value.title;
});
_applyFilters();
},
);
}
},
),
),
],
);
}
final value = _filterValues[index - 1];
final filterValue = _extractFilterValue(
value.key,
_currentFilter!.filter,
);
final isSelected =
_tempSelectedFilters[_currentFilter!.filter] ==
filterValue;
return ListTile(
title: Text(value.title),
selected: isSelected,
onTap: () {
// Show main filters view
return Column(
children: [
// Header
BottomSheetHeader(
title: t.libraries.filters,
leading: const Icon(Icons.filter_alt),
action: _tempSelectedFilters.isNotEmpty
? TextButton.icon(
onPressed: () {
setState(() {
_tempSelectedFilters[_currentFilter!.filter] =
filterValue;
// Cache the display name for this filter value
_filterDisplayNames['${_currentFilter!.filter}:$filterValue'] =
value.title;
_tempSelectedFilters.clear();
});
_applyFilters();
},
icon: const Icon(Icons.clear_all),
label: Text(t.libraries.clearAll),
)
: null,
),
// All Filters (boolean toggles first, then regular filters)
Expanded(
child: ListView.builder(
controller: scrollController,
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: _sortedFilters.length,
itemBuilder: (context, index) {
final filter = _sortedFilters[index];
// Handle boolean filters as switches (unwatched, inProgress, unmatched, hdr, etc.)
if (_isBooleanFilter(filter)) {
final isActive =
_tempSelectedFilters.containsKey(filter.filter) &&
_tempSelectedFilters[filter.filter] == '1';
return SwitchListTile(
focusNode: index == 0 ? _firstItemFocusNode : null,
value: isActive,
onChanged: (value) {
setState(() {
if (value) {
_tempSelectedFilters[filter.filter] = '1';
} else {
_tempSelectedFilters.remove(filter.filter);
}
});
_applyFilters();
},
title: Text(filter.title),
);
},
),
}
// Regular navigable filters - show selected value instead of checkmark
final selectedValue = _tempSelectedFilters[filter.filter];
String? displayValue;
if (selectedValue != null) {
// Try to get the cached display name, fall back to the value itself
displayValue =
_filterDisplayNames['${filter.filter}:$selectedValue'] ??
selectedValue;
}
return ListTile(
focusNode: index == 0 ? _firstItemFocusNode : null,
title: Text(filter.title),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (displayValue != null)
Flexible(
child: Text(
displayValue,
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w500,
),
overflow: TextOverflow.ellipsis,
),
),
if (displayValue != null) const SizedBox(width: 8),
const Icon(Icons.chevron_right),
],
),
onTap: () => _loadFilterValues(filter),
);
},
),
),
],
);
}
// Show main filters view
return Column(
children: [
// Header
BottomSheetHeader(
title: t.libraries.filters,
leading: const Icon(Icons.filter_alt),
action: _tempSelectedFilters.isNotEmpty
? TextButton.icon(
onPressed: () {
setState(() {
_tempSelectedFilters.clear();
});
_applyFilters();
},
icon: const Icon(Icons.clear_all),
label: Text(t.libraries.clearAll),
)
: null,
),
// All Filters (boolean toggles first, then regular filters)
Expanded(
child: ListView.builder(
controller: scrollController,
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: _sortedFilters.length,
itemBuilder: (context, index) {
final filter = _sortedFilters[index];
// Handle boolean filters as switches (unwatched, inProgress, unmatched, hdr, etc.)
if (_isBooleanFilter(filter)) {
final isActive =
_tempSelectedFilters.containsKey(filter.filter) &&
_tempSelectedFilters[filter.filter] == '1';
return SwitchListTile(
value: isActive,
onChanged: (value) {
setState(() {
if (value) {
_tempSelectedFilters[filter.filter] = '1';
} else {
_tempSelectedFilters.remove(filter.filter);
}
});
_applyFilters();
},
title: Text(filter.title),
);
}
// Regular navigable filters - show selected value instead of checkmark
final selectedValue = _tempSelectedFilters[filter.filter];
String? displayValue;
if (selectedValue != null) {
// Try to get the cached display name, fall back to the value itself
displayValue =
_filterDisplayNames['${filter.filter}:$selectedValue'] ??
selectedValue;
}
return ListTile(
title: Text(filter.title),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (displayValue != null)
Flexible(
child: Text(
displayValue,
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w500,
),
overflow: TextOverflow.ellipsis,
),
),
if (displayValue != null) const SizedBox(width: 8),
const Icon(Icons.chevron_right),
],
),
onTap: () => _loadFilterValues(filter),
);
},
),
),
],
);
},
},
),
);
}
}
+146
View File
@@ -0,0 +1,146 @@
import 'package:flutter/material.dart';
/// A reusable focus indicator widget that wraps a child with visual feedback
/// when focused. Provides consistent focus appearance across the app for
/// keyboard/d-pad/controller navigation.
class FocusIndicator extends StatelessWidget {
final Widget child;
final bool isFocused;
final Color? borderColor;
final double borderWidth;
final double borderRadius;
final double scale;
final Duration animationDuration;
final Curve animationCurve;
const FocusIndicator({
super.key,
required this.child,
required this.isFocused,
this.borderColor,
this.borderWidth = 3.0,
this.borderRadius = 8.0,
this.scale = 1.02,
this.animationDuration = const Duration(milliseconds: 150),
this.animationCurve = Curves.easeOutCubic,
});
@override
Widget build(BuildContext context) {
final effectiveBorderColor =
borderColor ?? Theme.of(context).colorScheme.primary;
// Use AnimatedScale for the scale effect (doesn't affect layout)
// and a positioned border overlay that also doesn't affect layout
return AnimatedScale(
scale: isFocused ? scale : 1.0,
duration: animationDuration,
curve: animationCurve,
child: Stack(
clipBehavior: Clip.none,
children: [
child,
// Border overlay - doesn't affect layout
Positioned.fill(
child: IgnorePointer(
child: AnimatedContainer(
duration: animationDuration,
curve: animationCurve,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(borderRadius),
border: Border.all(
color: isFocused
? effectiveBorderColor
: Colors.transparent,
width: borderWidth,
),
),
),
),
),
],
),
);
}
}
/// A wrapper widget that manages its own FocusNode and provides focus state
/// to its builder. Use this for items that need focus handling with
/// automatic FocusNode lifecycle management.
class FocusableWrapper extends StatefulWidget {
final Widget Function(BuildContext context, bool isFocused) builder;
final FocusNode? focusNode;
final bool autofocus;
final VoidCallback? onFocused;
final ValueChanged<KeyEvent>? onKeyEvent;
final String? debugLabel;
const FocusableWrapper({
super.key,
required this.builder,
this.focusNode,
this.autofocus = false,
this.onFocused,
this.onKeyEvent,
this.debugLabel,
});
@override
State<FocusableWrapper> createState() => _FocusableWrapperState();
}
class _FocusableWrapperState extends State<FocusableWrapper> {
late FocusNode _focusNode;
bool _isFocused = false;
@override
void initState() {
super.initState();
_focusNode = widget.focusNode ?? FocusNode(debugLabel: widget.debugLabel);
_focusNode.addListener(_handleFocusChange);
}
@override
void dispose() {
_focusNode.removeListener(_handleFocusChange);
// Only dispose if we created the node
if (widget.focusNode == null) {
_focusNode.dispose();
}
super.dispose();
}
void _handleFocusChange() {
final hasFocus = _focusNode.hasFocus;
if (_isFocused != hasFocus) {
setState(() {
_isFocused = hasFocus;
});
if (hasFocus) {
widget.onFocused?.call();
// Ensure the focused item is visible in scrollable containers
Scrollable.ensureVisible(
context,
alignment: 0.5,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
);
}
}
}
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
widget.onKeyEvent?.call(event);
return KeyEventResult.ignored;
}
@override
Widget build(BuildContext context) {
return Focus(
focusNode: _focusNode,
autofocus: widget.autofocus,
onKeyEvent: _handleKeyEvent,
child: widget.builder(context, _isFocused),
);
}
}
+115
View File
@@ -0,0 +1,115 @@
import 'package:flutter/material.dart';
/// Controller that manages navigation between hub sections.
/// Hub sections register themselves, and MediaCards can request
/// to navigate to adjacent hub sections.
class HubNavigationController extends ChangeNotifier {
final List<HubSectionRegistration> _registrations = [];
/// Map of hub ID to last focused item index
final Map<String, int> _focusMemory = {};
/// Register a hub section with the controller
/// If a hub with the same ID is already registered, it will be replaced
/// Registrations are kept sorted by order for consistent navigation
void register(HubSectionRegistration registration) {
// Remove any existing registration with the same hubId
_registrations.removeWhere((r) => r.hubId == registration.hubId);
_registrations.add(registration);
// Sort by order to maintain consistent navigation regardless of registration timing
_registrations.sort((a, b) => a.order.compareTo(b.order));
}
/// Unregister a hub section
void unregister(String hubId) {
_registrations.removeWhere((r) => r.hubId == hubId);
_focusMemory.remove(hubId);
}
/// Remember the focused item index for a hub
void rememberFocusedIndex(String hubId, int index) {
_focusMemory[hubId] = index;
}
/// Get the remembered focused index for a hub (or 0 if none)
int getRememberedIndex(String hubId) {
return _focusMemory[hubId] ?? 0;
}
/// Navigate to the next hub section (direction: 1 for down, -1 for up)
/// Returns true if navigation was handled
bool navigateToAdjacentHub(String currentHubId, int direction) {
final currentIndex = _registrations.indexWhere(
(r) => r.hubId == currentHubId,
);
if (currentIndex == -1) return false;
final targetIndex = currentIndex + direction;
if (targetIndex < 0 || targetIndex >= _registrations.length) return false;
final targetHub = _registrations[targetIndex];
if (targetHub.itemCount == 0) {
// Nothing to focus in the target hub
return false;
}
final rememberedIndex = getRememberedIndex(targetHub.hubId);
// Focus the remembered item or first item
targetHub.focusItem(rememberedIndex.clamp(0, targetHub.itemCount - 1));
return true;
}
/// Focus a specific hub and item by order index
/// [hubIndex] is the index in the sorted list (0 = first hub)
/// [itemIndex] is the item within that hub (0 = first item)
void focusHub(int hubIndex, int itemIndex) {
if (hubIndex < 0 || hubIndex >= _registrations.length) return;
final hub = _registrations[hubIndex];
if (hub.itemCount > 0) {
hub.focusItem(itemIndex.clamp(0, hub.itemCount - 1));
}
}
}
/// Registration info for a hub section
class HubSectionRegistration {
final String hubId;
final int itemCount;
final int order; // Visual order on screen (lower = higher on screen)
final void Function(int index) focusItem;
HubSectionRegistration({
required this.hubId,
required this.itemCount,
required this.focusItem,
this.order = 1000, // Default high order for dynamic hubs
});
}
/// InheritedWidget to provide the HubNavigationController down the tree
class HubNavigationScope extends InheritedWidget {
final HubNavigationController controller;
const HubNavigationScope({
super.key,
required this.controller,
required super.child,
});
static HubNavigationController? of(BuildContext context) {
final scope = context
.dependOnInheritedWidgetOfExactType<HubNavigationScope>();
return scope?.controller;
}
static HubNavigationController? maybeOf(BuildContext context) {
final scope = context.getInheritedWidgetOfExactType<HubNavigationScope>();
return scope?.controller;
}
@override
bool updateShouldNotify(HubNavigationScope oldWidget) {
return controller != oldWidget.controller;
}
}
+270 -94
View File
@@ -1,19 +1,25 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../models/plex_hub.dart';
import '../screens/hub_detail_screen.dart';
import 'media_card.dart';
import 'horizontal_scroll_with_arrows.dart';
import 'hub_navigation_controller.dart';
import '../i18n/strings.g.dart';
import 'focus/focus_indicator.dart';
/// Shared hub section widget used in both discover and library screens
/// Displays a hub title with icon and a horizontal scrollable list of items
class HubSection extends StatelessWidget {
class HubSection extends StatefulWidget {
final PlexHub hub;
final IconData icon;
final void Function(String)? onRefresh;
final VoidCallback? onRemoveFromContinueWatching;
final bool isInContinueWatching;
/// Order for navigation (lower = higher on screen). Default is 1000 for dynamic hubs.
final int navigationOrder;
const HubSection({
super.key,
required this.hub,
@@ -21,113 +27,283 @@ class HubSection extends StatelessWidget {
this.onRefresh,
this.onRemoveFromContinueWatching,
this.isInContinueWatching = false,
this.navigationOrder = 1000,
});
@override
State<HubSection> createState() => _HubSectionState();
}
class _HubSectionState extends State<HubSection> {
late final FocusNode _headerFocusNode;
bool _headerIsFocused = false;
HubNavigationController? _controller;
/// Focus nodes for each item in the hub
List<FocusNode> _itemFocusNodes = [];
String? _registeredHubId;
int? _registeredItemCount;
int? _registeredOrder;
String get _hubId => widget.hub.hubIdentifier ?? widget.hub.title;
@override
void initState() {
super.initState();
_headerFocusNode = FocusNode();
_headerFocusNode.addListener(_handleHeaderFocusChange);
_createItemFocusNodes();
}
void _createItemFocusNodes() {
// Create focus nodes for each item
_itemFocusNodes = List.generate(
widget.hub.items.length,
(index) => FocusNode(debugLabel: 'HubItem_${_hubId}_$index'),
);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_registerWithController();
}
@override
void didUpdateWidget(HubSection oldWidget) {
super.didUpdateWidget(oldWidget);
// If items changed, recreate focus nodes
if (widget.hub.items.length != _itemFocusNodes.length) {
_disposeItemFocusNodes();
_createItemFocusNodes();
}
_registerWithController();
}
void _unregisterFromController() {
if (_controller != null && _registeredHubId != null) {
_controller!.unregister(_registeredHubId!);
}
_registeredHubId = null;
_registeredItemCount = null;
_registeredOrder = null;
}
void _registerWithController() {
final controller = HubNavigationScope.maybeOf(context);
final hubId = _hubId;
final itemCount = widget.hub.items.length;
final order = widget.navigationOrder;
if (controller != _controller) {
_unregisterFromController();
_controller = controller;
}
if (controller == null) return;
final registrationChanged =
_registeredHubId != hubId ||
_registeredItemCount != itemCount ||
_registeredOrder != order;
if (registrationChanged) {
_unregisterFromController();
controller.register(
HubSectionRegistration(
hubId: hubId,
itemCount: itemCount,
focusItem: _focusItem,
order: order,
),
);
_registeredHubId = hubId;
_registeredItemCount = itemCount;
_registeredOrder = order;
}
}
void _focusItem(int index) {
if (index >= 0 && index < _itemFocusNodes.length) {
_itemFocusNodes[index].requestFocus();
}
}
void _disposeItemFocusNodes() {
for (final node in _itemFocusNodes) {
node.dispose();
}
_itemFocusNodes = [];
}
@override
void dispose() {
_unregisterFromController();
_headerFocusNode.removeListener(_handleHeaderFocusChange);
_headerFocusNode.dispose();
_disposeItemFocusNodes();
super.dispose();
}
void _handleHeaderFocusChange() {
if (_headerIsFocused != _headerFocusNode.hasFocus) {
setState(() {
_headerIsFocused = _headerFocusNode.hasFocus;
});
}
}
void _navigateToHubDetail() {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => HubDetailScreen(hub: widget.hub)),
);
}
KeyEventResult _handleHeaderKeyEvent(FocusNode node, KeyEvent event) {
if (event is KeyDownEvent && widget.hub.more) {
if (event.logicalKey == LogicalKeyboardKey.enter ||
event.logicalKey == LogicalKeyboardKey.space ||
event.logicalKey == LogicalKeyboardKey.select ||
event.logicalKey == LogicalKeyboardKey.gameButtonA) {
_navigateToHubDetail();
return KeyEventResult.handled;
}
}
return KeyEventResult.ignored;
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Hub header
Padding(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 8),
child: InkWell(
onTap: hub.more
? () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => HubDetailScreen(hub: hub),
),
);
}
: null,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row(
children: [
Icon(icon),
const SizedBox(width: 8),
Text(
hub.title,
style: Theme.of(context).textTheme.titleLarge,
return FocusTraversalGroup(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Hub header
Padding(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 8),
child: Focus(
focusNode: _headerFocusNode,
onKeyEvent: _handleHeaderKeyEvent,
canRequestFocus: widget.hub.more, // Only focusable if has "more"
child: FocusIndicator(
isFocused: _headerIsFocused && widget.hub.more,
borderRadius: 8,
child: InkWell(
onTap: widget.hub.more ? _navigateToHubDetail : null,
borderRadius: BorderRadius.circular(8),
focusColor: Colors.transparent,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(widget.icon),
const SizedBox(width: 8),
Text(
widget.hub.title,
style: Theme.of(context).textTheme.titleLarge,
),
if (widget.hub.more) ...[
const SizedBox(width: 4),
const Icon(Icons.chevron_right, size: 20),
],
],
),
),
if (hub.more) ...[
const SizedBox(width: 4),
const Icon(Icons.chevron_right, size: 20),
],
],
),
),
),
),
),
// Hub items (horizontal scroll)
if (hub.items.isNotEmpty)
LayoutBuilder(
builder: (context, constraints) {
// Responsive card width based on screen size
final screenWidth = constraints.maxWidth;
final cardWidth = screenWidth > 1600
? 220.0
: screenWidth > 1200
? 200.0
: screenWidth > 800
? 190.0
: 160.0;
// Hub items (horizontal scroll)
if (widget.hub.items.isNotEmpty)
LayoutBuilder(
builder: (context, constraints) {
// Responsive card width based on screen size
final screenWidth = constraints.maxWidth;
final cardWidth = screenWidth > 1600
? 220.0
: screenWidth > 1200
? 200.0
: screenWidth > 800
? 190.0
: 160.0;
// MediaCard has 8px padding on all sides (16px total horizontally)
// So actual poster width is cardWidth - 16
final posterWidth = cardWidth - 16;
// 2:3 poster aspect ratio (height is 1.5x width)
final posterHeight = posterWidth * 1.5;
// Container height = poster + padding + spacing + text
// 8px top padding + posterHeight + 4px spacing + ~26px text + 8px bottom padding
final containerHeight = posterHeight + 46;
// MediaCard has 8px padding on all sides (16px total horizontally)
// So actual poster width is cardWidth - 16
final posterWidth = cardWidth - 16;
// 2:3 poster aspect ratio (height is 1.5x width)
final posterHeight = posterWidth * 1.5;
// Container height = poster + padding + spacing + text + focus indicator headroom
// 8px top padding + posterHeight + 4px spacing + ~26px text + 8px bottom padding
// + 10px extra for focus indicator border (3px) and scale effect (1.02x)
final containerHeight = posterHeight + 46 + 10;
return SizedBox(
height: containerHeight,
child: HorizontalScrollWithArrows(
builder: (scrollController) => ListView.builder(
controller: scrollController,
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12),
itemCount: hub.items.length,
itemBuilder: (context, index) {
final item = hub.items[index];
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: MediaCard(
key: Key(item.ratingKey),
item: item,
width: cardWidth,
height: posterHeight,
onRefresh: onRefresh,
onRemoveFromContinueWatching:
onRemoveFromContinueWatching,
forceGridMode: true,
isInContinueWatching: isInContinueWatching,
return SizedBox(
height: containerHeight,
child: ClipRect(
clipBehavior: Clip.none,
child: HorizontalScrollWithArrows(
builder: (scrollController) => FocusTraversalGroup(
child: ListView.builder(
controller: scrollController,
scrollDirection: Axis.horizontal,
clipBehavior:
Clip.none, // Allow focus indicator to overflow
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 5,
),
itemCount: widget.hub.items.length,
itemBuilder: (context, index) {
final item = widget.hub.items[index];
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 2,
),
child: MediaCard(
key: Key(item.ratingKey),
item: item,
width: cardWidth,
height: posterHeight,
onRefresh: widget.onRefresh,
onRemoveFromContinueWatching:
widget.onRemoveFromContinueWatching,
forceGridMode: true,
isInContinueWatching:
widget.isInContinueWatching,
focusNode: _itemFocusNodes.length > index
? _itemFocusNodes[index]
: null,
hubId: _hubId,
itemIndex: index,
),
);
},
),
);
},
),
),
),
),
);
},
)
else
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
t.messages.noItemsAvailable,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: Colors.grey),
);
},
)
else
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
t.messages.noItemsAvailable,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: Colors.grey),
),
),
),
],
],
),
);
}
}
+548 -229
View File
@@ -1,7 +1,11 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:provider/provider.dart';
import 'focus/focus_indicator.dart';
import 'hub_navigation_controller.dart';
import '../client/plex_client.dart';
import '../mixins/keyboard_long_press_mixin.dart';
import '../models/plex_metadata.dart';
import '../models/plex_playlist.dart';
import '../providers/multi_server_provider.dart';
@@ -32,6 +36,15 @@ class MediaCard extends StatefulWidget {
final String?
collectionId; // The collection ID if displaying within a collection
/// External FocusNode for hub navigation (provided by HubSection)
final FocusNode? focusNode;
/// Hub section ID for focus memory tracking
final String? hubId;
/// Item index within the hub section
final int? itemIndex;
const MediaCard({
super.key,
required this.item,
@@ -43,6 +56,9 @@ class MediaCard extends StatefulWidget {
this.forceGridMode = false,
this.isInContinueWatching = false,
this.collectionId,
this.focusNode,
this.hubId,
this.itemIndex,
});
@override
@@ -50,6 +66,12 @@ class MediaCard extends StatefulWidget {
}
class _MediaCardState extends State<MediaCard> {
final _contextMenuKey = GlobalKey<MediaContextMenuState>();
void _showContextMenu() {
_contextMenuKey.currentState?.showContextMenu(context);
}
String _buildSemanticLabel() {
final item = widget.item;
final itemType = item.type.toLowerCase();
@@ -189,16 +211,22 @@ class _MediaCardState extends State<MediaCard> {
height: widget.height,
semanticLabel: semanticLabel,
onTap: () => _handleTap(context),
onLongPress: _showContextMenu,
focusNode: widget.focusNode,
hubId: widget.hubId,
itemIndex: widget.itemIndex,
)
: _MediaCardList(
item: widget.item,
semanticLabel: semanticLabel,
onTap: () => _handleTap(context),
onLongPress: _showContextMenu,
density: settingsProvider.libraryDensity,
);
// Use context menu for both PlexMetadata and PlexPlaylist items
return MediaContextMenu(
key: _contextMenuKey,
item: widget.item,
onRefresh: widget.onRefresh,
onRemoveFromContinueWatching: widget.onRemoveFromContinueWatching,
@@ -212,12 +240,22 @@ class _MediaCardState extends State<MediaCard> {
}
/// Grid layout for media cards
class _MediaCardGrid extends StatelessWidget {
class _MediaCardGrid extends StatefulWidget {
final dynamic item; // Can be PlexMetadata or PlexPlaylist
final double? width;
final double? height;
final String semanticLabel;
final VoidCallback onTap;
final VoidCallback onLongPress;
/// External FocusNode for hub navigation (provided by HubSection)
final FocusNode? focusNode;
/// Hub section ID for focus memory tracking
final String? hubId;
/// Item index within the hub section
final int? itemIndex;
const _MediaCardGrid({
required this.item,
@@ -225,139 +263,309 @@ class _MediaCardGrid extends StatelessWidget {
this.height,
required this.semanticLabel,
required this.onTap,
required this.onLongPress,
this.focusNode,
this.hubId,
this.itemIndex,
});
@override
State<_MediaCardGrid> createState() => _MediaCardGridState();
}
class _MediaCardGridState extends State<_MediaCardGrid>
with KeyboardLongPressMixin {
FocusNode? _ownFocusNode;
bool _isFocused = false;
@override
void onKeyboardTap() => widget.onTap();
@override
void onKeyboardLongPress() => widget.onLongPress();
/// Returns the effective focus node (external if provided, otherwise our own)
FocusNode get _focusNode {
if (widget.focusNode != null) return widget.focusNode!;
_ownFocusNode ??= FocusNode();
return _ownFocusNode!;
}
@override
void initState() {
super.initState();
_focusNode.addListener(_handleFocusChange);
}
@override
void didUpdateWidget(_MediaCardGrid oldWidget) {
super.didUpdateWidget(oldWidget);
// If focusNode changed, update listener
if (oldWidget.focusNode != widget.focusNode) {
oldWidget.focusNode?.removeListener(_handleFocusChange);
_focusNode.addListener(_handleFocusChange);
}
}
@override
void dispose() {
_focusNode.removeListener(_handleFocusChange);
// Only dispose if we created the node
_ownFocusNode?.dispose();
super.dispose();
}
void _handleFocusChange() {
if (_isFocused != _focusNode.hasFocus) {
setState(() {
_isFocused = _focusNode.hasFocus;
});
if (_focusNode.hasFocus) {
// Update focus memory if we're in a hub section
if (widget.hubId != null && widget.itemIndex != null) {
final controller = HubNavigationScope.maybeOf(context);
controller?.rememberFocusedIndex(widget.hubId!, widget.itemIndex!);
}
// Scroll to center only if item is not fully visible
_scrollToCenterIfNeeded();
}
}
}
/// Scrolls to center the item only if it's not already fully visible.
/// This prevents unnecessary scrolling when navigating horizontally
/// within the same row while still centering when scrolling is needed.
void _scrollToCenterIfNeeded() {
// For hub sections (nested scrollables), use simple centering
// The smart scroll logic doesn't work well with horizontal+vertical nesting
if (widget.hubId != null) {
Scrollable.ensureVisible(
context,
alignment: 0.5,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
);
return;
}
// For non-hub contexts (like library browse), use smart scrolling
final scrollable = Scrollable.maybeOf(context);
if (scrollable == null) {
// Fallback to simple centering
Scrollable.ensureVisible(
context,
alignment: 0.5,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
);
return;
}
final renderObject = context.findRenderObject();
if (renderObject == null || renderObject is! RenderBox) return;
final box = renderObject;
final scrollRenderObject = scrollable.context.findRenderObject();
if (scrollRenderObject == null) return;
// Get item's position relative to the scroll view
final transform = box.getTransformTo(scrollRenderObject);
final itemRect = MatrixUtils.transformRect(
transform,
Offset.zero & box.size,
);
// Get viewport bounds
final position = scrollable.position;
final viewportHeight = position.viewportDimension;
// Check if item is fully visible (with margin for focus indicator)
const focusMargin = 4.0;
final isFullyVisible =
itemRect.top >= focusMargin &&
itemRect.bottom <= viewportHeight - focusMargin;
if (!isFullyVisible) {
// Item is not fully visible, scroll to center it
Scrollable.ensureVisible(
context,
alignment: 0.5,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
);
}
}
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
// Handle long-press detection for activation keys
final longPressResult = handleKeyboardLongPress(event);
if (longPressResult == KeyEventResult.handled) {
return longPressResult;
}
if (event is KeyDownEvent) {
// Handle up/down for hub navigation
if (widget.hubId != null) {
final controller = HubNavigationScope.maybeOf(context);
if (controller != null) {
if (event.logicalKey == LogicalKeyboardKey.arrowUp) {
if (controller.navigateToAdjacentHub(widget.hubId!, -1)) {
return KeyEventResult.handled;
}
} else if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
if (controller.navigateToAdjacentHub(widget.hubId!, 1)) {
return KeyEventResult.handled;
}
}
}
}
}
return KeyEventResult.ignored;
}
@override
Widget build(BuildContext context) {
return SizedBox(
width: width,
child: Semantics(
label: semanticLabel,
button: true,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Poster
if (height != null)
SizedBox(
width: double.infinity,
height: height,
child: _buildPosterWithOverlay(context),
)
else
Expanded(child: _buildPosterWithOverlay(context)),
const SizedBox(height: 4),
// Text content
Column(
return Focus(
focusNode: _focusNode,
onKeyEvent: _handleKeyEvent,
child: FocusIndicator(
isFocused: _isFocused,
borderRadius: 8,
child: SizedBox(
width: widget.width,
child: Semantics(
label: widget.semanticLabel,
button: true,
child: InkWell(
onTap: widget.onTap,
borderRadius: BorderRadius.circular(8),
focusColor: Colors.transparent, // We use our own focus indicator
child: Padding(
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: [
Text(
item is PlexPlaylist
? (item as PlexPlaylist).title
: (item as PlexMetadata).displayTitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 13,
height: 1.1,
),
),
if (item is PlexPlaylist)
Builder(
builder: (context) {
final playlist = item as PlexPlaylist;
if (playlist.leafCount != null &&
playlist.leafCount! > 0) {
return Text(
t.playlists.itemCount(count: playlist.leafCount!),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: tokens(context).textMuted,
fontSize: 11,
height: 1.1,
),
);
}
return const SizedBox.shrink();
},
// Poster
if (widget.height != null)
SizedBox(
width: double.infinity,
height: widget.height,
child: _buildPosterWithOverlay(context),
)
else if (item is PlexMetadata) ...[
Builder(
builder: (context) {
final metadata = item as PlexMetadata;
// For collections, show item count
if (metadata.type.toLowerCase() == 'collection') {
final count =
metadata.childCount ?? metadata.leafCount;
if (count != null && count > 0) {
return Text(
t.playlists.itemCount(count: count),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: tokens(context).textMuted,
fontSize: 11,
height: 1.1,
),
);
}
}
// For other media types, show subtitle/parent/year
if (metadata.displaySubtitle != null) {
return Text(
metadata.displaySubtitle!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: tokens(context).textMuted,
fontSize: 11,
height: 1.1,
else
Expanded(child: _buildPosterWithOverlay(context)),
const SizedBox(height: 4),
// Text content
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: [
Text(
widget.item is PlexPlaylist
? (widget.item as PlexPlaylist).title
: (widget.item as PlexMetadata).displayTitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 13,
height: 1.1,
),
),
if (widget.item is PlexPlaylist)
Builder(
builder: (context) {
final playlist = widget.item as PlexPlaylist;
if (playlist.leafCount != null &&
playlist.leafCount! > 0) {
return Text(
t.playlists.itemCount(
count: playlist.leafCount!,
),
);
} else if (metadata.parentTitle != null) {
return Text(
metadata.parentTitle!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: tokens(context).textMuted,
fontSize: 11,
height: 1.1,
),
);
} else if (metadata.year != null) {
return Text(
'${metadata.year}',
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: tokens(context).textMuted,
fontSize: 11,
height: 1.1,
),
);
}
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: tokens(context).textMuted,
fontSize: 11,
height: 1.1,
),
);
}
return const SizedBox.shrink();
},
)
else if (widget.item is PlexMetadata) ...[
Builder(
builder: (context) {
final metadata = widget.item as PlexMetadata;
return const SizedBox.shrink();
},
),
],
// For collections, show item count
if (metadata.type.toLowerCase() == 'collection') {
final count =
metadata.childCount ?? metadata.leafCount;
if (count != null && count > 0) {
return Text(
t.playlists.itemCount(count: count),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: tokens(context).textMuted,
fontSize: 11,
height: 1.1,
),
);
}
}
// For other media types, show subtitle/parent/year
if (metadata.displaySubtitle != null) {
return Text(
metadata.displaySubtitle!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: tokens(context).textMuted,
fontSize: 11,
height: 1.1,
),
);
} else if (metadata.parentTitle != null) {
return Text(
metadata.parentTitle!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: tokens(context).textMuted,
fontSize: 11,
height: 1.1,
),
);
} else if (metadata.year != null) {
return Text(
'${metadata.year}',
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: tokens(context).textMuted,
fontSize: 11,
height: 1.1,
),
);
}
return const SizedBox.shrink();
},
),
],
],
),
],
),
],
),
),
),
),
@@ -370,30 +578,129 @@ class _MediaCardGrid extends StatelessWidget {
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: _buildPosterImage(context, item),
child: _buildPosterImage(context, widget.item),
),
_PosterOverlay(item: item),
_PosterOverlay(item: widget.item),
],
);
}
}
/// List layout for media cards
class _MediaCardList extends StatelessWidget {
class _MediaCardList extends StatefulWidget {
final dynamic item; // Can be PlexMetadata or PlexPlaylist
final String semanticLabel;
final VoidCallback onTap;
final VoidCallback onLongPress;
final LibraryDensity density;
const _MediaCardList({
required this.item,
required this.semanticLabel,
required this.onTap,
required this.onLongPress,
required this.density,
});
@override
State<_MediaCardList> createState() => _MediaCardListState();
}
class _MediaCardListState extends State<_MediaCardList>
with KeyboardLongPressMixin {
late final FocusNode _focusNode;
bool _isFocused = false;
@override
void onKeyboardTap() => widget.onTap();
@override
void onKeyboardLongPress() => widget.onLongPress();
@override
void initState() {
super.initState();
_focusNode = FocusNode();
_focusNode.addListener(_handleFocusChange);
}
@override
void dispose() {
_focusNode.removeListener(_handleFocusChange);
_focusNode.dispose();
super.dispose();
}
void _handleFocusChange() {
if (_isFocused != _focusNode.hasFocus) {
setState(() {
_isFocused = _focusNode.hasFocus;
});
if (_focusNode.hasFocus) {
// Scroll to center only if item is not fully visible
_scrollToCenterIfNeeded();
}
}
}
/// Scrolls to center the item only if it's not already fully visible.
/// This prevents unnecessary scrolling when navigating horizontally
/// within the same row while still centering when scrolling is needed.
void _scrollToCenterIfNeeded() {
final scrollable = Scrollable.maybeOf(context);
if (scrollable == null) {
// Fallback to simple centering
Scrollable.ensureVisible(
context,
alignment: 0.5,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
);
return;
}
final renderObject = context.findRenderObject();
if (renderObject == null || renderObject is! RenderBox) return;
final box = renderObject;
final scrollRenderObject = scrollable.context.findRenderObject();
if (scrollRenderObject == null) return;
// Get item's position relative to the scroll view
final transform = box.getTransformTo(scrollRenderObject);
final itemRect = MatrixUtils.transformRect(
transform,
Offset.zero & box.size,
);
// Get viewport bounds
final position = scrollable.position;
final viewportHeight = position.viewportDimension;
// Check if item is fully visible (with margin for focus indicator)
const focusMargin = 4.0;
final isFullyVisible =
itemRect.top >= focusMargin &&
itemRect.bottom <= viewportHeight - focusMargin;
if (!isFullyVisible) {
// Item is not fully visible, scroll to center it
Scrollable.ensureVisible(
context,
alignment: 0.5,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
);
}
}
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
// Handle long-press detection for activation keys
return handleKeyboardLongPress(event);
}
double get _posterWidth {
switch (density) {
switch (widget.density) {
case LibraryDensity.compact:
return 80;
case LibraryDensity.normal:
@@ -408,7 +715,7 @@ class _MediaCardList extends StatelessWidget {
}
double get _titleFontSize {
switch (density) {
switch (widget.density) {
case LibraryDensity.compact:
return 14;
case LibraryDensity.normal:
@@ -419,7 +726,7 @@ class _MediaCardList extends StatelessWidget {
}
double get _metadataFontSize {
switch (density) {
switch (widget.density) {
case LibraryDensity.compact:
return 11;
case LibraryDensity.normal:
@@ -430,7 +737,7 @@ class _MediaCardList extends StatelessWidget {
}
double get _subtitleFontSize {
switch (density) {
switch (widget.density) {
case LibraryDensity.compact:
return 12;
case LibraryDensity.normal:
@@ -446,7 +753,7 @@ class _MediaCardList extends StatelessWidget {
}
int get _summaryMaxLines {
switch (density) {
switch (widget.density) {
case LibraryDensity.compact:
return 2;
case LibraryDensity.normal:
@@ -459,8 +766,8 @@ class _MediaCardList extends StatelessWidget {
String _buildMetadataLine() {
final parts = <String>[];
if (item is PlexPlaylist) {
final playlist = item as PlexPlaylist;
if (widget.item is PlexPlaylist) {
final playlist = widget.item as PlexPlaylist;
// Add item count
if (playlist.leafCount != null && playlist.leafCount! > 0) {
parts.add(t.playlists.itemCount(count: playlist.leafCount!));
@@ -475,8 +782,8 @@ class _MediaCardList extends StatelessWidget {
if (playlist.smart) {
parts.add(t.playlists.smartPlaylist);
}
} else if (item is PlexMetadata) {
final metadata = item as PlexMetadata;
} else if (widget.item is PlexMetadata) {
final metadata = widget.item as PlexMetadata;
// For collections, show item count
if (metadata.type.toLowerCase() == 'collection') {
@@ -521,11 +828,11 @@ class _MediaCardList extends StatelessWidget {
}
String? _buildSubtitleText() {
if (item is PlexPlaylist) {
if (widget.item is PlexPlaylist) {
// Playlists don't have subtitles
return null;
} else if (item is PlexMetadata) {
final metadata = item as PlexMetadata;
} else if (widget.item is PlexMetadata) {
final metadata = widget.item as PlexMetadata;
// For TV episodes, show S#E# format
if (metadata.parentIndex != null && metadata.index != null) {
@@ -549,100 +856,112 @@ class _MediaCardList extends StatelessWidget {
final metadataLine = _buildMetadataLine();
final subtitle = _buildSubtitleText();
return Semantics(
label: semanticLabel,
button: true,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.all(8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Poster (responsive size based on density)
SizedBox(
width: _posterWidth,
height: _posterHeight,
child: Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: _buildPosterImage(context, item),
return Focus(
focusNode: _focusNode,
onKeyEvent: _handleKeyEvent,
child: FocusIndicator(
isFocused: _isFocused,
borderRadius: 8,
child: Semantics(
label: widget.semanticLabel,
button: true,
child: InkWell(
onTap: widget.onTap,
borderRadius: BorderRadius.circular(8),
focusColor: Colors.transparent, // We use our own focus indicator
child: Padding(
padding: const EdgeInsets.all(8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Poster (responsive size based on density)
SizedBox(
width: _posterWidth,
height: _posterHeight,
child: Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: _buildPosterImage(context, widget.item),
),
_PosterOverlay(item: widget.item),
],
),
_PosterOverlay(item: item),
],
),
),
const SizedBox(width: 12),
// Metadata
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: [
// Title
Text(
item.displayTitle,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: _titleFontSize,
height: 1.2,
),
),
const SizedBox(width: 12),
// Metadata
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: [
// Title
Text(
widget.item.displayTitle,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: _titleFontSize,
height: 1.2,
),
),
const SizedBox(height: 4),
// Metadata info line (rating, duration, score, studio)
if (metadataLine.isNotEmpty) ...[
Text(
metadataLine,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: tokens(
context,
).textMuted.withValues(alpha: 0.9),
fontSize: _metadataFontSize,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 2),
],
// Subtitle (S#E# or year/parent title)
if (subtitle != null) ...[
Text(
subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: tokens(
context,
).textMuted.withValues(alpha: 0.85),
fontSize: _subtitleFontSize,
),
),
const SizedBox(height: 4),
],
// Summary
if (widget.item.summary != null) ...[
Text(
widget.item.summary!,
maxLines: _summaryMaxLines,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: tokens(
context,
).textMuted.withValues(alpha: 0.7),
fontSize: _summaryFontSize,
height: 1.3,
),
),
],
],
),
const SizedBox(height: 4),
// Metadata info line (rating, duration, score, studio)
if (metadataLine.isNotEmpty) ...[
Text(
metadataLine,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: tokens(
context,
).textMuted.withValues(alpha: 0.9),
fontSize: _metadataFontSize,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 2),
],
// Subtitle (S#E# or year/parent title)
if (subtitle != null) ...[
Text(
subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: tokens(
context,
).textMuted.withValues(alpha: 0.85),
fontSize: _subtitleFontSize,
),
),
const SizedBox(height: 4),
],
// Summary
if (item.summary != null) ...[
Text(
item.summary!,
maxLines: _summaryMaxLines,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: tokens(
context,
).textMuted.withValues(alpha: 0.7),
fontSize: _summaryFontSize,
height: 1.3,
),
),
],
],
),
),
],
),
],
),
),
),
),
+351 -60
View File
@@ -1,5 +1,6 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../client/plex_client.dart';
import '../models/plex_metadata.dart';
@@ -9,6 +10,7 @@ import '../providers/playback_state_provider.dart';
import '../utils/provider_extensions.dart';
import '../utils/app_logger.dart';
import '../utils/collection_playlist_play_helper.dart';
import '../utils/keyboard_utils.dart';
import '../utils/library_refresh_notifier.dart';
import '../utils/video_player_navigation.dart';
import '../screens/media_detail_screen.dart';
@@ -51,16 +53,40 @@ class MediaContextMenu extends StatefulWidget {
});
@override
State<MediaContextMenu> createState() => _MediaContextMenuState();
State<MediaContextMenu> createState() => MediaContextMenuState();
}
class _MediaContextMenuState extends State<MediaContextMenu> {
class MediaContextMenuState extends State<MediaContextMenu> {
Offset? _tapPosition;
void _storeTapPosition(TapDownDetails details) {
_tapPosition = details.globalPosition;
}
bool _openedFromKeyboard = false;
/// Show the context menu programmatically.
/// Used for keyboard/gamepad long-press activation.
/// If [position] is null, the menu will appear at the center of this widget.
void showContextMenu(BuildContext menuContext, {Offset? position}) {
_openedFromKeyboard = true;
if (position != null) {
_tapPosition = position;
} else {
// Calculate center of the widget for keyboard activation
final RenderBox? renderBox = context.findRenderObject() as RenderBox?;
if (renderBox != null) {
final size = renderBox.size;
final topLeft = renderBox.localToGlobal(Offset.zero);
_tapPosition = Offset(
topLeft.dx + size.width / 2,
topLeft.dy + size.height / 2,
);
}
}
_showContextMenu(menuContext);
}
/// Get the correct PlexClient for this item's server
PlexClient _getClientForItem() {
String? serverId;
@@ -243,51 +269,21 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
String? selected;
final openedFromKeyboard = _openedFromKeyboard;
_openedFromKeyboard = false;
if (useBottomSheet) {
// Show bottom sheet on mobile
selected = await showModalBottomSheet<String>(
context: context,
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
widget.item.title,
style: Theme.of(context).textTheme.titleMedium,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
...menuActions.map(
(action) => ListTile(
leading: Icon(action.icon),
title: Text(action.label),
onTap: () => Navigator.pop(context, action.value),
),
),
],
),
builder: (context) => _FocusableContextMenuSheet(
title: widget.item.title,
actions: menuActions,
focusFirstItem: openedFromKeyboard,
),
);
} else {
// Show popup menu on larger screens
final menuItems = menuActions
.map(
(action) => PopupMenuItem(
value: action.value,
child: Row(
children: [
Icon(action.icon),
const SizedBox(width: 12),
Expanded(child: Text(action.label)),
],
),
),
)
.toList();
// Show custom focusable popup menu on larger screens
// Use stored tap position or fallback to widget position
final RenderBox? overlay =
Overlay.of(context).context.findRenderObject() as RenderBox?;
@@ -300,27 +296,13 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
position = renderBox.localToGlobal(Offset.zero, ancestor: overlay);
}
// Calculate position for menu using RelativeRect
final overlayRect = RelativeRect.fromLTRB(
position.dx,
position.dy,
position.dx + 1,
position.dy + 1,
);
// Use showMenu with fast animations via PopupMenuTheme
selected = await showMenu<String>(
selected = await showDialog<String>(
context: context,
position: overlayRect,
items: menuItems,
elevation: 8,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
menuPadding: EdgeInsets.zero,
// Override animation duration for faster animations
popUpAnimationStyle: AnimationStyle(
duration: const Duration(milliseconds: 150),
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeIn,
barrierColor: Colors.transparent,
builder: (dialogContext) => _FocusablePopupMenu(
actions: menuActions,
position: position,
focusFirstItem: openedFromKeyboard,
),
);
}
@@ -1441,3 +1423,312 @@ class _CreateCollectionDialogState extends State<_CreateCollectionDialog> {
);
}
}
/// Focusable context menu sheet for keyboard/gamepad navigation (mobile)
class _FocusableContextMenuSheet extends StatefulWidget {
final String title;
final List<_MenuAction> actions;
final bool focusFirstItem;
const _FocusableContextMenuSheet({
required this.title,
required this.actions,
this.focusFirstItem = false,
});
@override
State<_FocusableContextMenuSheet> createState() =>
_FocusableContextMenuSheetState();
}
class _FocusableContextMenuSheetState
extends State<_FocusableContextMenuSheet> {
late List<FocusNode> _focusNodes;
int _focusedIndex = 0;
@override
void initState() {
super.initState();
_focusNodes = List.generate(
widget.actions.length,
(index) => FocusNode(debugLabel: 'ContextMenuItem$index'),
);
if (widget.focusFirstItem && widget.actions.isNotEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_focusNodes[0].requestFocus();
});
}
}
@override
void dispose() {
for (final node in _focusNodes) {
node.dispose();
}
super.dispose();
}
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
if (event is! KeyDownEvent) return KeyEventResult.ignored;
// Close on back keys
if (isBackKey(event.logicalKey)) {
Navigator.pop(context);
return KeyEventResult.handled;
}
// Navigate with arrow keys
if (event.logicalKey == LogicalKeyboardKey.arrowUp) {
if (_focusedIndex > 0) {
_focusedIndex--;
_focusNodes[_focusedIndex].requestFocus();
}
return KeyEventResult.handled;
}
if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
if (_focusedIndex < widget.actions.length - 1) {
_focusedIndex++;
_focusNodes[_focusedIndex].requestFocus();
}
return KeyEventResult.handled;
}
// Select with Enter/Space
if (event.logicalKey == LogicalKeyboardKey.enter ||
event.logicalKey == LogicalKeyboardKey.space ||
event.logicalKey == LogicalKeyboardKey.select ||
event.logicalKey == LogicalKeyboardKey.gameButtonA) {
Navigator.pop(context, widget.actions[_focusedIndex].value);
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
@override
Widget build(BuildContext context) {
return Focus(
onKeyEvent: _handleKeyEvent,
child: SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
widget.title,
style: Theme.of(context).textTheme.titleMedium,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
...widget.actions.asMap().entries.map((entry) {
final index = entry.key;
final action = entry.value;
return Focus(
focusNode: _focusNodes[index],
onFocusChange: (hasFocus) {
if (hasFocus) {
setState(() => _focusedIndex = index);
}
},
child: Builder(
builder: (context) {
final isFocused = Focus.of(context).hasFocus;
return ListTile(
leading: Icon(action.icon),
title: Text(action.label),
onTap: () => Navigator.pop(context, action.value),
selected: isFocused,
selectedTileColor: Theme.of(
context,
).colorScheme.primary.withOpacity(0.1),
);
},
),
);
}),
],
),
),
);
}
}
/// Focusable popup menu for keyboard/gamepad navigation (desktop)
class _FocusablePopupMenu extends StatefulWidget {
final List<_MenuAction> actions;
final Offset position;
final bool focusFirstItem;
const _FocusablePopupMenu({
required this.actions,
required this.position,
this.focusFirstItem = false,
});
@override
State<_FocusablePopupMenu> createState() => _FocusablePopupMenuState();
}
class _FocusablePopupMenuState extends State<_FocusablePopupMenu> {
late List<FocusNode> _focusNodes;
int _focusedIndex = 0;
@override
void initState() {
super.initState();
_focusNodes = List.generate(
widget.actions.length,
(index) => FocusNode(debugLabel: 'PopupMenuItem$index'),
);
if (widget.focusFirstItem && widget.actions.isNotEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_focusNodes[0].requestFocus();
});
}
}
@override
void dispose() {
for (final node in _focusNodes) {
node.dispose();
}
super.dispose();
}
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
if (event is! KeyDownEvent) return KeyEventResult.ignored;
// Close on back keys
if (isBackKey(event.logicalKey)) {
Navigator.pop(context);
return KeyEventResult.handled;
}
// Navigate with arrow keys
if (event.logicalKey == LogicalKeyboardKey.arrowUp) {
if (_focusedIndex > 0) {
_focusedIndex--;
_focusNodes[_focusedIndex].requestFocus();
}
return KeyEventResult.handled;
}
if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
if (_focusedIndex < widget.actions.length - 1) {
_focusedIndex++;
_focusNodes[_focusedIndex].requestFocus();
}
return KeyEventResult.handled;
}
// Select with Enter/Space
if (event.logicalKey == LogicalKeyboardKey.enter ||
event.logicalKey == LogicalKeyboardKey.space ||
event.logicalKey == LogicalKeyboardKey.select ||
event.logicalKey == LogicalKeyboardKey.gameButtonA) {
Navigator.pop(context, widget.actions[_focusedIndex].value);
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
@override
Widget build(BuildContext context) {
final screenSize = MediaQuery.of(context).size;
const menuWidth = 220.0;
// Calculate menu position, keeping it on screen
double left = widget.position.dx;
double top = widget.position.dy;
// Adjust if menu would go off right edge
if (left + menuWidth > screenSize.width) {
left = screenSize.width - menuWidth - 8;
}
// Estimate menu height and adjust if would go off bottom
final estimatedHeight = widget.actions.length * 48.0 + 16;
if (top + estimatedHeight > screenSize.height) {
top = screenSize.height - estimatedHeight - 8;
}
return Stack(
children: [
// Barrier to close menu when clicking outside
Positioned.fill(
child: GestureDetector(
onTap: () => Navigator.pop(context),
behavior: HitTestBehavior.opaque,
child: Container(color: Colors.transparent),
),
),
// Menu
Positioned(
left: left,
top: top,
child: Focus(
autofocus: !widget.focusFirstItem,
onKeyEvent: _handleKeyEvent,
child: Material(
elevation: 8,
borderRadius: BorderRadius.circular(8),
clipBehavior: Clip.antiAlias,
child: ConstrainedBox(
constraints: const BoxConstraints(
minWidth: menuWidth,
maxWidth: menuWidth,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: widget.actions.asMap().entries.map((entry) {
final index = entry.key;
final action = entry.value;
return Focus(
focusNode: _focusNodes[index],
onFocusChange: (hasFocus) {
if (hasFocus) {
setState(() => _focusedIndex = index);
}
},
child: Builder(
builder: (context) {
final isFocused = Focus.of(context).hasFocus;
return InkWell(
onTap: () => Navigator.pop(context, action.value),
child: Container(
color: isFocused
? Theme.of(
context,
).colorScheme.primary.withOpacity(0.1)
: null,
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
child: Row(
children: [
Icon(action.icon, size: 20),
const SizedBox(width: 12),
Expanded(child: Text(action.label)),
],
),
),
);
},
),
);
}).toList(),
),
),
),
),
),
],
);
}
}
+162 -83
View File
@@ -1,18 +1,23 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../client/plex_client.dart';
import '../mixins/keyboard_long_press_mixin.dart';
import '../models/plex_metadata.dart';
import '../utils/duration_formatter.dart';
import '../utils/provider_extensions.dart';
import '../i18n/strings.g.dart';
import 'focus/focus_indicator.dart';
import 'media_context_menu.dart';
/// Custom list item widget for playlist items
/// Shows drag handle, poster, title/metadata, duration, and remove button
class PlaylistItemCard extends StatelessWidget {
class PlaylistItemCard extends StatefulWidget {
final PlexMetadata item;
final int index;
final VoidCallback onRemove;
final VoidCallback? onTap;
final void Function(String ratingKey)? onRefresh;
final bool canReorder; // Whether drag handle should be shown
const PlaylistItemCard({
@@ -21,97 +26,170 @@ class PlaylistItemCard extends StatelessWidget {
required this.index,
required this.onRemove,
this.onTap,
this.onRefresh,
this.canReorder = true,
});
@override
State<PlaylistItemCard> createState() => _PlaylistItemCardState();
}
class _PlaylistItemCardState extends State<PlaylistItemCard>
with KeyboardLongPressMixin {
late final FocusNode _focusNode;
bool _isFocused = false;
final _contextMenuKey = GlobalKey<MediaContextMenuState>();
@override
void onKeyboardTap() => widget.onTap?.call();
@override
void onKeyboardLongPress() {
_contextMenuKey.currentState?.showContextMenu(context);
}
@override
void initState() {
super.initState();
_focusNode = FocusNode();
_focusNode.addListener(_handleFocusChange);
}
@override
void dispose() {
_focusNode.removeListener(_handleFocusChange);
_focusNode.dispose();
super.dispose();
}
void _handleFocusChange() {
if (_isFocused != _focusNode.hasFocus) {
setState(() {
_isFocused = _focusNode.hasFocus;
});
if (_focusNode.hasFocus) {
Scrollable.ensureVisible(
context,
alignment: 0.5,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
);
}
}
}
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
// Handle long-press detection for activation keys
return handleKeyboardLongPress(event);
}
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
children: [
// Drag handle (if reorderable)
if (canReorder)
ReorderableDragStartListener(
index: index,
child: const Padding(
padding: EdgeInsets.only(right: 12),
child: Icon(Icons.drag_indicator, color: Colors.grey),
),
),
// Poster thumbnail
_buildPosterImage(context),
const SizedBox(width: 12),
// Title and metadata
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
final item = widget.item;
return MediaContextMenu(
key: _contextMenuKey,
item: item,
onRefresh: widget.onRefresh,
onTap: widget.onTap,
child: Focus(
focusNode: _focusNode,
onKeyEvent: _handleKeyEvent,
child: FocusIndicator(
isFocused: _isFocused,
borderRadius: 12,
child: Card(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: InkWell(
onTap: widget.onTap,
focusColor: Colors.transparent,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
children: [
// Title
Text(
item.displayTitle,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
// Subtitle (episode info or type)
Text(
_buildSubtitle(),
style: TextStyle(fontSize: 13, color: Colors.grey[400]),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
// Progress indicator if partially watched
if (item.viewOffset != null && item.duration != null)
Padding(
padding: const EdgeInsets.only(top: 6),
child: LinearProgressIndicator(
value: item.viewOffset! / item.duration!,
backgroundColor: Colors.grey[800],
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.primary,
),
minHeight: 3,
// Drag handle (if reorderable)
if (widget.canReorder)
ReorderableDragStartListener(
index: widget.index,
child: const Padding(
padding: EdgeInsets.only(right: 12),
child: Icon(Icons.drag_indicator, color: Colors.grey),
),
),
// Poster thumbnail
_buildPosterImage(context),
const SizedBox(width: 12),
// Title and metadata
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Title
Text(
item.displayTitle,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
// Subtitle (episode info or type)
Text(
_buildSubtitle(),
style: TextStyle(
fontSize: 13,
color: Colors.grey[400],
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
// Progress indicator if partially watched
if (item.viewOffset != null && item.duration != null)
Padding(
padding: const EdgeInsets.only(top: 6),
child: LinearProgressIndicator(
value: item.viewOffset! / item.duration!,
backgroundColor: Colors.grey[800],
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.primary,
),
minHeight: 3,
),
),
],
),
),
const SizedBox(width: 12),
// Duration
if (item.duration != null)
Text(
formatDurationTextual(item.duration!),
style: TextStyle(fontSize: 13, color: Colors.grey[400]),
),
const SizedBox(width: 8),
// Remove button
IconButton(
icon: const Icon(Icons.close, size: 20),
onPressed: widget.onRemove,
tooltip: t.playlists.removeItem,
color: Colors.grey[400],
),
],
),
),
const SizedBox(width: 12),
// Duration
if (item.duration != null)
Text(
formatDurationTextual(item.duration!),
style: TextStyle(fontSize: 13, color: Colors.grey[400]),
),
const SizedBox(width: 8),
// Remove button
IconButton(
icon: const Icon(Icons.close, size: 20),
onPressed: onRemove,
tooltip: t.playlists.removeItem,
color: Colors.grey[400],
),
],
),
),
),
),
@@ -120,11 +198,11 @@ class PlaylistItemCard extends StatelessWidget {
/// Get the correct PlexClient for this item's server
PlexClient _getClientForItem(BuildContext context) {
return context.getClientForServer(item.serverId!);
return context.getClientForServer(widget.item.serverId!);
}
Widget _buildPosterImage(BuildContext context) {
final posterUrl = item.posterThumb();
final posterUrl = widget.item.posterThumb();
if (posterUrl != null) {
return Builder(
builder: (context) {
@@ -160,6 +238,7 @@ class PlaylistItemCard extends StatelessWidget {
}
String _buildSubtitle() {
final item = widget.item;
final itemType = item.type.toLowerCase();
if (itemType == 'episode') {
+83 -52
View File
@@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../models/plex_sort.dart';
import '../widgets/bottom_sheet_header.dart';
import '../utils/keyboard_utils.dart';
import '../i18n/strings.g.dart';
class SortBottomSheet extends StatefulWidget {
@@ -26,12 +28,23 @@ class SortBottomSheet extends StatefulWidget {
class _SortBottomSheetState extends State<SortBottomSheet> {
late PlexSort? _currentSort;
late bool _currentDescending;
final FocusNode _firstItemFocusNode = FocusNode(debugLabel: 'SortFirstItem');
@override
void initState() {
super.initState();
_currentSort = widget.selectedSort;
_currentDescending = widget.isSortDescending;
// Focus the first item after build
WidgetsBinding.instance.addPostFrameCallback((_) {
_firstItemFocusNode.requestFocus();
});
}
@override
void dispose() {
_firstItemFocusNode.dispose();
super.dispose();
}
void _handleSortChange(PlexSort sort, bool descending) {
@@ -54,46 +67,68 @@ class _SortBottomSheetState extends State<SortBottomSheet> {
@override
Widget build(BuildContext context) {
return DraggableScrollableSheet(
initialChildSize: 0.6,
minChildSize: 0.4,
maxChildSize: 0.9,
expand: false,
builder: (context, scrollController) {
return Column(
children: [
BottomSheetHeader(
title: t.libraries.sortBy,
action: widget.onClear != null
? TextButton(
onPressed: _handleClear,
child: Text(t.common.clear),
)
: null,
),
Expanded(
child: RadioGroup<PlexSort>(
groupValue: _currentSort,
onChanged: (PlexSort? value) {
if (value != null) {
_handleSortChange(value, value.isDefaultDescending);
}
},
child: ListView.builder(
controller: scrollController,
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: widget.sortOptions.length,
itemBuilder: (context, index) {
final sort = widget.sortOptions[index];
final isSelected = _currentSort?.key == sort.key;
return Focus(
autofocus: true,
onKeyEvent: (node, event) {
if (isBackKeyEvent(event)) {
Navigator.pop(context);
return KeyEventResult.handled;
}
// Left/right arrows toggle sort direction when a sort is selected
if (event is KeyDownEvent && _currentSort != null) {
if (event.logicalKey == LogicalKeyboardKey.arrowLeft &&
_currentDescending) {
_handleSortChange(_currentSort!, false);
return KeyEventResult.handled;
}
if (event.logicalKey == LogicalKeyboardKey.arrowRight &&
!_currentDescending) {
_handleSortChange(_currentSort!, true);
return KeyEventResult.handled;
}
}
return KeyEventResult.ignored;
},
child: DraggableScrollableSheet(
initialChildSize: 0.6,
minChildSize: 0.4,
maxChildSize: 0.9,
expand: false,
builder: (context, scrollController) {
return Column(
children: [
BottomSheetHeader(
title: t.libraries.sortBy,
action: widget.onClear != null
? TextButton(
onPressed: _handleClear,
child: Text(t.common.clear),
)
: null,
),
Expanded(
child: FocusTraversalGroup(
child: ListView.builder(
controller: scrollController,
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: widget.sortOptions.length,
itemBuilder: (context, index) {
final sort = widget.sortOptions[index];
final isSelected = _currentSort?.key == sort.key;
return ListTile(
title: Text(sort.title),
trailing: isSelected
? Row(
mainAxisSize: MainAxisSize.min,
children: [
SegmentedButton<bool>(
return RadioListTile<PlexSort>(
focusNode: index == 0 ? _firstItemFocusNode : null,
title: Text(sort.title),
value: sort,
groupValue: _currentSort,
onChanged: (value) {
if (value != null) {
_handleSortChange(value, value.isDefaultDescending);
}
},
secondary: isSelected
? ExcludeFocus(
child: SegmentedButton<bool>(
showSelectedIcon: false,
segments: const [
ButtonSegment(
@@ -113,21 +148,17 @@ class _SortBottomSheetState extends State<SortBottomSheet> {
_handleSortChange(sort, newSelection.first);
},
),
],
)
: null,
leading: Radio<PlexSort>(value: sort, toggleable: false),
onTap: () {
_handleSortChange(sort, sort.isDefaultDescending);
},
);
},
)
: null,
);
},
),
),
),
),
],
);
},
],
);
},
),
);
}
}
@@ -584,6 +584,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
_nextSubtitleTrack,
_nextChapter,
_previousChapter,
onBack: () => Navigator.of(context).pop(true),
);
},
child: MouseRegion(