refactor: deduplicate

This commit is contained in:
edde746
2025-12-12 18:00:34 +01:00
parent 11461eefe5
commit 0eb84dc7d2
15 changed files with 14701 additions and 7693 deletions
+92
View File
@@ -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;
}
}
+4 -22
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+18 -22
View File
@@ -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) {
+47 -46
View File
@@ -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),
),
);
}
+44 -52
View File
@@ -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)),
],
),
),
);
}
}
+41 -63
View File
@@ -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
+84 -74
View File
@@ -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);
+6
View File
@@ -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;
}
}
}
+107 -85
View File
@@ -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,
);
}
+10 -31
View File
@@ -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
+11 -38
View File
@@ -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
+1 -16
View File
@@ -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) {