refactor: remove keyboard navigation
This commit is contained in:
@@ -1,102 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../utils/keyboard_utils.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();
|
||||
|
||||
/// 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 (!isKeyboardActivationKey(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();
|
||||
}
|
||||
}
|
||||
+304
-426
@@ -1,6 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import '../../services/plex_client.dart';
|
||||
@@ -14,7 +13,6 @@ import '../providers/playback_state_provider.dart';
|
||||
import '../widgets/desktop_app_bar.dart';
|
||||
import 'profile/user_avatar_widget.dart';
|
||||
import '../widgets/hub_section.dart';
|
||||
import '../widgets/hub_navigation_controller.dart';
|
||||
import 'profile/profile_switch_screen.dart';
|
||||
import '../providers/user_profile_provider.dart';
|
||||
import '../providers/settings_provider.dart';
|
||||
@@ -22,12 +20,10 @@ 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;
|
||||
@@ -59,7 +55,6 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
List<PlexMetadata> _onDeck = [];
|
||||
List<PlexHub> _hubs = [];
|
||||
bool _isLoading = true;
|
||||
bool _isInitialLoad = true;
|
||||
bool _areHubsLoading = true;
|
||||
String? _errorMessage;
|
||||
final PageController _heroController = PageController();
|
||||
@@ -68,10 +63,6 @@ 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) {
|
||||
@@ -99,89 +90,16 @@ 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 (isKeyboardActivationKey(event.logicalKey)) {
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -307,16 +225,6 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
_currentHeroIndex = 0;
|
||||
});
|
||||
|
||||
// Focus the hero on initial load
|
||||
if (_isInitialLoad && onDeck.isNotEmpty) {
|
||||
_isInitialLoad = false;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
_heroFocusNode.requestFocus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Sync PageController to first page after OnDeck loads
|
||||
if (_heroController.hasClients && onDeck.isNotEmpty) {
|
||||
_heroController.jumpToPage(0);
|
||||
@@ -417,13 +325,6 @@ 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();
|
||||
@@ -604,360 +505,337 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
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,
|
||||
),
|
||||
if (_isLoading)
|
||||
const SliverFillRemaining(
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
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 (_errorMessage != null)
|
||||
SliverFillRemaining(
|
||||
child: Center(
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
|
||||
// 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,
|
||||
),
|
||||
),
|
||||
|
||||
// Show loading skeleton for hubs while they're loading
|
||||
if (_areHubsLoading && _hubs.isEmpty)
|
||||
for (int i = 0; i < 3; i++)
|
||||
SliverToBoxAdapter(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.error_outline,
|
||||
size: 48,
|
||||
color: Colors.red,
|
||||
// Hub title skeleton
|
||||
Container(
|
||||
width: 200,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(_errorMessage!),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _loadContent,
|
||||
child: Text(t.common.retry),
|
||||
// Hub items skeleton
|
||||
SizedBox(
|
||||
height: 200,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: 5,
|
||||
itemBuilder: (context, index) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(right: 12),
|
||||
width: 140,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
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());
|
||||
},
|
||||
|
||||
if (_onDeck.isEmpty && _hubs.isEmpty && !_areHubsLoading)
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 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
|
||||
),
|
||||
),
|
||||
|
||||
// Show loading skeleton for hubs while they're loading
|
||||
if (_areHubsLoading && _hubs.isEmpty)
|
||||
for (int i = 0; i < 3; i++)
|
||||
SliverToBoxAdapter(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Hub title skeleton
|
||||
Container(
|
||||
width: 200,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Hub items skeleton
|
||||
SizedBox(
|
||||
height: 200,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: 5,
|
||||
itemBuilder: (context, index) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(right: 12),
|
||||
width: 140,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (_onDeck.isEmpty && _hubs.isEmpty && !_areHubsLoading)
|
||||
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)),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 24)),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeroSection() {
|
||||
// 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
|
||||
),
|
||||
);
|
||||
|
||||
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',
|
||||
),
|
||||
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,
|
||||
);
|
||||
),
|
||||
// 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,
|
||||
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,
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
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),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}(),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -22,16 +22,12 @@ 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
|
||||
@@ -39,45 +35,39 @@ class AdaptiveMediaGrid extends StatelessWidget {
|
||||
return Consumer<SettingsProvider>(
|
||||
builder: (context, settingsProvider, child) {
|
||||
if (settingsProvider.viewMode == ViewMode.list) {
|
||||
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,
|
||||
);
|
||||
},
|
||||
),
|
||||
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,
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
return FocusTraversalGroup(
|
||||
child: GridView.builder(
|
||||
padding: padding,
|
||||
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent(
|
||||
context,
|
||||
settingsProvider.libraryDensity,
|
||||
),
|
||||
childAspectRatio: childAspectRatio,
|
||||
crossAxisSpacing: 0,
|
||||
mainAxisSpacing: 0,
|
||||
return GridView.builder(
|
||||
padding: padding,
|
||||
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent(
|
||||
context,
|
||||
settingsProvider.libraryDensity,
|
||||
),
|
||||
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,
|
||||
);
|
||||
},
|
||||
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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -3,7 +3,6 @@ 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 {
|
||||
@@ -31,29 +30,12 @@ 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() {
|
||||
@@ -87,10 +69,6 @@ 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 = [];
|
||||
@@ -111,21 +89,6 @@ 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('?');
|
||||
@@ -140,181 +103,174 @@ class _FiltersBottomSheetState extends State<FiltersBottomSheet> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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,
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
|
||||
// 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,
|
||||
// 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,
|
||||
);
|
||||
final isSelected =
|
||||
_tempSelectedFilters[_currentFilter!.filter] ==
|
||||
filterValue;
|
||||
|
||||
return ListTile(
|
||||
title: Text(value.title),
|
||||
title: Text(t.libraries.all),
|
||||
selected: isSelected,
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_tempSelectedFilters[_currentFilter!.filter] =
|
||||
filterValue;
|
||||
// Cache the display name for this filter value
|
||||
_filterDisplayNames['${_currentFilter!.filter}:$filterValue'] =
|
||||
value.title;
|
||||
_tempSelectedFilters.remove(
|
||||
_currentFilter!.filter,
|
||||
);
|
||||
});
|
||||
_applyFilters();
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 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(
|
||||
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),
|
||||
final value = _filterValues[index - 1];
|
||||
final filterValue = _extractFilterValue(
|
||||
value.key,
|
||||
_currentFilter!.filter,
|
||||
);
|
||||
}
|
||||
final isSelected =
|
||||
_tempSelectedFilters[_currentFilter!.filter] ==
|
||||
filterValue;
|
||||
|
||||
// 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),
|
||||
);
|
||||
},
|
||||
return ListTile(
|
||||
title: Text(value.title),
|
||||
selected: isSelected,
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_tempSelectedFilters[_currentFilter!.filter] =
|
||||
filterValue;
|
||||
// Cache the display name for this filter value
|
||||
_filterDisplayNames['${_currentFilter!.filter}:$filterValue'] =
|
||||
value.title;
|
||||
});
|
||||
_applyFilters();
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
}
|
||||
|
||||
// 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),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../../models/plex_metadata.dart';
|
||||
import '../../utils/keyboard_utils.dart';
|
||||
|
||||
/// Individual item in the folder tree
|
||||
/// Can be either a folder (expandable) or a file (tappable)
|
||||
class FolderTreeItem extends StatefulWidget {
|
||||
class FolderTreeItem extends StatelessWidget {
|
||||
final PlexMetadata item;
|
||||
final int depth;
|
||||
final bool isExpanded;
|
||||
final bool isFolder;
|
||||
final void Function({bool isKeyboard})? onTap;
|
||||
final VoidCallback? onTap;
|
||||
final VoidCallback? onExpand;
|
||||
final bool isLoading;
|
||||
|
||||
@@ -25,20 +23,13 @@ class FolderTreeItem extends StatefulWidget {
|
||||
this.isLoading = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<FolderTreeItem> createState() => _FolderTreeItemState();
|
||||
}
|
||||
|
||||
class _FolderTreeItemState extends State<FolderTreeItem> {
|
||||
bool _isKeyboardActivation = false;
|
||||
|
||||
IconData _getIcon() {
|
||||
if (widget.isFolder) {
|
||||
if (isFolder) {
|
||||
return Icons.folder;
|
||||
}
|
||||
|
||||
// File icons based on type
|
||||
final type = widget.item.type.toLowerCase();
|
||||
final type = item.type.toLowerCase();
|
||||
switch (type) {
|
||||
case 'movie':
|
||||
return Icons.movie;
|
||||
@@ -55,103 +46,89 @@ class _FolderTreeItemState extends State<FolderTreeItem> {
|
||||
}
|
||||
}
|
||||
|
||||
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
|
||||
if (event is KeyDownEvent && isKeyboardActivationKey(event.logicalKey)) {
|
||||
_isKeyboardActivation = true;
|
||||
return KeyEventResult.ignored; // Let InkWell handle the activation
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
void _handleTap() {
|
||||
if (widget.isFolder) {
|
||||
widget.onExpand?.call();
|
||||
if (isFolder) {
|
||||
onExpand?.call();
|
||||
} else {
|
||||
widget.onTap?.call(isKeyboard: _isKeyboardActivation);
|
||||
onTap?.call();
|
||||
}
|
||||
_isKeyboardActivation = false;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final indentation = widget.depth * 24.0;
|
||||
final indentation = depth * 24.0;
|
||||
|
||||
return Focus(
|
||||
onKeyEvent: _handleKeyEvent,
|
||||
child: InkWell(
|
||||
onTap: _handleTap,
|
||||
child: Container(
|
||||
padding: EdgeInsets.only(
|
||||
left: 16.0 + indentation,
|
||||
right: 16.0,
|
||||
top: 12.0,
|
||||
bottom: 12.0,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Expand/collapse icon for folders
|
||||
if (widget.isFolder)
|
||||
SizedBox(
|
||||
width: 24,
|
||||
child: widget.isLoading
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Icon(
|
||||
widget.isExpanded
|
||||
? Icons.keyboard_arrow_down
|
||||
: Icons.keyboard_arrow_right,
|
||||
size: 20,
|
||||
),
|
||||
)
|
||||
else
|
||||
const SizedBox(width: 24),
|
||||
return InkWell(
|
||||
onTap: _handleTap,
|
||||
child: Container(
|
||||
padding: EdgeInsets.only(
|
||||
left: 16.0 + indentation,
|
||||
right: 16.0,
|
||||
top: 12.0,
|
||||
bottom: 12.0,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Expand/collapse icon for folders
|
||||
if (isFolder)
|
||||
SizedBox(
|
||||
width: 24,
|
||||
child: isLoading
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Icon(
|
||||
isExpanded
|
||||
? Icons.keyboard_arrow_down
|
||||
: Icons.keyboard_arrow_right,
|
||||
size: 20,
|
||||
),
|
||||
)
|
||||
else
|
||||
const SizedBox(width: 24),
|
||||
|
||||
const SizedBox(width: 8),
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// File/folder icon
|
||||
Icon(
|
||||
_getIcon(),
|
||||
size: 20,
|
||||
color: widget.isFolder
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.7),
|
||||
),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Item title
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.item.title,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: widget.isFolder
|
||||
? FontWeight.w500
|
||||
: FontWeight.w400,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
|
||||
// Additional metadata for files
|
||||
if (!widget.isFolder && widget.item.year != null)
|
||||
Text(
|
||||
widget.item.year.toString(),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.of(
|
||||
// File/folder icon
|
||||
Icon(
|
||||
_getIcon(),
|
||||
size: 20,
|
||||
color: isFolder
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
).colorScheme.onSurface.withValues(alpha: 0.7),
|
||||
),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Item title
|
||||
Expanded(
|
||||
child: Text(
|
||||
item.title,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: isFolder ? FontWeight.w500 : FontWeight.w400,
|
||||
),
|
||||
],
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
|
||||
// Additional metadata for files
|
||||
if (!isFolder && item.year != null)
|
||||
Text(
|
||||
item.year.toString(),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -150,10 +150,7 @@ class _FolderTreeViewState extends State<FolderTreeView> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleItemTap(
|
||||
PlexMetadata item, {
|
||||
bool isKeyboard = false,
|
||||
}) async {
|
||||
Future<void> _handleItemTap(PlexMetadata item) async {
|
||||
final itemType = item.type.toLowerCase();
|
||||
|
||||
// For episodes, start playback directly
|
||||
@@ -166,8 +163,7 @@ class _FolderTreeViewState extends State<FolderTreeView> {
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) =>
|
||||
SeasonDetailScreen(season: item, focusFirstEpisode: isKeyboard),
|
||||
builder: (context) => SeasonDetailScreen(season: item),
|
||||
),
|
||||
);
|
||||
widget.onRefresh?.call(item.ratingKey);
|
||||
@@ -219,10 +215,7 @@ class _FolderTreeViewState extends State<FolderTreeView> {
|
||||
isExpanded: isExpanded,
|
||||
isLoading: isLoading,
|
||||
onExpand: isFolder ? () => _toggleFolder(item) : null,
|
||||
onTap: !isFolder
|
||||
? ({bool isKeyboard = false}) =>
|
||||
_handleItemTap(item, isKeyboard: isKeyboard)
|
||||
: null,
|
||||
onTap: !isFolder ? () => _handleItemTap(item) : null,
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,6 @@
|
||||
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 {
|
||||
@@ -28,23 +26,12 @@ 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) {
|
||||
@@ -67,98 +54,70 @@ class _SortBottomSheetState extends State<SortBottomSheet> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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 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: 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 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(
|
||||
value: false,
|
||||
icon: Icon(Icons.arrow_upward, size: 16),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: true,
|
||||
icon: Icon(
|
||||
Icons.arrow_downward,
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
],
|
||||
selected: {_currentDescending},
|
||||
onSelectionChanged: (Set<bool> newSelection) {
|
||||
_handleSortChange(sort, newSelection.first);
|
||||
},
|
||||
),
|
||||
)
|
||||
: null,
|
||||
);
|
||||
return RadioListTile<PlexSort>(
|
||||
title: Text(sort.title),
|
||||
value: sort,
|
||||
groupValue: _currentSort,
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
_handleSortChange(value, value.isDefaultDescending);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
secondary: isSelected
|
||||
? SegmentedButton<bool>(
|
||||
showSelectedIcon: false,
|
||||
segments: const [
|
||||
ButtonSegment(
|
||||
value: false,
|
||||
icon: Icon(Icons.arrow_upward, size: 16),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: true,
|
||||
icon: Icon(
|
||||
Icons.arrow_downward,
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
],
|
||||
selected: {_currentDescending},
|
||||
onSelectionChanged: (Set<bool> newSelection) {
|
||||
_handleSortChange(sort, newSelection.first);
|
||||
},
|
||||
)
|
||||
: null,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,19 +139,6 @@ 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
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import '../../../../services/plex_client.dart';
|
||||
@@ -10,7 +9,6 @@ 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 '../folder_tree_view.dart';
|
||||
import '../filters_bottom_sheet.dart';
|
||||
@@ -89,11 +87,6 @@ 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();
|
||||
@@ -112,17 +105,9 @@ 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();
|
||||
@@ -328,48 +313,35 @@ class _LibraryBrowseTabState extends State<LibraryBrowseTab>
|
||||
context: context,
|
||||
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;
|
||||
});
|
||||
return ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: options.length,
|
||||
itemBuilder: (context, index) {
|
||||
final grouping = options[index];
|
||||
return RadioListTile<String>(
|
||||
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 (!sheetContext.mounted) return;
|
||||
if (!sheetContext.mounted) return;
|
||||
|
||||
Navigator.pop(sheetContext);
|
||||
_loadItems();
|
||||
}
|
||||
},
|
||||
);
|
||||
Navigator.pop(sheetContext);
|
||||
_loadItems();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -435,52 +407,30 @@ class _LibraryBrowseTabState extends State<LibraryBrowseTab>
|
||||
required String label,
|
||||
required VoidCallback onPressed,
|
||||
}) {
|
||||
return Focus(
|
||||
onKeyEvent: (node, event) {
|
||||
if (event is KeyDownEvent) {
|
||||
if (isKeyboardActivationKey(event.logicalKey)) {
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return InkWell(
|
||||
onTap: onPressed,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 16, color: colorScheme.onSurfaceVariant),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
label,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.labelMedium
|
||||
?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
);
|
||||
},
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -627,7 +577,6 @@ class _LibraryBrowseTabState extends State<LibraryBrowseTab>
|
||||
key: Key(item.ratingKey),
|
||||
item: item,
|
||||
onRefresh: updateItem,
|
||||
focusNode: index == 0 ? _firstItemFocusNode : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,17 +21,6 @@ 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;
|
||||
|
||||
@@ -54,19 +43,11 @@ 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,
|
||||
firstItemFocusNode: _firstItemFocusNode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,17 +25,6 @@ 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;
|
||||
|
||||
@@ -60,13 +49,6 @@ class _LibraryPlaylistsTabState
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void focusFirstItem() {
|
||||
if (items.isNotEmpty) {
|
||||
_firstItemFocusNode.requestFocus();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget buildContent(List<PlexPlaylist> items) {
|
||||
return Consumer<SettingsProvider>(
|
||||
@@ -104,7 +86,6 @@ class _LibraryPlaylistsTabState
|
||||
key: Key(playlist.ratingKey),
|
||||
item: playlist,
|
||||
onListRefresh: loadItems,
|
||||
focusNode: index == 0 ? _firstItemFocusNode : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import '../../../i18n/strings.g.dart';
|
||||
import '../../../mixins/item_updatable.dart';
|
||||
import '../../../models/plex_hub.dart';
|
||||
import '../../../models/plex_metadata.dart';
|
||||
import '../../../widgets/hub_navigation_controller.dart';
|
||||
import '../../../widgets/hub_section.dart';
|
||||
import 'base_library_tab.dart';
|
||||
|
||||
@@ -21,21 +20,6 @@ class LibraryRecommendedTab extends BaseLibraryTab<PlexHub> {
|
||||
class _LibraryRecommendedTabState
|
||||
extends BaseLibraryTabState<PlexHub, LibraryRecommendedTab>
|
||||
with ItemUpdatable {
|
||||
final HubNavigationController _hubNavigationController =
|
||||
HubNavigationController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_hubNavigationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Focus the first item in the first hub
|
||||
@override
|
||||
void focusFirstItem() {
|
||||
_hubNavigationController.focusHub(0, 0);
|
||||
}
|
||||
|
||||
@override
|
||||
PlexClient get client => getClientForLibrary();
|
||||
|
||||
@@ -111,28 +95,24 @@ class _LibraryRecommendedTabState
|
||||
|
||||
@override
|
||||
Widget buildContent(List<PlexHub> items) {
|
||||
return HubNavigationScope(
|
||||
controller: _hubNavigationController,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final hub = items[index];
|
||||
final isContinueWatching =
|
||||
hub.hubIdentifier == '_library_continue_watching_';
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final hub = items[index];
|
||||
final isContinueWatching =
|
||||
hub.hubIdentifier == '_library_continue_watching_';
|
||||
|
||||
return HubSection(
|
||||
hub: hub,
|
||||
icon: _getHubIcon(hub),
|
||||
navigationOrder: index,
|
||||
isInContinueWatching: isContinueWatching,
|
||||
onRefresh: updateItem,
|
||||
onRemoveFromContinueWatching: isContinueWatching
|
||||
? _refreshContinueWatching
|
||||
: null,
|
||||
);
|
||||
},
|
||||
),
|
||||
return HubSection(
|
||||
hub: hub,
|
||||
icon: _getHubIcon(hub),
|
||||
isInContinueWatching: isContinueWatching,
|
||||
onRefresh: updateItem,
|
||||
onRemoveFromContinueWatching: isContinueWatching
|
||||
? _refreshContinueWatching
|
||||
: null,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+25
-147
@@ -1,10 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../services/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';
|
||||
@@ -19,30 +17,6 @@ import 'libraries/libraries_screen.dart';
|
||||
import 'search_screen.dart';
|
||||
import 'settings/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;
|
||||
|
||||
@@ -61,18 +35,9 @@ 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(
|
||||
@@ -104,38 +69,9 @@ 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)
|
||||
@@ -215,110 +151,52 @@ 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)
|
||||
// Focus search input when selecting Search tab
|
||||
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 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;
|
||||
},
|
||||
return Scaffold(
|
||||
body: IndexedStack(index: _currentIndex, children: _screens),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: _currentIndex,
|
||||
onDestinationSelected: _selectTab,
|
||||
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,11 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../services/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 '../../widgets/focus/focus_indicator.dart';
|
||||
import '../../widgets/media_context_menu.dart';
|
||||
import '../../widgets/plex_optimized_image.dart';
|
||||
|
||||
@@ -33,18 +31,9 @@ class PlaylistItemCard extends StatefulWidget {
|
||||
State<PlaylistItemCard> createState() => _PlaylistItemCardState();
|
||||
}
|
||||
|
||||
class _PlaylistItemCardState extends State<PlaylistItemCard>
|
||||
with KeyboardLongPressMixin {
|
||||
class _PlaylistItemCardState extends State<PlaylistItemCard> {
|
||||
final _contextMenuKey = GlobalKey<MediaContextMenuState>();
|
||||
|
||||
@override
|
||||
void onKeyboardTap() => widget.onTap?.call();
|
||||
|
||||
@override
|
||||
void onKeyboardLongPress() {
|
||||
_contextMenuKey.currentState?.showContextMenu(context);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final item = widget.item;
|
||||
@@ -53,103 +42,92 @@ class _PlaylistItemCardState extends State<PlaylistItemCard>
|
||||
item: item,
|
||||
onRefresh: widget.onRefresh,
|
||||
onTap: widget.onTap,
|
||||
child: FocusableWrapper(
|
||||
onKeyEvent: (node, event) => handleKeyboardLongPress(event),
|
||||
builder: (context, isFocused) => 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: [
|
||||
// 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: InkWell(
|
||||
onTap: widget.onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
// 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),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
// Poster thumbnail
|
||||
_buildPosterImage(context),
|
||||
|
||||
// Duration
|
||||
if (item.duration != null)
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Title and metadata
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Title
|
||||
Text(
|
||||
formatDurationTextual(item.duration!),
|
||||
style: TextStyle(fontSize: 13, color: Colors.grey[400]),
|
||||
item.displayTitle,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
|
||||
const SizedBox(width: 8),
|
||||
const SizedBox(height: 4),
|
||||
|
||||
// Remove button
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, size: 20),
|
||||
onPressed: widget.onRemove,
|
||||
tooltip: t.playlists.removeItem,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
],
|
||||
// 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],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
+129
-164
@@ -1,10 +1,8 @@
|
||||
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';
|
||||
@@ -12,7 +10,6 @@ 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';
|
||||
|
||||
@@ -160,178 +157,146 @@ class _SearchScreenState extends State<SearchScreen> with Refreshable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle back key press - focus bottom navigation
|
||||
KeyEventResult _handleBackKey(FocusNode node, KeyEvent event) {
|
||||
if (isBackKeyEvent(event)) {
|
||||
// Allow backspace to work normally in search field when it has content
|
||||
if (event.logicalKey == LogicalKeyboardKey.backspace &&
|
||||
_searchFocusNode.hasFocus &&
|
||||
_searchController.text.isNotEmpty) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
BackNavigationScope.of(context)?.focusBottomNav();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
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,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
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,
|
||||
),
|
||||
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,
|
||||
),
|
||||
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,
|
||||
),
|
||||
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.enterTitleActorOrKeyword,
|
||||
style: TextStyle(color: Colors.grey.shade600),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
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: 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,
|
||||
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),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
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, 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,31 +1,20 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../services/plex_client.dart';
|
||||
import '../widgets/plex_optimized_image.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';
|
||||
|
||||
class SeasonDetailScreen extends StatefulWidget {
|
||||
final PlexMetadata season;
|
||||
|
||||
/// Whether to focus the first episode after loading.
|
||||
/// Should be true when navigating via keyboard, false for mouse/tap.
|
||||
final bool focusFirstEpisode;
|
||||
|
||||
const SeasonDetailScreen({
|
||||
super.key,
|
||||
required this.season,
|
||||
this.focusFirstEpisode = false,
|
||||
});
|
||||
const SeasonDetailScreen({super.key, required this.season});
|
||||
|
||||
@override
|
||||
State<SeasonDetailScreen> createState() => _SeasonDetailScreenState();
|
||||
@@ -41,9 +30,6 @@ 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) {
|
||||
@@ -60,12 +46,6 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen>
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_firstEpisodeFocusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadEpisodes() async {
|
||||
setState(() {
|
||||
_isLoadingEpisodes = true;
|
||||
@@ -79,13 +59,6 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen>
|
||||
_episodes = episodes;
|
||||
_isLoadingEpisodes = false;
|
||||
});
|
||||
|
||||
// Focus the first episode after loading (only if keyboard navigation)
|
||||
if (episodes.isNotEmpty && widget.focusFirstEpisode) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_firstEpisodeFocusNode.requestFocus();
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_isLoadingEpisodes = false;
|
||||
@@ -110,145 +83,77 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen>
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
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,
|
||||
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(
|
||||
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 _EpisodeCard(
|
||||
episode: episode,
|
||||
client: _client,
|
||||
onTap: () async {
|
||||
await navigateToVideoPlayer(context, metadata: episode);
|
||||
// Refresh episodes when returning from video player
|
||||
_loadEpisodes();
|
||||
},
|
||||
onRefresh: updateItem,
|
||||
);
|
||||
}, childCount: _episodes.length),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Focusable episode card widget
|
||||
class _EpisodeCard extends StatefulWidget {
|
||||
/// Episode card widget
|
||||
class _EpisodeCard extends StatelessWidget {
|
||||
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 &&
|
||||
@@ -258,239 +163,217 @@ class _EpisodeCardState extends State<_EpisodeCard>
|
||||
: 0.0;
|
||||
|
||||
return MediaContextMenu(
|
||||
key: _contextMenuKey,
|
||||
item: episode,
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
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
|
||||
? PlexThumbImage(
|
||||
client: widget.client,
|
||||
imagePath: 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
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(
|
||||
onRefresh: onRefresh,
|
||||
onTap: onTap,
|
||||
child: InkWell(
|
||||
key: Key(episode.ratingKey),
|
||||
onTap: onTap,
|
||||
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
|
||||
? PlexThumbImage(
|
||||
client: client,
|
||||
imagePath: episode.thumb,
|
||||
filterQuality: FilterQuality.medium,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) => Container(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
child: Text(
|
||||
'E${episode.index}',
|
||||
style: TextStyle(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onPrimaryContainer,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
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),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
episode.title,
|
||||
style: Theme.of(context).textTheme.titleSmall
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
|
||||
// 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
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),
|
||||
// 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(
|
||||
episode.summary!,
|
||||
formatDurationTimestamp(
|
||||
Duration(milliseconds: episode.duration!),
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color: tokens(context).textMuted,
|
||||
height: 1.3,
|
||||
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,
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -4,13 +4,11 @@ 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 'hotkey_recorder_widget.dart';
|
||||
import 'about_screen.dart';
|
||||
@@ -69,15 +67,6 @@ 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) {
|
||||
@@ -85,34 +74,31 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
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(),
|
||||
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(),
|
||||
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),
|
||||
]),
|
||||
),
|
||||
],
|
||||
_buildAboutSection(),
|
||||
const SizedBox(height: 24),
|
||||
]),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import 'package:flutter/services.dart';
|
||||
import 'package:hotkey_manager/hotkey_manager.dart';
|
||||
import '../mpv/mpv.dart';
|
||||
import 'settings_service.dart';
|
||||
import '../utils/keyboard_utils.dart';
|
||||
import '../utils/player_utils.dart';
|
||||
|
||||
class KeyboardShortcutsService {
|
||||
@@ -159,8 +158,8 @@ class KeyboardShortcutsService {
|
||||
}) {
|
||||
if (event is! KeyDownEvent) return KeyEventResult.ignored;
|
||||
|
||||
// Handle back navigation keys first
|
||||
if (isBackKey(event.logicalKey)) {
|
||||
// Handle back navigation keys (Escape)
|
||||
if (event.logicalKey == LogicalKeyboardKey.escape) {
|
||||
onBack?.call();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
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);
|
||||
}
|
||||
|
||||
/// Check if the given key should activate/select an item
|
||||
bool isKeyboardActivationKey(LogicalKeyboardKey key) {
|
||||
return key == LogicalKeyboardKey.enter ||
|
||||
key == LogicalKeyboardKey.space ||
|
||||
key == LogicalKeyboardKey.select ||
|
||||
key == LogicalKeyboardKey.gameButtonA;
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
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 KeyEventResult Function(FocusNode, KeyEvent)? onKeyEvent;
|
||||
final void Function(BuildContext context)? onScrollIntoView;
|
||||
final String? debugLabel;
|
||||
|
||||
const FocusableWrapper({
|
||||
super.key,
|
||||
required this.builder,
|
||||
this.focusNode,
|
||||
this.autofocus = false,
|
||||
this.onFocused,
|
||||
this.onKeyEvent,
|
||||
this.onScrollIntoView,
|
||||
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();
|
||||
// Use custom scroll behavior if provided, otherwise default
|
||||
if (widget.onScrollIntoView != null) {
|
||||
widget.onScrollIntoView!(context);
|
||||
} else {
|
||||
Scrollable.ensureVisible(
|
||||
context,
|
||||
alignment: 0.5,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
|
||||
return widget.onKeyEvent?.call(node, event) ?? KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Focus(
|
||||
focusNode: _focusNode,
|
||||
autofocus: widget.autofocus,
|
||||
onKeyEvent: _handleKeyEvent,
|
||||
child: widget.builder(context, _isFocused),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
+99
-266
@@ -1,26 +1,19 @@
|
||||
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';
|
||||
import '../utils/keyboard_utils.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 StatefulWidget {
|
||||
class HubSection extends StatelessWidget {
|
||||
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,
|
||||
@@ -28,284 +21,124 @@ class HubSection extends StatefulWidget {
|
||||
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() {
|
||||
void _navigateToHubDetail(BuildContext context) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => HubDetailScreen(hub: widget.hub)),
|
||||
MaterialPageRoute(builder: (context) => HubDetailScreen(hub: hub)),
|
||||
);
|
||||
}
|
||||
|
||||
KeyEventResult _handleHeaderKeyEvent(FocusNode node, KeyEvent event) {
|
||||
if (event is KeyDownEvent && widget.hub.more) {
|
||||
if (isKeyboardActivationKey(event.logicalKey)) {
|
||||
_navigateToHubDetail();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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),
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.hub.title,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
),
|
||||
if (widget.hub.more) ...[
|
||||
const SizedBox(width: 4),
|
||||
const Icon(Icons.chevron_right, size: 20),
|
||||
],
|
||||
],
|
||||
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 ? () => _navigateToHubDetail(context) : null,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
hub.title,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (hub.more) ...[
|
||||
const SizedBox(width: 4),
|
||||
const Icon(Icons.chevron_right, size: 20),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 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;
|
||||
// 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;
|
||||
|
||||
// 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;
|
||||
// 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;
|
||||
|
||||
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,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
return SizedBox(
|
||||
height: containerHeight,
|
||||
child: HorizontalScrollWithArrows(
|
||||
builder: (scrollController) => ListView.builder(
|
||||
controller: scrollController,
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 5,
|
||||
),
|
||||
itemCount: hub.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,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+242
-512
@@ -1,10 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'focus/focus_indicator.dart';
|
||||
import 'hub_navigation_controller.dart';
|
||||
import '../../services/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';
|
||||
@@ -36,15 +32,6 @@ 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,
|
||||
@@ -56,9 +43,6 @@ class MediaCard extends StatefulWidget {
|
||||
this.forceGridMode = false,
|
||||
this.isInContinueWatching = false,
|
||||
this.collectionId,
|
||||
this.focusNode,
|
||||
this.hubId,
|
||||
this.itemIndex,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -116,7 +100,7 @@ class _MediaCardState extends State<MediaCard> {
|
||||
return baseLabel;
|
||||
}
|
||||
|
||||
void _handleTap(BuildContext context, {bool isKeyboard = false}) async {
|
||||
void _handleTap(BuildContext context) async {
|
||||
// Handle playlists
|
||||
if (widget.item is PlexPlaylist) {
|
||||
await Navigator.push(
|
||||
@@ -177,7 +161,6 @@ class _MediaCardState extends State<MediaCard> {
|
||||
MaterialPageRoute(
|
||||
builder: (context) => SeasonDetailScreen(
|
||||
season: widget.item,
|
||||
focusFirstEpisode: isKeyboard,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -213,18 +196,13 @@ class _MediaCardState extends State<MediaCard> {
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
semanticLabel: semanticLabel,
|
||||
onTap: ({bool isKeyboard = false}) =>
|
||||
_handleTap(context, isKeyboard: isKeyboard),
|
||||
onTap: () => _handleTap(context),
|
||||
onLongPress: _showContextMenu,
|
||||
focusNode: widget.focusNode,
|
||||
hubId: widget.hubId,
|
||||
itemIndex: widget.itemIndex,
|
||||
)
|
||||
: _MediaCardList(
|
||||
item: widget.item,
|
||||
semanticLabel: semanticLabel,
|
||||
onTap: ({bool isKeyboard = false}) =>
|
||||
_handleTap(context, isKeyboard: isKeyboard),
|
||||
onTap: () => _handleTap(context),
|
||||
onLongPress: _showContextMenu,
|
||||
density: settingsProvider.libraryDensity,
|
||||
);
|
||||
@@ -245,23 +223,14 @@ class _MediaCardState extends State<MediaCard> {
|
||||
}
|
||||
|
||||
/// Grid layout for media cards
|
||||
class _MediaCardGrid extends StatefulWidget {
|
||||
class _MediaCardGrid extends StatelessWidget {
|
||||
final dynamic item; // Can be PlexMetadata or PlexPlaylist
|
||||
final double? width;
|
||||
final double? height;
|
||||
final String semanticLabel;
|
||||
final void Function({bool isKeyboard}) onTap;
|
||||
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,
|
||||
this.width,
|
||||
@@ -269,308 +238,141 @@ class _MediaCardGrid extends StatefulWidget {
|
||||
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(isKeyboard: true);
|
||||
|
||||
@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 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(isKeyboard: false),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
focusColor: Colors.transparent, // We use our own focus indicator
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Column(
|
||||
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(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
// Poster
|
||||
if (widget.height != null)
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: widget.height,
|
||||
child: _buildPosterWithOverlay(context),
|
||||
)
|
||||
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!,
|
||||
),
|
||||
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;
|
||||
|
||||
// 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();
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
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();
|
||||
},
|
||||
)
|
||||
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 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();
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -583,19 +385,19 @@ class _MediaCardGridState extends State<_MediaCardGrid>
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: _buildPosterImage(context, widget.item),
|
||||
child: _buildPosterImage(context, item),
|
||||
),
|
||||
_PosterOverlay(item: widget.item),
|
||||
_PosterOverlay(item: item),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// List layout for media cards
|
||||
class _MediaCardList extends StatefulWidget {
|
||||
class _MediaCardList extends StatelessWidget {
|
||||
final dynamic item; // Can be PlexMetadata or PlexPlaylist
|
||||
final String semanticLabel;
|
||||
final void Function({bool isKeyboard}) onTap;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback onLongPress;
|
||||
final LibraryDensity density;
|
||||
|
||||
@@ -607,71 +409,8 @@ class _MediaCardList extends StatefulWidget {
|
||||
required this.density,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_MediaCardList> createState() => _MediaCardListState();
|
||||
}
|
||||
|
||||
class _MediaCardListState extends State<_MediaCardList>
|
||||
with KeyboardLongPressMixin {
|
||||
@override
|
||||
void onKeyboardTap() => widget.onTap(isKeyboard: true);
|
||||
|
||||
@override
|
||||
void onKeyboardLongPress() => widget.onLongPress();
|
||||
|
||||
/// 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(BuildContext ctx) {
|
||||
final scrollable = Scrollable.maybeOf(ctx);
|
||||
if (scrollable == null) {
|
||||
// Fallback to simple centering
|
||||
Scrollable.ensureVisible(
|
||||
ctx,
|
||||
alignment: 0.5,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final renderObject = ctx.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(
|
||||
ctx,
|
||||
alignment: 0.5,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
double get _posterWidth {
|
||||
switch (widget.density) {
|
||||
switch (density) {
|
||||
case LibraryDensity.compact:
|
||||
return 80;
|
||||
case LibraryDensity.normal:
|
||||
@@ -686,7 +425,7 @@ class _MediaCardListState extends State<_MediaCardList>
|
||||
}
|
||||
|
||||
double get _titleFontSize {
|
||||
switch (widget.density) {
|
||||
switch (density) {
|
||||
case LibraryDensity.compact:
|
||||
return 14;
|
||||
case LibraryDensity.normal:
|
||||
@@ -697,7 +436,7 @@ class _MediaCardListState extends State<_MediaCardList>
|
||||
}
|
||||
|
||||
double get _metadataFontSize {
|
||||
switch (widget.density) {
|
||||
switch (density) {
|
||||
case LibraryDensity.compact:
|
||||
return 11;
|
||||
case LibraryDensity.normal:
|
||||
@@ -708,7 +447,7 @@ class _MediaCardListState extends State<_MediaCardList>
|
||||
}
|
||||
|
||||
double get _subtitleFontSize {
|
||||
switch (widget.density) {
|
||||
switch (density) {
|
||||
case LibraryDensity.compact:
|
||||
return 12;
|
||||
case LibraryDensity.normal:
|
||||
@@ -724,7 +463,7 @@ class _MediaCardListState extends State<_MediaCardList>
|
||||
}
|
||||
|
||||
int get _summaryMaxLines {
|
||||
switch (widget.density) {
|
||||
switch (density) {
|
||||
case LibraryDensity.compact:
|
||||
return 2;
|
||||
case LibraryDensity.normal:
|
||||
@@ -737,8 +476,8 @@ class _MediaCardListState extends State<_MediaCardList>
|
||||
String _buildMetadataLine() {
|
||||
final parts = <String>[];
|
||||
|
||||
if (widget.item is PlexPlaylist) {
|
||||
final playlist = widget.item as PlexPlaylist;
|
||||
if (item is PlexPlaylist) {
|
||||
final playlist = item as PlexPlaylist;
|
||||
// Add item count
|
||||
if (playlist.leafCount != null && playlist.leafCount! > 0) {
|
||||
parts.add(t.playlists.itemCount(count: playlist.leafCount!));
|
||||
@@ -753,8 +492,8 @@ class _MediaCardListState extends State<_MediaCardList>
|
||||
if (playlist.smart) {
|
||||
parts.add(t.playlists.smartPlaylist);
|
||||
}
|
||||
} else if (widget.item is PlexMetadata) {
|
||||
final metadata = widget.item as PlexMetadata;
|
||||
} else if (item is PlexMetadata) {
|
||||
final metadata = item as PlexMetadata;
|
||||
|
||||
// For collections, show item count
|
||||
if (metadata.type.toLowerCase() == 'collection') {
|
||||
@@ -799,11 +538,11 @@ class _MediaCardListState extends State<_MediaCardList>
|
||||
}
|
||||
|
||||
String? _buildSubtitleText() {
|
||||
if (widget.item is PlexPlaylist) {
|
||||
if (item is PlexPlaylist) {
|
||||
// Playlists don't have subtitles
|
||||
return null;
|
||||
} else if (widget.item is PlexMetadata) {
|
||||
final metadata = widget.item as PlexMetadata;
|
||||
} else if (item is PlexMetadata) {
|
||||
final metadata = item as PlexMetadata;
|
||||
|
||||
// For TV episodes, show S#E# format
|
||||
if (metadata.parentIndex != null && metadata.index != null) {
|
||||
@@ -827,112 +566,103 @@ class _MediaCardListState extends State<_MediaCardList>
|
||||
final metadataLine = _buildMetadataLine();
|
||||
final subtitle = _buildSubtitleText();
|
||||
|
||||
return FocusableWrapper(
|
||||
onKeyEvent: (node, event) => handleKeyboardLongPress(event),
|
||||
onScrollIntoView: _scrollToCenterIfNeeded,
|
||||
builder: (context, isFocused) => FocusIndicator(
|
||||
isFocused: isFocused,
|
||||
borderRadius: 8,
|
||||
child: Semantics(
|
||||
label: widget.semanticLabel,
|
||||
button: true,
|
||||
child: InkWell(
|
||||
onTap: () => widget.onTap(isKeyboard: false),
|
||||
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),
|
||||
],
|
||||
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),
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
_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(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,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../services/plex_client.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
@@ -10,7 +9,6 @@ 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';
|
||||
@@ -371,10 +369,7 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
await _navigateToRelated(
|
||||
context,
|
||||
metadata!.parentRatingKey,
|
||||
(metadata) => SeasonDetailScreen(
|
||||
season: metadata,
|
||||
focusFirstEpisode: _openedFromKeyboard,
|
||||
),
|
||||
(metadata) => SeasonDetailScreen(season: metadata),
|
||||
t.messages.errorLoadingSeason,
|
||||
);
|
||||
break;
|
||||
@@ -1446,111 +1441,29 @@ class _FocusableContextMenuSheet extends StatefulWidget {
|
||||
|
||||
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 (isKeyboardActivationKey(event.logicalKey)) {
|
||||
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,
|
||||
),
|
||||
return 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.withValues(alpha: 0.1),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
...widget.actions.map((action) {
|
||||
return ListTile(
|
||||
leading: Icon(action.icon),
|
||||
title: Text(action.label),
|
||||
onTap: () => Navigator.pop(context, action.value),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1573,66 +1486,6 @@ class _FocusablePopupMenu extends StatefulWidget {
|
||||
}
|
||||
|
||||
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 (isKeyboardActivationKey(event.logicalKey)) {
|
||||
Navigator.pop(context, widget.actions[_focusedIndex].value);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
@@ -1667,60 +1520,36 @@ class _FocusablePopupMenuState extends State<_FocusablePopupMenu> {
|
||||
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.withValues(alpha: 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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
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.map((action) {
|
||||
return InkWell(
|
||||
onTap: () => Navigator.pop(context, action.value),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(action.icon, size: 20),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Text(action.label)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user