refactor: deduplicate
This commit is contained in:
@@ -1,4 +1,37 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'dpad_navigator.dart';
|
||||
|
||||
/// Callbacks for chip key event handling.
|
||||
class ChipKeyCallbacks {
|
||||
/// Called when SELECT key is pressed.
|
||||
final VoidCallback? onSelect;
|
||||
|
||||
/// Called when DOWN arrow is pressed.
|
||||
final VoidCallback? onNavigateDown;
|
||||
|
||||
/// Called when UP arrow is pressed.
|
||||
final VoidCallback? onNavigateUp;
|
||||
|
||||
/// Called when LEFT arrow is pressed.
|
||||
final VoidCallback? onNavigateLeft;
|
||||
|
||||
/// Called when RIGHT arrow is pressed.
|
||||
final VoidCallback? onNavigateRight;
|
||||
|
||||
/// Called when BACK key is pressed.
|
||||
final VoidCallback? onBack;
|
||||
|
||||
const ChipKeyCallbacks({
|
||||
this.onSelect,
|
||||
this.onNavigateDown,
|
||||
this.onNavigateUp,
|
||||
this.onNavigateLeft,
|
||||
this.onNavigateRight,
|
||||
this.onBack,
|
||||
});
|
||||
}
|
||||
|
||||
/// A mixin that provides common FocusNode lifecycle management for chip widgets.
|
||||
///
|
||||
@@ -60,4 +93,63 @@ mixin FocusableChipStateMixin<T extends StatefulWidget> on State<T> {
|
||||
setState(() => _isFocused = focusNode.hasFocus);
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared key event handler for chip widgets.
|
||||
///
|
||||
/// Handles common key patterns:
|
||||
/// - SELECT key -> onSelect
|
||||
/// - Arrow keys -> navigation callbacks
|
||||
/// - BACK key -> onBack
|
||||
///
|
||||
/// Returns [KeyEventResult.handled] if the event was consumed,
|
||||
/// [KeyEventResult.ignored] otherwise.
|
||||
KeyEventResult handleChipKeyEvent(
|
||||
FocusNode node,
|
||||
KeyEvent event,
|
||||
ChipKeyCallbacks callbacks,
|
||||
) {
|
||||
if (event is! KeyDownEvent && event is! KeyRepeatEvent) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
final key = event.logicalKey;
|
||||
|
||||
// SELECT key activates the chip
|
||||
if (key.isSelectKey && callbacks.onSelect != null) {
|
||||
callbacks.onSelect!();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// LEFT arrow
|
||||
if (key.isLeftKey && callbacks.onNavigateLeft != null) {
|
||||
callbacks.onNavigateLeft!();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// RIGHT arrow
|
||||
if (key.isRightKey && callbacks.onNavigateRight != null) {
|
||||
callbacks.onNavigateRight!();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// DOWN arrow
|
||||
if (key.isDownKey) {
|
||||
callbacks.onNavigateDown?.call();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// UP arrow
|
||||
if (key.isUpKey && callbacks.onNavigateUp != null) {
|
||||
callbacks.onNavigateUp!();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// BACK key
|
||||
if (key.isBackKey && callbacks.onBack != null) {
|
||||
callbacks.onBack!();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'dpad_navigator.dart';
|
||||
import 'focus_theme.dart';
|
||||
import 'input_mode_tracker.dart';
|
||||
|
||||
@@ -265,7 +266,7 @@ class _FocusableWrapperState extends State<FocusableWrapper>
|
||||
}
|
||||
|
||||
// Handle SELECT key with optional long-press detection
|
||||
if (_isSelectKey(key)) {
|
||||
if (key.isSelectKey) {
|
||||
if (widget.enableLongPress) {
|
||||
if (event is KeyDownEvent) {
|
||||
// Only start timer on initial press, not repeats
|
||||
@@ -309,7 +310,7 @@ class _FocusableWrapperState extends State<FocusableWrapper>
|
||||
}
|
||||
|
||||
// Context menu key
|
||||
if (_isContextMenuKey(key)) {
|
||||
if (key.isContextMenuKey) {
|
||||
widget.onLongPress?.call();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
@@ -321,7 +322,7 @@ class _FocusableWrapperState extends State<FocusableWrapper>
|
||||
}
|
||||
|
||||
// BACK key
|
||||
if (_isBackKey(key) && widget.onBack != null) {
|
||||
if (key.isBackKey && widget.onBack != null) {
|
||||
widget.onBack!();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
@@ -329,25 +330,6 @@ class _FocusableWrapperState extends State<FocusableWrapper>
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
bool _isSelectKey(LogicalKeyboardKey key) {
|
||||
return key == LogicalKeyboardKey.select ||
|
||||
key == LogicalKeyboardKey.enter ||
|
||||
key == LogicalKeyboardKey.numpadEnter ||
|
||||
key == LogicalKeyboardKey.gameButtonA;
|
||||
}
|
||||
|
||||
bool _isContextMenuKey(LogicalKeyboardKey key) {
|
||||
return key == LogicalKeyboardKey.contextMenu ||
|
||||
key == LogicalKeyboardKey.gameButtonX;
|
||||
}
|
||||
|
||||
bool _isBackKey(LogicalKeyboardKey key) {
|
||||
return key == LogicalKeyboardKey.escape ||
|
||||
key == LogicalKeyboardKey.goBack ||
|
||||
key == LogicalKeyboardKey.browserBack ||
|
||||
key == LogicalKeyboardKey.gameButtonB;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final duration = FocusTheme.getAnimationDuration(context);
|
||||
|
||||
+14195
-7177
File diff suppressed because it is too large
Load Diff
@@ -92,6 +92,22 @@ class OfflineWatchProvider extends ChangeNotifier {
|
||||
return metadata?.viewOffset;
|
||||
}
|
||||
|
||||
/// Get sorted episodes for a show (by season, then episode number).
|
||||
List<PlexMetadata> _getSortedEpisodes(String showRatingKey) {
|
||||
final episodes = _downloadProvider.getDownloadedEpisodesForShow(
|
||||
showRatingKey,
|
||||
);
|
||||
if (episodes.isEmpty) return episodes;
|
||||
|
||||
episodes.sort((a, b) {
|
||||
final seasonCompare = (a.parentIndex ?? 0).compareTo(b.parentIndex ?? 0);
|
||||
if (seasonCompare != 0) return seasonCompare;
|
||||
return (a.index ?? 0).compareTo(b.index ?? 0);
|
||||
});
|
||||
|
||||
return episodes;
|
||||
}
|
||||
|
||||
/// Find the next unwatched downloaded episode for a show.
|
||||
///
|
||||
/// This is the "offline OnDeck" calculation - finds the first
|
||||
@@ -101,19 +117,9 @@ class OfflineWatchProvider extends ChangeNotifier {
|
||||
///
|
||||
/// Returns the next unwatched episode, or the first episode if all watched.
|
||||
Future<PlexMetadata?> getNextUnwatchedEpisode(String showRatingKey) async {
|
||||
final episodes = _downloadProvider.getDownloadedEpisodesForShow(
|
||||
showRatingKey,
|
||||
);
|
||||
|
||||
final episodes = _getSortedEpisodes(showRatingKey);
|
||||
if (episodes.isEmpty) return null;
|
||||
|
||||
// Sort by season, then episode number
|
||||
episodes.sort((a, b) {
|
||||
final seasonCompare = (a.parentIndex ?? 0).compareTo(b.parentIndex ?? 0);
|
||||
if (seasonCompare != 0) return seasonCompare;
|
||||
return (a.index ?? 0).compareTo(b.index ?? 0);
|
||||
});
|
||||
|
||||
// Find first unwatched episode
|
||||
for (final episode in episodes) {
|
||||
final watched = await isWatched(episode.globalKey);
|
||||
@@ -131,19 +137,9 @@ class OfflineWatchProvider extends ChangeNotifier {
|
||||
/// This uses cached metadata without checking local offline actions.
|
||||
/// For real-time accuracy, use getNextUnwatchedEpisode() instead.
|
||||
PlexMetadata? getNextUnwatchedEpisodeSync(String showRatingKey) {
|
||||
final episodes = _downloadProvider.getDownloadedEpisodesForShow(
|
||||
showRatingKey,
|
||||
);
|
||||
|
||||
final episodes = _getSortedEpisodes(showRatingKey);
|
||||
if (episodes.isEmpty) return null;
|
||||
|
||||
// Sort by season, then episode number
|
||||
episodes.sort((a, b) {
|
||||
final seasonCompare = (a.parentIndex ?? 0).compareTo(b.parentIndex ?? 0);
|
||||
if (seasonCompare != 0) return seasonCompare;
|
||||
return (a.index ?? 0).compareTo(b.index ?? 0);
|
||||
});
|
||||
|
||||
// Find first unwatched episode (using metadata's isWatched)
|
||||
for (final episode in episodes) {
|
||||
if (!episode.isWatched) {
|
||||
|
||||
@@ -384,6 +384,52 @@ class _MainScreenState extends State<MainScreen> with RouteAware {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build navigation destinations for bottom navigation bar.
|
||||
List<NavigationDestination> _buildNavDestinations(bool isOffline) {
|
||||
if (isOffline) {
|
||||
return [
|
||||
NavigationDestination(
|
||||
icon: const AppIcon(Symbols.download_rounded, fill: 1),
|
||||
selectedIcon: const AppIcon(Symbols.download_rounded, fill: 1),
|
||||
label: t.navigation.downloads,
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: const AppIcon(Symbols.settings_rounded, fill: 1),
|
||||
selectedIcon: const AppIcon(Symbols.settings_rounded, fill: 1),
|
||||
label: t.navigation.settings,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
NavigationDestination(
|
||||
icon: const AppIcon(Symbols.home_rounded, fill: 1),
|
||||
selectedIcon: const AppIcon(Symbols.home_rounded, fill: 1),
|
||||
label: t.navigation.home,
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: const AppIcon(Symbols.video_library_rounded, fill: 1),
|
||||
selectedIcon: const AppIcon(Symbols.video_library_rounded, fill: 1),
|
||||
label: t.navigation.libraries,
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: const AppIcon(Symbols.search_rounded, fill: 1),
|
||||
selectedIcon: const AppIcon(Symbols.search_rounded, fill: 1),
|
||||
label: t.navigation.search,
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: const AppIcon(Symbols.download_rounded, fill: 1),
|
||||
selectedIcon: const AppIcon(Symbols.download_rounded, fill: 1),
|
||||
label: t.navigation.downloads,
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: const AppIcon(Symbols.settings_rounded, fill: 1),
|
||||
selectedIcon: const AppIcon(Symbols.settings_rounded, fill: 1),
|
||||
label: t.navigation.settings,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final useSideNav = PlatformDetector.shouldUseSideNavigation(context);
|
||||
@@ -441,57 +487,12 @@ class _MainScreenState extends State<MainScreen> with RouteAware {
|
||||
);
|
||||
}
|
||||
|
||||
// In offline mode, only show Downloads and Settings
|
||||
final destinations = _isOffline
|
||||
? [
|
||||
NavigationDestination(
|
||||
icon: const AppIcon(Symbols.download_rounded, fill: 1),
|
||||
selectedIcon: const AppIcon(Symbols.download_rounded, fill: 1),
|
||||
label: t.navigation.downloads,
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: const AppIcon(Symbols.settings_rounded, fill: 1),
|
||||
selectedIcon: const AppIcon(Symbols.settings_rounded, fill: 1),
|
||||
label: t.navigation.settings,
|
||||
),
|
||||
]
|
||||
: [
|
||||
NavigationDestination(
|
||||
icon: const AppIcon(Symbols.home_rounded, fill: 1),
|
||||
selectedIcon: const AppIcon(Symbols.home_rounded, fill: 1),
|
||||
label: t.navigation.home,
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: const AppIcon(Symbols.video_library_rounded, fill: 1),
|
||||
selectedIcon: const AppIcon(
|
||||
Symbols.video_library_rounded,
|
||||
fill: 1,
|
||||
),
|
||||
label: t.navigation.libraries,
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: const AppIcon(Symbols.search_rounded, fill: 1),
|
||||
selectedIcon: const AppIcon(Symbols.search_rounded, fill: 1),
|
||||
label: t.navigation.search,
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: const AppIcon(Symbols.download_rounded, fill: 1),
|
||||
selectedIcon: const AppIcon(Symbols.download_rounded, fill: 1),
|
||||
label: t.navigation.downloads,
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: const AppIcon(Symbols.settings_rounded, fill: 1),
|
||||
selectedIcon: const AppIcon(Symbols.settings_rounded, fill: 1),
|
||||
label: t.navigation.settings,
|
||||
),
|
||||
];
|
||||
|
||||
return Scaffold(
|
||||
body: IndexedStack(index: _currentIndex, children: _screens),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: _currentIndex,
|
||||
onDestinationSelected: _selectTab,
|
||||
destinations: destinations,
|
||||
destinations: _buildNavDestinations(_isOffline),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -212,60 +212,16 @@ class _SearchScreenState extends State<SearchScreen> with Refreshable {
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
else if (!_hasSearched)
|
||||
SliverFillRemaining(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
AppIcon(
|
||||
Symbols.search_rounded,
|
||||
fill: 1,
|
||||
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: 8),
|
||||
Text(
|
||||
t.search.enterTitleActorOrKeyword,
|
||||
style: TextStyle(color: Colors.grey.shade600),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_SearchEmptyState(
|
||||
icon: Symbols.search_rounded,
|
||||
title: t.search.searchYourMedia,
|
||||
subtitle: t.search.enterTitleActorOrKeyword,
|
||||
)
|
||||
else if (_searchResults.isEmpty)
|
||||
SliverFillRemaining(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
AppIcon(
|
||||
Symbols.search_off_rounded,
|
||||
fill: 1,
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_SearchEmptyState(
|
||||
icon: Symbols.search_off_rounded,
|
||||
title: t.messages.noResultsFound,
|
||||
subtitle: t.search.tryDifferentTerm,
|
||||
)
|
||||
else
|
||||
Consumer<SettingsProvider>(
|
||||
@@ -295,3 +251,39 @@ class _SearchScreenState extends State<SearchScreen> with Refreshable {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Empty state widget for search screen with icon, title, and subtitle.
|
||||
class _SearchEmptyState extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
|
||||
const _SearchEmptyState({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SliverFillRemaining(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
AppIcon(icon, fill: 1, size: 80, color: Colors.grey.shade400),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(color: Colors.grey.shade600),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(subtitle, style: TextStyle(color: Colors.grey.shade600)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,23 @@ class DownloadStorageService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the base app directory for storing data.
|
||||
/// Uses ApplicationDocumentsDirectory on mobile, ApplicationSupportDirectory on desktop.
|
||||
Future<Directory> _getBaseAppDir() async {
|
||||
if (Platform.isAndroid || Platform.isIOS) {
|
||||
return getApplicationDocumentsDirectory();
|
||||
}
|
||||
return getApplicationSupportDirectory();
|
||||
}
|
||||
|
||||
/// Format episode filename base: S{XX}E{XX} - {Title}
|
||||
String _formatEpisodeFileName(PlexMetadata episode) {
|
||||
final seasonNum = (episode.parentIndex ?? 0).toString().padLeft(2, '0');
|
||||
final episodeNum = (episode.index ?? 0).toString().padLeft(2, '0');
|
||||
final episodeName = _sanitizeFileName(episode.title);
|
||||
return 'S${seasonNum}E$episodeNum - $episodeName';
|
||||
}
|
||||
|
||||
/// Check if using custom download path
|
||||
bool isUsingCustomPath() => _customDownloadPath != null;
|
||||
|
||||
@@ -68,12 +85,7 @@ class DownloadStorageService {
|
||||
|
||||
/// Get default download path (for "Reset to Default" functionality)
|
||||
Future<String> getDefaultDownloadPath() async {
|
||||
final Directory baseDir;
|
||||
if (Platform.isAndroid || Platform.isIOS) {
|
||||
baseDir = await getApplicationDocumentsDirectory();
|
||||
} else {
|
||||
baseDir = await getApplicationSupportDirectory();
|
||||
}
|
||||
final baseDir = await _getBaseAppDir();
|
||||
return path.join(baseDir.path, 'downloads');
|
||||
}
|
||||
|
||||
@@ -113,14 +125,7 @@ class DownloadStorageService {
|
||||
}
|
||||
|
||||
// Default path logic
|
||||
final Directory baseDir;
|
||||
if (Platform.isAndroid || Platform.isIOS) {
|
||||
baseDir = await getApplicationDocumentsDirectory();
|
||||
} else {
|
||||
// Desktop: macOS, Windows, Linux
|
||||
baseDir = await getApplicationSupportDirectory();
|
||||
}
|
||||
|
||||
final baseDir = await _getBaseAppDir();
|
||||
_baseDownloadsDir = Directory(path.join(baseDir.path, 'downloads'));
|
||||
if (!await _baseDownloadsDir!.exists()) {
|
||||
await _baseDownloadsDir!.create(recursive: true);
|
||||
@@ -148,14 +153,7 @@ class DownloadStorageService {
|
||||
}
|
||||
|
||||
// Default: Get the app base directory directly (not downloads directory)
|
||||
final Directory baseDir;
|
||||
if (Platform.isAndroid || Platform.isIOS) {
|
||||
baseDir = await getApplicationDocumentsDirectory();
|
||||
} else {
|
||||
// Desktop: macOS, Windows, Linux
|
||||
baseDir = await getApplicationSupportDirectory();
|
||||
}
|
||||
|
||||
final baseDir = await _getBaseAppDir();
|
||||
final artworkDir = Directory(path.join(baseDir.path, 'artwork'));
|
||||
if (!await artworkDir.exists()) {
|
||||
await artworkDir.create(recursive: true);
|
||||
@@ -357,6 +355,17 @@ class DownloadStorageService {
|
||||
return path.join(seasonDir.path, '$artworkType.jpg');
|
||||
}
|
||||
|
||||
/// Get base path info for episode files (season directory path and formatted filename).
|
||||
/// [showYear]: Pass the show's premiere year (not episode year)
|
||||
Future<({String seasonDirPath, String fileName})> _getEpisodeBasePath(
|
||||
PlexMetadata episode, {
|
||||
int? showYear,
|
||||
}) async {
|
||||
final seasonDir = await getSeasonDirectory(episode, showYear: showYear);
|
||||
final fileName = _formatEpisodeFileName(episode);
|
||||
return (seasonDirPath: seasonDir.path, fileName: fileName);
|
||||
}
|
||||
|
||||
/// Get episode video file path: .../Season XX/S{XX}E{XX} - {Title}.{ext}
|
||||
/// [showYear]: Pass the show's premiere year (not episode year)
|
||||
Future<String> getEpisodeVideoPath(
|
||||
@@ -364,14 +373,8 @@ class DownloadStorageService {
|
||||
String extension, {
|
||||
int? showYear,
|
||||
}) async {
|
||||
final seasonDir = await getSeasonDirectory(episode, showYear: showYear);
|
||||
final seasonNum = (episode.parentIndex ?? 0).toString().padLeft(2, '0');
|
||||
final episodeNum = (episode.index ?? 0).toString().padLeft(2, '0');
|
||||
final episodeName = _sanitizeFileName(episode.title);
|
||||
return path.join(
|
||||
seasonDir.path,
|
||||
'S${seasonNum}E$episodeNum - $episodeName.$extension',
|
||||
);
|
||||
final base = await _getEpisodeBasePath(episode, showYear: showYear);
|
||||
return path.join(base.seasonDirPath, '${base.fileName}.$extension');
|
||||
}
|
||||
|
||||
/// Get episode thumbnail path: .../Season XX/S{XX}E{XX} - {Title}.jpg
|
||||
@@ -380,14 +383,8 @@ class DownloadStorageService {
|
||||
PlexMetadata episode, {
|
||||
int? showYear,
|
||||
}) async {
|
||||
final seasonDir = await getSeasonDirectory(episode, showYear: showYear);
|
||||
final seasonNum = (episode.parentIndex ?? 0).toString().padLeft(2, '0');
|
||||
final episodeNum = (episode.index ?? 0).toString().padLeft(2, '0');
|
||||
final episodeName = _sanitizeFileName(episode.title);
|
||||
return path.join(
|
||||
seasonDir.path,
|
||||
'S${seasonNum}E$episodeNum - $episodeName.jpg',
|
||||
);
|
||||
final base = await _getEpisodeBasePath(episode, showYear: showYear);
|
||||
return path.join(base.seasonDirPath, '${base.fileName}.jpg');
|
||||
}
|
||||
|
||||
/// Get subtitles directory for episode: .../Season XX/S{XX}E{XX} - {Title}_subs/
|
||||
@@ -396,15 +393,9 @@ class DownloadStorageService {
|
||||
PlexMetadata episode, {
|
||||
int? showYear,
|
||||
}) async {
|
||||
final seasonDir = await getSeasonDirectory(episode, showYear: showYear);
|
||||
final seasonNum = (episode.parentIndex ?? 0).toString().padLeft(2, '0');
|
||||
final episodeNum = (episode.index ?? 0).toString().padLeft(2, '0');
|
||||
final episodeName = _sanitizeFileName(episode.title);
|
||||
final base = await _getEpisodeBasePath(episode, showYear: showYear);
|
||||
final subsDir = Directory(
|
||||
path.join(
|
||||
seasonDir.path,
|
||||
'S${seasonNum}E$episodeNum - ${episodeName}_subs',
|
||||
),
|
||||
path.join(base.seasonDirPath, '${base.fileName}_subs'),
|
||||
);
|
||||
if (!await subsDir.exists()) {
|
||||
await subsDir.create(recursive: true);
|
||||
@@ -463,12 +454,7 @@ class DownloadStorageService {
|
||||
/// the container UUID can change.
|
||||
/// Returns a path relative to the app's documents directory.
|
||||
Future<String> toRelativePath(String absolutePath) async {
|
||||
final Directory baseDir;
|
||||
if (Platform.isAndroid || Platform.isIOS) {
|
||||
baseDir = await getApplicationDocumentsDirectory();
|
||||
} else {
|
||||
baseDir = await getApplicationSupportDirectory();
|
||||
}
|
||||
final baseDir = await _getBaseAppDir();
|
||||
|
||||
// If the path starts with the base directory, strip it
|
||||
if (absolutePath.startsWith(baseDir.path)) {
|
||||
@@ -492,13 +478,7 @@ class DownloadStorageService {
|
||||
return relativePath;
|
||||
}
|
||||
|
||||
final Directory baseDir;
|
||||
if (Platform.isAndroid || Platform.isIOS) {
|
||||
baseDir = await getApplicationDocumentsDirectory();
|
||||
} else {
|
||||
baseDir = await getApplicationSupportDirectory();
|
||||
}
|
||||
|
||||
final baseDir = await _getBaseAppDir();
|
||||
return path.join(baseDir.path, relativePath);
|
||||
}
|
||||
|
||||
@@ -686,10 +666,8 @@ class DownloadStorageService {
|
||||
|
||||
/// Get SAF file name for an episode
|
||||
String getEpisodeSafFileName(PlexMetadata episode, String extension) {
|
||||
final seasonNum = (episode.parentIndex ?? 0).toString().padLeft(2, '0');
|
||||
final episodeNum = (episode.index ?? 0).toString().padLeft(2, '0');
|
||||
final episodeName = _sanitizeFileName(episode.title);
|
||||
return 'S${seasonNum}E$episodeNum - $episodeName.$extension';
|
||||
final fileName = _formatEpisodeFileName(episode);
|
||||
return '$fileName.$extension';
|
||||
}
|
||||
|
||||
/// Check if a path is a SAF content URI
|
||||
|
||||
@@ -154,16 +154,11 @@ class OfflineWatchSyncService extends ChangeNotifier {
|
||||
Future<void> queueMarkWatched({
|
||||
required String serverId,
|
||||
required String ratingKey,
|
||||
}) async {
|
||||
await _database.insertWatchAction(
|
||||
serverId: serverId,
|
||||
ratingKey: ratingKey,
|
||||
actionType: 'watched',
|
||||
);
|
||||
|
||||
appLogger.d('Queued offline mark watched: $serverId:$ratingKey');
|
||||
notifyListeners();
|
||||
}
|
||||
}) => _queueWatchStatusAction(
|
||||
serverId: serverId,
|
||||
ratingKey: ratingKey,
|
||||
actionType: 'watched',
|
||||
);
|
||||
|
||||
/// Queue a manual "mark as unwatched" action.
|
||||
///
|
||||
@@ -171,14 +166,25 @@ class OfflineWatchSyncService extends ChangeNotifier {
|
||||
Future<void> queueMarkUnwatched({
|
||||
required String serverId,
|
||||
required String ratingKey,
|
||||
}) => _queueWatchStatusAction(
|
||||
serverId: serverId,
|
||||
ratingKey: ratingKey,
|
||||
actionType: 'unwatched',
|
||||
);
|
||||
|
||||
/// Internal helper to queue watch/unwatch actions.
|
||||
Future<void> _queueWatchStatusAction({
|
||||
required String serverId,
|
||||
required String ratingKey,
|
||||
required String actionType,
|
||||
}) async {
|
||||
await _database.insertWatchAction(
|
||||
serverId: serverId,
|
||||
ratingKey: ratingKey,
|
||||
actionType: 'unwatched',
|
||||
actionType: actionType,
|
||||
);
|
||||
|
||||
appLogger.d('Queued offline mark unwatched: $serverId:$ratingKey');
|
||||
appLogger.d('Queued offline mark $actionType: $serverId:$ratingKey');
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -309,6 +315,28 @@ class OfflineWatchSyncService extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute a callback with an online client for the given server.
|
||||
///
|
||||
/// Returns null if no client available or server is offline.
|
||||
/// The callback receives the PlexClient and should return the result.
|
||||
Future<T?> _withOnlineClient<T>(
|
||||
String serverId,
|
||||
Future<T> Function(PlexClient client) callback,
|
||||
) async {
|
||||
final client = _serverManager.getClient(serverId);
|
||||
if (client == null) {
|
||||
appLogger.d('No client for server $serverId, skipping');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!_serverManager.isServerOnline(serverId)) {
|
||||
appLogger.d('Server $serverId is offline, skipping');
|
||||
return null;
|
||||
}
|
||||
|
||||
return callback(client);
|
||||
}
|
||||
|
||||
/// Sync a single action to the server.
|
||||
Future<void> _syncAction(
|
||||
PlexClient client,
|
||||
@@ -392,85 +420,67 @@ class OfflineWatchSyncService extends ChangeNotifier {
|
||||
for (final serverEntry in episodesByServerAndSeason.entries) {
|
||||
final serverId = serverEntry.key;
|
||||
final seasonMap = serverEntry.value;
|
||||
final client = _serverManager.getClient(serverId);
|
||||
|
||||
if (client == null) {
|
||||
appLogger.d('No client for server $serverId, skipping');
|
||||
continue;
|
||||
}
|
||||
await _withOnlineClient(serverId, (client) async {
|
||||
for (final seasonEntry in seasonMap.entries) {
|
||||
final seasonRatingKey = seasonEntry.key;
|
||||
final downloadedEpisodeKeys = seasonEntry.value;
|
||||
|
||||
if (!_serverManager.isServerOnline(serverId)) {
|
||||
appLogger.d('Server $serverId is offline, skipping');
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
// Fetch all episodes in this season with one API call
|
||||
final seasonEpisodes = await client.getChildren(seasonRatingKey);
|
||||
seasonCount++;
|
||||
|
||||
for (final seasonEntry in seasonMap.entries) {
|
||||
final seasonRatingKey = seasonEntry.key;
|
||||
final downloadedEpisodeKeys = seasonEntry.value;
|
||||
|
||||
try {
|
||||
// Fetch all episodes in this season with one API call
|
||||
final seasonEpisodes = await client.getChildren(seasonRatingKey);
|
||||
seasonCount++;
|
||||
|
||||
// Cache only the episodes we have downloaded
|
||||
for (final episode in seasonEpisodes) {
|
||||
if (downloadedEpisodeKeys.contains(episode.ratingKey)) {
|
||||
await PlexApiCache.instance.put(
|
||||
serverId,
|
||||
'/library/metadata/${episode.ratingKey}',
|
||||
{
|
||||
'MediaContainer': {
|
||||
'Metadata': [episode.toJson()],
|
||||
// Cache only the episodes we have downloaded
|
||||
for (final episode in seasonEpisodes) {
|
||||
if (downloadedEpisodeKeys.contains(episode.ratingKey)) {
|
||||
await PlexApiCache.instance.put(
|
||||
serverId,
|
||||
'/library/metadata/${episode.ratingKey}',
|
||||
{
|
||||
'MediaContainer': {
|
||||
'Metadata': [episode.toJson()],
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
syncedCount++;
|
||||
);
|
||||
syncedCount++;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.d(
|
||||
'Failed to sync watch states for season $seasonRatingKey: $e',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.d(
|
||||
'Failed to sync watch states for season $seasonRatingKey: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch non-episode items individually (movies, etc.)
|
||||
for (final entry in nonEpisodeItems.entries) {
|
||||
final serverId = entry.key;
|
||||
final ratingKeys = entry.value;
|
||||
final client = _serverManager.getClient(serverId);
|
||||
|
||||
if (client == null) {
|
||||
appLogger.d('No client for server $serverId, skipping');
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!_serverManager.isServerOnline(serverId)) {
|
||||
appLogger.d('Server $serverId is offline, skipping');
|
||||
continue;
|
||||
}
|
||||
|
||||
for (final ratingKey in ratingKeys) {
|
||||
try {
|
||||
final metadata = await client.getMetadataWithImages(ratingKey);
|
||||
if (metadata != null) {
|
||||
await PlexApiCache.instance.put(
|
||||
serverId,
|
||||
'/library/metadata/$ratingKey',
|
||||
{
|
||||
'MediaContainer': {
|
||||
'Metadata': [metadata.toJson()],
|
||||
await _withOnlineClient(serverId, (client) async {
|
||||
for (final ratingKey in ratingKeys) {
|
||||
try {
|
||||
final metadata = await client.getMetadataWithImages(ratingKey);
|
||||
if (metadata != null) {
|
||||
await PlexApiCache.instance.put(
|
||||
serverId,
|
||||
'/library/metadata/$ratingKey',
|
||||
{
|
||||
'MediaContainer': {
|
||||
'Metadata': [metadata.toJson()],
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
syncedCount++;
|
||||
);
|
||||
syncedCount++;
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.d('Failed to sync watch state for $ratingKey: $e');
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.d('Failed to sync watch state for $ratingKey: $e');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
final movieCount = nonEpisodeItems.values.fold(0, (a, b) => a + b.length);
|
||||
|
||||
@@ -335,6 +335,10 @@ class DownloadTreeItem extends StatelessWidget {
|
||||
tooltip: 'Delete',
|
||||
iconSize: 20,
|
||||
);
|
||||
|
||||
case DownloadStatus.partial:
|
||||
// Partial status is for shows/seasons, not leaf nodes
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -363,6 +367,8 @@ class DownloadTreeItem extends StatelessWidget {
|
||||
return Colors.amber;
|
||||
case DownloadStatus.cancelled:
|
||||
return Colors.grey;
|
||||
case DownloadStatus.partial:
|
||||
return Colors.teal;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -482,104 +482,126 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
|
||||
|
||||
/// Build action buttons for nodes
|
||||
Widget _buildActions(DownloadTreeNode node) {
|
||||
final globalKey = node.key;
|
||||
final isContainer =
|
||||
node.type == DownloadNodeType.show ||
|
||||
node.type == DownloadNodeType.season;
|
||||
|
||||
final actions = isContainer
|
||||
? _getContainerActions(node)
|
||||
: _getItemActions(node);
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// For individual items (episodes/movies)
|
||||
if (!isContainer) ...[
|
||||
// Pause button for downloading items
|
||||
if (node.status == DownloadStatus.downloading &&
|
||||
widget.onPause != null)
|
||||
IconButton(
|
||||
icon: const AppIcon(Symbols.pause_rounded, fill: 1, size: 20),
|
||||
onPressed: () => widget.onPause!(globalKey),
|
||||
tooltip: 'Pause',
|
||||
),
|
||||
children: actions,
|
||||
);
|
||||
}
|
||||
|
||||
// Resume button for paused items
|
||||
if (node.status == DownloadStatus.paused && widget.onResume != null)
|
||||
IconButton(
|
||||
icon: const AppIcon(
|
||||
Symbols.play_arrow_rounded,
|
||||
fill: 1,
|
||||
size: 20,
|
||||
),
|
||||
onPressed: () => widget.onResume!(globalKey),
|
||||
tooltip: 'Resume',
|
||||
),
|
||||
/// Get action buttons for individual items (episodes/movies)
|
||||
List<Widget> _getItemActions(DownloadTreeNode node) {
|
||||
final globalKey = node.key;
|
||||
final status = node.status;
|
||||
final actions = <Widget>[];
|
||||
|
||||
// Cancel button for downloading/queued items
|
||||
if ((node.status == DownloadStatus.downloading ||
|
||||
node.status == DownloadStatus.queued) &&
|
||||
widget.onCancel != null)
|
||||
IconButton(
|
||||
icon: const AppIcon(Symbols.close_rounded, fill: 1, size: 20),
|
||||
onPressed: () => widget.onCancel!(globalKey),
|
||||
tooltip: 'Cancel',
|
||||
),
|
||||
// Pause button for downloading items
|
||||
if (status == DownloadStatus.downloading && widget.onPause != null) {
|
||||
actions.add(_buildActionButton(
|
||||
icon: Symbols.pause_rounded,
|
||||
tooltip: 'Pause',
|
||||
onPressed: () => widget.onPause!(globalKey),
|
||||
));
|
||||
}
|
||||
|
||||
// Retry button for failed items
|
||||
if (node.status == DownloadStatus.failed && widget.onRetry != null)
|
||||
IconButton(
|
||||
icon: const AppIcon(Symbols.refresh_rounded, fill: 1, size: 20),
|
||||
onPressed: () => widget.onRetry!(globalKey),
|
||||
tooltip: t.downloads.retryDownload,
|
||||
),
|
||||
// Resume button for paused items
|
||||
if (status == DownloadStatus.paused && widget.onResume != null) {
|
||||
actions.add(_buildActionButton(
|
||||
icon: Symbols.play_arrow_rounded,
|
||||
tooltip: 'Resume',
|
||||
onPressed: () => widget.onResume!(globalKey),
|
||||
));
|
||||
}
|
||||
|
||||
// Delete button for completed/failed/cancelled items
|
||||
if ((node.status == DownloadStatus.completed ||
|
||||
node.status == DownloadStatus.failed ||
|
||||
node.status == DownloadStatus.cancelled) &&
|
||||
widget.onDelete != null)
|
||||
IconButton(
|
||||
icon: const AppIcon(Symbols.delete_rounded, fill: 1, size: 20),
|
||||
onPressed: () => widget.onDelete!(globalKey),
|
||||
tooltip: 'Delete',
|
||||
),
|
||||
],
|
||||
// Cancel button for downloading/queued items
|
||||
if ((status == DownloadStatus.downloading ||
|
||||
status == DownloadStatus.queued) &&
|
||||
widget.onCancel != null) {
|
||||
actions.add(_buildActionButton(
|
||||
icon: Symbols.close_rounded,
|
||||
tooltip: 'Cancel',
|
||||
onPressed: () => widget.onCancel!(globalKey),
|
||||
));
|
||||
}
|
||||
|
||||
// For container nodes (shows/seasons)
|
||||
if (isContainer) ...[
|
||||
// Pause all button - show if any children are downloading or queued
|
||||
if ((node.status == DownloadStatus.downloading ||
|
||||
node.status == DownloadStatus.queued) &&
|
||||
widget.onPause != null)
|
||||
IconButton(
|
||||
icon: const AppIcon(Symbols.pause_rounded, fill: 1, size: 20),
|
||||
onPressed: () => _pauseAllChildren(node),
|
||||
tooltip: 'Pause all',
|
||||
),
|
||||
// Retry button for failed items
|
||||
if (status == DownloadStatus.failed && widget.onRetry != null) {
|
||||
actions.add(_buildActionButton(
|
||||
icon: Symbols.refresh_rounded,
|
||||
tooltip: t.downloads.retryDownload,
|
||||
onPressed: () => widget.onRetry!(globalKey),
|
||||
));
|
||||
}
|
||||
|
||||
// Resume all button - show if container is paused
|
||||
if (node.status == DownloadStatus.paused && widget.onResume != null)
|
||||
IconButton(
|
||||
icon: const AppIcon(
|
||||
Symbols.play_arrow_rounded,
|
||||
fill: 1,
|
||||
size: 20,
|
||||
),
|
||||
onPressed: () => _resumeAllChildren(node),
|
||||
tooltip: 'Resume all',
|
||||
),
|
||||
// Delete button for completed/failed/cancelled items
|
||||
if ((status == DownloadStatus.completed ||
|
||||
status == DownloadStatus.failed ||
|
||||
status == DownloadStatus.cancelled) &&
|
||||
widget.onDelete != null) {
|
||||
actions.add(_buildActionButton(
|
||||
icon: Symbols.delete_rounded,
|
||||
tooltip: 'Delete',
|
||||
onPressed: () => widget.onDelete!(globalKey),
|
||||
));
|
||||
}
|
||||
|
||||
// Delete all button
|
||||
if (widget.onDelete != null)
|
||||
IconButton(
|
||||
icon: const AppIcon(
|
||||
Symbols.delete_sweep_rounded,
|
||||
fill: 1,
|
||||
size: 20,
|
||||
),
|
||||
onPressed: () => _deleteAllChildren(node),
|
||||
tooltip: 'Delete all',
|
||||
),
|
||||
],
|
||||
],
|
||||
return actions;
|
||||
}
|
||||
|
||||
/// Get action buttons for container nodes (shows/seasons)
|
||||
List<Widget> _getContainerActions(DownloadTreeNode node) {
|
||||
final status = node.status;
|
||||
final actions = <Widget>[];
|
||||
|
||||
// Pause all button - show if any children are downloading or queued
|
||||
if ((status == DownloadStatus.downloading ||
|
||||
status == DownloadStatus.queued) &&
|
||||
widget.onPause != null) {
|
||||
actions.add(_buildActionButton(
|
||||
icon: Symbols.pause_rounded,
|
||||
tooltip: 'Pause all',
|
||||
onPressed: () => _pauseAllChildren(node),
|
||||
));
|
||||
}
|
||||
|
||||
// Resume all button - show if container is paused
|
||||
if (status == DownloadStatus.paused && widget.onResume != null) {
|
||||
actions.add(_buildActionButton(
|
||||
icon: Symbols.play_arrow_rounded,
|
||||
tooltip: 'Resume all',
|
||||
onPressed: () => _resumeAllChildren(node),
|
||||
));
|
||||
}
|
||||
|
||||
// Delete all button
|
||||
if (widget.onDelete != null) {
|
||||
actions.add(_buildActionButton(
|
||||
icon: Symbols.delete_sweep_rounded,
|
||||
tooltip: 'Delete all',
|
||||
onPressed: () => _deleteAllChildren(node),
|
||||
));
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
/// Build a single action button
|
||||
Widget _buildActionButton({
|
||||
required IconData icon,
|
||||
required String tooltip,
|
||||
required VoidCallback onPressed,
|
||||
}) {
|
||||
return IconButton(
|
||||
icon: AppIcon(icon, fill: 1, size: 20),
|
||||
onPressed: onPressed,
|
||||
tooltip: tooltip,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:plezy/widgets/app_icon.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../focus/dpad_navigator.dart';
|
||||
import '../focus/focusable_chip_mixin.dart';
|
||||
import '../focus/input_mode_tracker.dart';
|
||||
import 'focus_builders.dart';
|
||||
@@ -70,36 +69,16 @@ class _FocusableFilterChipState extends State<FocusableFilterChip>
|
||||
}
|
||||
|
||||
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
|
||||
if (event is! KeyDownEvent && event is! KeyRepeatEvent) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
// SELECT key activates the chip
|
||||
if (event.logicalKey.isSelectKey) {
|
||||
widget.onPressed();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// DOWN arrow navigates to the grid
|
||||
if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
|
||||
widget.onNavigateDown?.call();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// UP arrow navigates to tab bar
|
||||
if (event.logicalKey == LogicalKeyboardKey.arrowUp &&
|
||||
widget.onNavigateUp != null) {
|
||||
widget.onNavigateUp!();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// BACK key navigates to tab bar
|
||||
if (event.logicalKey.isBackKey && widget.onBack != null) {
|
||||
widget.onBack!();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
return KeyEventResult.ignored;
|
||||
return handleChipKeyEvent(
|
||||
node,
|
||||
event,
|
||||
ChipKeyCallbacks(
|
||||
onSelect: widget.onPressed,
|
||||
onNavigateDown: widget.onNavigateDown,
|
||||
onNavigateUp: widget.onNavigateUp,
|
||||
onBack: widget.onBack,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../focus/dpad_navigator.dart';
|
||||
import '../focus/focusable_chip_mixin.dart';
|
||||
import '../focus/input_mode_tracker.dart';
|
||||
import 'focus_builders.dart';
|
||||
@@ -78,43 +77,17 @@ class _FocusableTabChipState extends State<FocusableTabChip>
|
||||
}
|
||||
|
||||
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
|
||||
if (event is! KeyDownEvent && event is! KeyRepeatEvent) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
final key = event.logicalKey;
|
||||
|
||||
// SELECT key activates the tab
|
||||
if (key.isSelectKey) {
|
||||
widget.onSelect();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// LEFT arrow switches to previous tab
|
||||
if (key.isLeftKey && widget.onNavigateLeft != null) {
|
||||
widget.onNavigateLeft!();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// RIGHT arrow switches to next tab
|
||||
if (key.isRightKey && widget.onNavigateRight != null) {
|
||||
widget.onNavigateRight!();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// DOWN arrow navigates to tab content
|
||||
if (key == LogicalKeyboardKey.arrowDown) {
|
||||
widget.onNavigateDown?.call();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// BACK key navigates to sidenav
|
||||
if (key.isBackKey && widget.onBack != null) {
|
||||
widget.onBack!();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
return KeyEventResult.ignored;
|
||||
return handleChipKeyEvent(
|
||||
node,
|
||||
event,
|
||||
ChipKeyCallbacks(
|
||||
onSelect: widget.onSelect,
|
||||
onNavigateLeft: widget.onNavigateLeft,
|
||||
onNavigateRight: widget.onNavigateRight,
|
||||
onNavigateDown: widget.onNavigateDown,
|
||||
onBack: widget.onBack,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -319,21 +319,6 @@ class SideNavigationRailState extends State<SideNavigationRail> {
|
||||
}
|
||||
}
|
||||
|
||||
IconData _getLibraryIconFilled(String type) {
|
||||
switch (type.toLowerCase()) {
|
||||
case 'movie':
|
||||
return Symbols.movie_rounded;
|
||||
case 'show':
|
||||
return Symbols.tv_rounded;
|
||||
case 'artist':
|
||||
return Symbols.music_note_rounded;
|
||||
case 'photo':
|
||||
return Symbols.photo_rounded;
|
||||
default:
|
||||
return Symbols.folder_rounded;
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate top padding for macOS traffic lights
|
||||
double _getTopPadding(BuildContext context) {
|
||||
double basePadding = MediaQuery.of(context).padding.top + 16;
|
||||
@@ -635,7 +620,7 @@ class SideNavigationRailState extends State<SideNavigationRail> {
|
||||
|
||||
return NavigationRailItem(
|
||||
icon: _getLibraryIcon(library.type),
|
||||
selectedIcon: _getLibraryIconFilled(library.type),
|
||||
selectedIcon: _getLibraryIcon(library.type),
|
||||
label: SizedBox(
|
||||
height: 32, // Fixed height for consistent item sizing
|
||||
child: Column(
|
||||
|
||||
@@ -181,42 +181,34 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle key events for horizontal button navigation
|
||||
KeyEventResult _handleButtonKeyEvent(
|
||||
FocusNode node,
|
||||
KeyEvent event,
|
||||
int index,
|
||||
) {
|
||||
/// Handle directional navigation for bottom control row.
|
||||
///
|
||||
/// Returns [KeyEventResult.handled] if the key was processed,
|
||||
/// [KeyEventResult.ignored] otherwise.
|
||||
/// UP always navigates to timeline.
|
||||
KeyEventResult _handleDirectionalNavigation(
|
||||
KeyEvent event, {
|
||||
FocusNode? leftTarget,
|
||||
FocusNode? rightTarget,
|
||||
}) {
|
||||
if (event is! KeyDownEvent && event is! KeyRepeatEvent) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
final key = event.logicalKey;
|
||||
|
||||
// LEFT arrow - move to previous button
|
||||
if (key == LogicalKeyboardKey.arrowLeft) {
|
||||
if (index > 0) {
|
||||
_buttonFocusNodes[index - 1].requestFocus();
|
||||
widget.onFocusActivity?.call();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.handled; // At start, consume to prevent bubbling
|
||||
leftTarget?.requestFocus();
|
||||
widget.onFocusActivity?.call();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// RIGHT arrow - move to next button or to volume
|
||||
if (key == LogicalKeyboardKey.arrowRight) {
|
||||
if (index < _buttonFocusNodes.length - 1) {
|
||||
_buttonFocusNodes[index + 1].requestFocus();
|
||||
widget.onFocusActivity?.call();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
// At end of playback buttons - move to volume
|
||||
_volumeFocusNode.requestFocus();
|
||||
rightTarget?.requestFocus();
|
||||
widget.onFocusActivity?.call();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// UP arrow - move focus to timeline
|
||||
if (key == LogicalKeyboardKey.arrowUp) {
|
||||
_timelineFocusNode.requestFocus();
|
||||
widget.onFocusActivity?.call();
|
||||
@@ -226,38 +218,33 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
/// Handle key events for horizontal button navigation
|
||||
KeyEventResult _handleButtonKeyEvent(
|
||||
FocusNode node,
|
||||
KeyEvent event,
|
||||
int index,
|
||||
) {
|
||||
final leftTarget = index > 0 ? _buttonFocusNodes[index - 1] : null;
|
||||
final rightTarget = index < _buttonFocusNodes.length - 1
|
||||
? _buttonFocusNodes[index + 1]
|
||||
: _volumeFocusNode;
|
||||
|
||||
return _handleDirectionalNavigation(
|
||||
event,
|
||||
leftTarget: leftTarget,
|
||||
rightTarget: rightTarget,
|
||||
);
|
||||
}
|
||||
|
||||
/// Handle key events for volume control navigation
|
||||
KeyEventResult _handleVolumeKeyEvent(FocusNode node, KeyEvent event) {
|
||||
if (event is! KeyDownEvent && event is! KeyRepeatEvent) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
final key = event.logicalKey;
|
||||
|
||||
// LEFT arrow - move back to last playback button
|
||||
if (key == LogicalKeyboardKey.arrowLeft) {
|
||||
_nextItemFocusNode.requestFocus();
|
||||
widget.onFocusActivity?.call();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// RIGHT arrow - move to first track control button
|
||||
if (key == LogicalKeyboardKey.arrowRight) {
|
||||
if (_trackControlFocusNodes.isNotEmpty) {
|
||||
_trackControlFocusNodes[0].requestFocus();
|
||||
widget.onFocusActivity?.call();
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// UP arrow - move focus to timeline
|
||||
if (key == LogicalKeyboardKey.arrowUp) {
|
||||
_timelineFocusNode.requestFocus();
|
||||
widget.onFocusActivity?.call();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
return KeyEventResult.ignored;
|
||||
return _handleDirectionalNavigation(
|
||||
event,
|
||||
leftTarget: _nextItemFocusNode,
|
||||
rightTarget: _trackControlFocusNodes.isNotEmpty
|
||||
? _trackControlFocusNodes[0]
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
/// Handle key events for timeline navigation
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:plezy/widgets/app_icon.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../../../focus/dpad_navigator.dart';
|
||||
import '../../../mpv/mpv.dart';
|
||||
import '../../../services/settings_service.dart';
|
||||
import '../../../i18n/strings.g.dart';
|
||||
@@ -91,7 +92,7 @@ class _VolumeControlState extends State<VolumeControl> {
|
||||
_adjustVolume(_volumeStep);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (_isBackKey(key) || _isSelectKey(key)) {
|
||||
if (key.isBackKey || key.isSelectKey) {
|
||||
_exitAdjustMode();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
@@ -110,20 +111,6 @@ class _VolumeControlState extends State<VolumeControl> {
|
||||
return widget.onKeyEvent?.call(node, event) ?? KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
bool _isBackKey(LogicalKeyboardKey key) {
|
||||
return key == LogicalKeyboardKey.escape ||
|
||||
key == LogicalKeyboardKey.goBack ||
|
||||
key == LogicalKeyboardKey.browserBack ||
|
||||
key == LogicalKeyboardKey.gameButtonB;
|
||||
}
|
||||
|
||||
bool _isSelectKey(LogicalKeyboardKey key) {
|
||||
return key == LogicalKeyboardKey.select ||
|
||||
key == LogicalKeyboardKey.enter ||
|
||||
key == LogicalKeyboardKey.numpadEnter ||
|
||||
key == LogicalKeyboardKey.gameButtonA;
|
||||
}
|
||||
|
||||
void _handleFocusChange(bool hasFocus) {
|
||||
// Exit adjust mode when focus is lost
|
||||
if (!hasFocus && _isAdjustMode) {
|
||||
|
||||
Reference in New Issue
Block a user