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/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. /// 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); 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/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'dpad_navigator.dart';
import 'focus_theme.dart'; import 'focus_theme.dart';
import 'input_mode_tracker.dart'; import 'input_mode_tracker.dart';
@@ -265,7 +266,7 @@ class _FocusableWrapperState extends State<FocusableWrapper>
} }
// Handle SELECT key with optional long-press detection // Handle SELECT key with optional long-press detection
if (_isSelectKey(key)) { if (key.isSelectKey) {
if (widget.enableLongPress) { if (widget.enableLongPress) {
if (event is KeyDownEvent) { if (event is KeyDownEvent) {
// Only start timer on initial press, not repeats // Only start timer on initial press, not repeats
@@ -309,7 +310,7 @@ class _FocusableWrapperState extends State<FocusableWrapper>
} }
// Context menu key // Context menu key
if (_isContextMenuKey(key)) { if (key.isContextMenuKey) {
widget.onLongPress?.call(); widget.onLongPress?.call();
return KeyEventResult.handled; return KeyEventResult.handled;
} }
@@ -321,7 +322,7 @@ class _FocusableWrapperState extends State<FocusableWrapper>
} }
// BACK key // BACK key
if (_isBackKey(key) && widget.onBack != null) { if (key.isBackKey && widget.onBack != null) {
widget.onBack!(); widget.onBack!();
return KeyEventResult.handled; return KeyEventResult.handled;
} }
@@ -329,25 +330,6 @@ class _FocusableWrapperState extends State<FocusableWrapper>
return KeyEventResult.ignored; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final duration = FocusTheme.getAnimationDuration(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; 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. /// Find the next unwatched downloaded episode for a show.
/// ///
/// This is the "offline OnDeck" calculation - finds the first /// 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. /// Returns the next unwatched episode, or the first episode if all watched.
Future<PlexMetadata?> getNextUnwatchedEpisode(String showRatingKey) async { Future<PlexMetadata?> getNextUnwatchedEpisode(String showRatingKey) async {
final episodes = _downloadProvider.getDownloadedEpisodesForShow( final episodes = _getSortedEpisodes(showRatingKey);
showRatingKey,
);
if (episodes.isEmpty) return null; 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 // Find first unwatched episode
for (final episode in episodes) { for (final episode in episodes) {
final watched = await isWatched(episode.globalKey); final watched = await isWatched(episode.globalKey);
@@ -131,19 +137,9 @@ class OfflineWatchProvider extends ChangeNotifier {
/// This uses cached metadata without checking local offline actions. /// This uses cached metadata without checking local offline actions.
/// For real-time accuracy, use getNextUnwatchedEpisode() instead. /// For real-time accuracy, use getNextUnwatchedEpisode() instead.
PlexMetadata? getNextUnwatchedEpisodeSync(String showRatingKey) { PlexMetadata? getNextUnwatchedEpisodeSync(String showRatingKey) {
final episodes = _downloadProvider.getDownloadedEpisodesForShow( final episodes = _getSortedEpisodes(showRatingKey);
showRatingKey,
);
if (episodes.isEmpty) return null; 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) // Find first unwatched episode (using metadata's isWatched)
for (final episode in episodes) { for (final episode in episodes) {
if (!episode.isWatched) { 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final useSideNav = PlatformDetector.shouldUseSideNavigation(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( return Scaffold(
body: IndexedStack(index: _currentIndex, children: _screens), body: IndexedStack(index: _currentIndex, children: _screens),
bottomNavigationBar: NavigationBar( bottomNavigationBar: NavigationBar(
selectedIndex: _currentIndex, selectedIndex: _currentIndex,
onDestinationSelected: _selectTab, 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()), child: Center(child: CircularProgressIndicator()),
) )
else if (!_hasSearched) else if (!_hasSearched)
SliverFillRemaining( _SearchEmptyState(
child: Center( icon: Symbols.search_rounded,
child: Column( title: t.search.searchYourMedia,
mainAxisAlignment: MainAxisAlignment.center, subtitle: t.search.enterTitleActorOrKeyword,
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),
),
],
),
),
) )
else if (_searchResults.isEmpty) else if (_searchResults.isEmpty)
SliverFillRemaining( _SearchEmptyState(
child: Center( icon: Symbols.search_off_rounded,
child: Column( title: t.messages.noResultsFound,
mainAxisAlignment: MainAxisAlignment.center, subtitle: t.search.tryDifferentTerm,
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),
),
],
),
),
) )
else else
Consumer<SettingsProvider>( 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 /// Check if using custom download path
bool isUsingCustomPath() => _customDownloadPath != null; bool isUsingCustomPath() => _customDownloadPath != null;
@@ -68,12 +85,7 @@ class DownloadStorageService {
/// Get default download path (for "Reset to Default" functionality) /// Get default download path (for "Reset to Default" functionality)
Future<String> getDefaultDownloadPath() async { Future<String> getDefaultDownloadPath() async {
final Directory baseDir; final baseDir = await _getBaseAppDir();
if (Platform.isAndroid || Platform.isIOS) {
baseDir = await getApplicationDocumentsDirectory();
} else {
baseDir = await getApplicationSupportDirectory();
}
return path.join(baseDir.path, 'downloads'); return path.join(baseDir.path, 'downloads');
} }
@@ -113,14 +125,7 @@ class DownloadStorageService {
} }
// Default path logic // Default path logic
final Directory baseDir; final baseDir = await _getBaseAppDir();
if (Platform.isAndroid || Platform.isIOS) {
baseDir = await getApplicationDocumentsDirectory();
} else {
// Desktop: macOS, Windows, Linux
baseDir = await getApplicationSupportDirectory();
}
_baseDownloadsDir = Directory(path.join(baseDir.path, 'downloads')); _baseDownloadsDir = Directory(path.join(baseDir.path, 'downloads'));
if (!await _baseDownloadsDir!.exists()) { if (!await _baseDownloadsDir!.exists()) {
await _baseDownloadsDir!.create(recursive: true); await _baseDownloadsDir!.create(recursive: true);
@@ -148,14 +153,7 @@ class DownloadStorageService {
} }
// Default: Get the app base directory directly (not downloads directory) // Default: Get the app base directory directly (not downloads directory)
final Directory baseDir; final baseDir = await _getBaseAppDir();
if (Platform.isAndroid || Platform.isIOS) {
baseDir = await getApplicationDocumentsDirectory();
} else {
// Desktop: macOS, Windows, Linux
baseDir = await getApplicationSupportDirectory();
}
final artworkDir = Directory(path.join(baseDir.path, 'artwork')); final artworkDir = Directory(path.join(baseDir.path, 'artwork'));
if (!await artworkDir.exists()) { if (!await artworkDir.exists()) {
await artworkDir.create(recursive: true); await artworkDir.create(recursive: true);
@@ -357,6 +355,17 @@ class DownloadStorageService {
return path.join(seasonDir.path, '$artworkType.jpg'); 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} /// Get episode video file path: .../Season XX/S{XX}E{XX} - {Title}.{ext}
/// [showYear]: Pass the show's premiere year (not episode year) /// [showYear]: Pass the show's premiere year (not episode year)
Future<String> getEpisodeVideoPath( Future<String> getEpisodeVideoPath(
@@ -364,14 +373,8 @@ class DownloadStorageService {
String extension, { String extension, {
int? showYear, int? showYear,
}) async { }) async {
final seasonDir = await getSeasonDirectory(episode, showYear: showYear); final base = await _getEpisodeBasePath(episode, showYear: showYear);
final seasonNum = (episode.parentIndex ?? 0).toString().padLeft(2, '0'); return path.join(base.seasonDirPath, '${base.fileName}.$extension');
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',
);
} }
/// Get episode thumbnail path: .../Season XX/S{XX}E{XX} - {Title}.jpg /// Get episode thumbnail path: .../Season XX/S{XX}E{XX} - {Title}.jpg
@@ -380,14 +383,8 @@ class DownloadStorageService {
PlexMetadata episode, { PlexMetadata episode, {
int? showYear, int? showYear,
}) async { }) async {
final seasonDir = await getSeasonDirectory(episode, showYear: showYear); final base = await _getEpisodeBasePath(episode, showYear: showYear);
final seasonNum = (episode.parentIndex ?? 0).toString().padLeft(2, '0'); return path.join(base.seasonDirPath, '${base.fileName}.jpg');
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',
);
} }
/// Get subtitles directory for episode: .../Season XX/S{XX}E{XX} - {Title}_subs/ /// Get subtitles directory for episode: .../Season XX/S{XX}E{XX} - {Title}_subs/
@@ -396,15 +393,9 @@ class DownloadStorageService {
PlexMetadata episode, { PlexMetadata episode, {
int? showYear, int? showYear,
}) async { }) async {
final seasonDir = await getSeasonDirectory(episode, showYear: showYear); final base = await _getEpisodeBasePath(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 subsDir = Directory( final subsDir = Directory(
path.join( path.join(base.seasonDirPath, '${base.fileName}_subs'),
seasonDir.path,
'S${seasonNum}E$episodeNum - ${episodeName}_subs',
),
); );
if (!await subsDir.exists()) { if (!await subsDir.exists()) {
await subsDir.create(recursive: true); await subsDir.create(recursive: true);
@@ -463,12 +454,7 @@ class DownloadStorageService {
/// the container UUID can change. /// the container UUID can change.
/// Returns a path relative to the app's documents directory. /// Returns a path relative to the app's documents directory.
Future<String> toRelativePath(String absolutePath) async { Future<String> toRelativePath(String absolutePath) async {
final Directory baseDir; final baseDir = await _getBaseAppDir();
if (Platform.isAndroid || Platform.isIOS) {
baseDir = await getApplicationDocumentsDirectory();
} else {
baseDir = await getApplicationSupportDirectory();
}
// If the path starts with the base directory, strip it // If the path starts with the base directory, strip it
if (absolutePath.startsWith(baseDir.path)) { if (absolutePath.startsWith(baseDir.path)) {
@@ -492,13 +478,7 @@ class DownloadStorageService {
return relativePath; return relativePath;
} }
final Directory baseDir; final baseDir = await _getBaseAppDir();
if (Platform.isAndroid || Platform.isIOS) {
baseDir = await getApplicationDocumentsDirectory();
} else {
baseDir = await getApplicationSupportDirectory();
}
return path.join(baseDir.path, relativePath); return path.join(baseDir.path, relativePath);
} }
@@ -686,10 +666,8 @@ class DownloadStorageService {
/// Get SAF file name for an episode /// Get SAF file name for an episode
String getEpisodeSafFileName(PlexMetadata episode, String extension) { String getEpisodeSafFileName(PlexMetadata episode, String extension) {
final seasonNum = (episode.parentIndex ?? 0).toString().padLeft(2, '0'); final fileName = _formatEpisodeFileName(episode);
final episodeNum = (episode.index ?? 0).toString().padLeft(2, '0'); return '$fileName.$extension';
final episodeName = _sanitizeFileName(episode.title);
return 'S${seasonNum}E$episodeNum - $episodeName.$extension';
} }
/// Check if a path is a SAF content URI /// Check if a path is a SAF content URI
+84 -74
View File
@@ -154,16 +154,11 @@ class OfflineWatchSyncService extends ChangeNotifier {
Future<void> queueMarkWatched({ Future<void> queueMarkWatched({
required String serverId, required String serverId,
required String ratingKey, required String ratingKey,
}) async { }) => _queueWatchStatusAction(
await _database.insertWatchAction( serverId: serverId,
serverId: serverId, ratingKey: ratingKey,
ratingKey: ratingKey, actionType: 'watched',
actionType: 'watched', );
);
appLogger.d('Queued offline mark watched: $serverId:$ratingKey');
notifyListeners();
}
/// Queue a manual "mark as unwatched" action. /// Queue a manual "mark as unwatched" action.
/// ///
@@ -171,14 +166,25 @@ class OfflineWatchSyncService extends ChangeNotifier {
Future<void> queueMarkUnwatched({ Future<void> queueMarkUnwatched({
required String serverId, required String serverId,
required String ratingKey, 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 { }) async {
await _database.insertWatchAction( await _database.insertWatchAction(
serverId: serverId, serverId: serverId,
ratingKey: ratingKey, ratingKey: ratingKey,
actionType: 'unwatched', actionType: actionType,
); );
appLogger.d('Queued offline mark unwatched: $serverId:$ratingKey'); appLogger.d('Queued offline mark $actionType: $serverId:$ratingKey');
notifyListeners(); 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. /// Sync a single action to the server.
Future<void> _syncAction( Future<void> _syncAction(
PlexClient client, PlexClient client,
@@ -392,85 +420,67 @@ class OfflineWatchSyncService extends ChangeNotifier {
for (final serverEntry in episodesByServerAndSeason.entries) { for (final serverEntry in episodesByServerAndSeason.entries) {
final serverId = serverEntry.key; final serverId = serverEntry.key;
final seasonMap = serverEntry.value; final seasonMap = serverEntry.value;
final client = _serverManager.getClient(serverId);
if (client == null) { await _withOnlineClient(serverId, (client) async {
appLogger.d('No client for server $serverId, skipping'); for (final seasonEntry in seasonMap.entries) {
continue; final seasonRatingKey = seasonEntry.key;
} final downloadedEpisodeKeys = seasonEntry.value;
if (!_serverManager.isServerOnline(serverId)) { try {
appLogger.d('Server $serverId is offline, skipping'); // Fetch all episodes in this season with one API call
continue; final seasonEpisodes = await client.getChildren(seasonRatingKey);
} seasonCount++;
for (final seasonEntry in seasonMap.entries) { // Cache only the episodes we have downloaded
final seasonRatingKey = seasonEntry.key; for (final episode in seasonEpisodes) {
final downloadedEpisodeKeys = seasonEntry.value; if (downloadedEpisodeKeys.contains(episode.ratingKey)) {
await PlexApiCache.instance.put(
try { serverId,
// Fetch all episodes in this season with one API call '/library/metadata/${episode.ratingKey}',
final seasonEpisodes = await client.getChildren(seasonRatingKey); {
seasonCount++; '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.) // Fetch non-episode items individually (movies, etc.)
for (final entry in nonEpisodeItems.entries) { for (final entry in nonEpisodeItems.entries) {
final serverId = entry.key; final serverId = entry.key;
final ratingKeys = entry.value; final ratingKeys = entry.value;
final client = _serverManager.getClient(serverId);
if (client == null) { await _withOnlineClient(serverId, (client) async {
appLogger.d('No client for server $serverId, skipping'); for (final ratingKey in ratingKeys) {
continue; try {
} final metadata = await client.getMetadataWithImages(ratingKey);
if (metadata != null) {
if (!_serverManager.isServerOnline(serverId)) { await PlexApiCache.instance.put(
appLogger.d('Server $serverId is offline, skipping'); serverId,
continue; '/library/metadata/$ratingKey',
} {
'MediaContainer': {
for (final ratingKey in ratingKeys) { 'Metadata': [metadata.toJson()],
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); final movieCount = nonEpisodeItems.values.fold(0, (a, b) => a + b.length);
+6
View File
@@ -335,6 +335,10 @@ class DownloadTreeItem extends StatelessWidget {
tooltip: 'Delete', tooltip: 'Delete',
iconSize: 20, 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; return Colors.amber;
case DownloadStatus.cancelled: case DownloadStatus.cancelled:
return Colors.grey; 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 /// Build action buttons for nodes
Widget _buildActions(DownloadTreeNode node) { Widget _buildActions(DownloadTreeNode node) {
final globalKey = node.key;
final isContainer = final isContainer =
node.type == DownloadNodeType.show || node.type == DownloadNodeType.show ||
node.type == DownloadNodeType.season; node.type == DownloadNodeType.season;
final actions = isContainer
? _getContainerActions(node)
: _getItemActions(node);
return Row( return Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: actions,
// 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',
),
// Resume button for paused items /// Get action buttons for individual items (episodes/movies)
if (node.status == DownloadStatus.paused && widget.onResume != null) List<Widget> _getItemActions(DownloadTreeNode node) {
IconButton( final globalKey = node.key;
icon: const AppIcon( final status = node.status;
Symbols.play_arrow_rounded, final actions = <Widget>[];
fill: 1,
size: 20,
),
onPressed: () => widget.onResume!(globalKey),
tooltip: 'Resume',
),
// Cancel button for downloading/queued items // Pause button for downloading items
if ((node.status == DownloadStatus.downloading || if (status == DownloadStatus.downloading && widget.onPause != null) {
node.status == DownloadStatus.queued) && actions.add(_buildActionButton(
widget.onCancel != null) icon: Symbols.pause_rounded,
IconButton( tooltip: 'Pause',
icon: const AppIcon(Symbols.close_rounded, fill: 1, size: 20), onPressed: () => widget.onPause!(globalKey),
onPressed: () => widget.onCancel!(globalKey), ));
tooltip: 'Cancel', }
),
// Retry button for failed items // Resume button for paused items
if (node.status == DownloadStatus.failed && widget.onRetry != null) if (status == DownloadStatus.paused && widget.onResume != null) {
IconButton( actions.add(_buildActionButton(
icon: const AppIcon(Symbols.refresh_rounded, fill: 1, size: 20), icon: Symbols.play_arrow_rounded,
onPressed: () => widget.onRetry!(globalKey), tooltip: 'Resume',
tooltip: t.downloads.retryDownload, onPressed: () => widget.onResume!(globalKey),
), ));
}
// Delete button for completed/failed/cancelled items // Cancel button for downloading/queued items
if ((node.status == DownloadStatus.completed || if ((status == DownloadStatus.downloading ||
node.status == DownloadStatus.failed || status == DownloadStatus.queued) &&
node.status == DownloadStatus.cancelled) && widget.onCancel != null) {
widget.onDelete != null) actions.add(_buildActionButton(
IconButton( icon: Symbols.close_rounded,
icon: const AppIcon(Symbols.delete_rounded, fill: 1, size: 20), tooltip: 'Cancel',
onPressed: () => widget.onDelete!(globalKey), onPressed: () => widget.onCancel!(globalKey),
tooltip: 'Delete', ));
), }
],
// For container nodes (shows/seasons) // Retry button for failed items
if (isContainer) ...[ if (status == DownloadStatus.failed && widget.onRetry != null) {
// Pause all button - show if any children are downloading or queued actions.add(_buildActionButton(
if ((node.status == DownloadStatus.downloading || icon: Symbols.refresh_rounded,
node.status == DownloadStatus.queued) && tooltip: t.downloads.retryDownload,
widget.onPause != null) onPressed: () => widget.onRetry!(globalKey),
IconButton( ));
icon: const AppIcon(Symbols.pause_rounded, fill: 1, size: 20), }
onPressed: () => _pauseAllChildren(node),
tooltip: 'Pause all',
),
// Resume all button - show if container is paused // Delete button for completed/failed/cancelled items
if (node.status == DownloadStatus.paused && widget.onResume != null) if ((status == DownloadStatus.completed ||
IconButton( status == DownloadStatus.failed ||
icon: const AppIcon( status == DownloadStatus.cancelled) &&
Symbols.play_arrow_rounded, widget.onDelete != null) {
fill: 1, actions.add(_buildActionButton(
size: 20, icon: Symbols.delete_rounded,
), tooltip: 'Delete',
onPressed: () => _resumeAllChildren(node), onPressed: () => widget.onDelete!(globalKey),
tooltip: 'Resume all', ));
), }
// Delete all button return actions;
if (widget.onDelete != null) }
IconButton(
icon: const AppIcon( /// Get action buttons for container nodes (shows/seasons)
Symbols.delete_sweep_rounded, List<Widget> _getContainerActions(DownloadTreeNode node) {
fill: 1, final status = node.status;
size: 20, final actions = <Widget>[];
),
onPressed: () => _deleteAllChildren(node), // Pause all button - show if any children are downloading or queued
tooltip: 'Delete all', 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:plezy/widgets/app_icon.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import '../focus/dpad_navigator.dart';
import '../focus/focusable_chip_mixin.dart'; import '../focus/focusable_chip_mixin.dart';
import '../focus/input_mode_tracker.dart'; import '../focus/input_mode_tracker.dart';
import 'focus_builders.dart'; import 'focus_builders.dart';
@@ -70,36 +69,16 @@ class _FocusableFilterChipState extends State<FocusableFilterChip>
} }
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
if (event is! KeyDownEvent && event is! KeyRepeatEvent) { return handleChipKeyEvent(
return KeyEventResult.ignored; node,
} event,
ChipKeyCallbacks(
// SELECT key activates the chip onSelect: widget.onPressed,
if (event.logicalKey.isSelectKey) { onNavigateDown: widget.onNavigateDown,
widget.onPressed(); onNavigateUp: widget.onNavigateUp,
return KeyEventResult.handled; onBack: widget.onBack,
} ),
);
// 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;
} }
@override @override
+11 -38
View File
@@ -1,7 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import '../focus/dpad_navigator.dart';
import '../focus/focusable_chip_mixin.dart'; import '../focus/focusable_chip_mixin.dart';
import '../focus/input_mode_tracker.dart'; import '../focus/input_mode_tracker.dart';
import 'focus_builders.dart'; import 'focus_builders.dart';
@@ -78,43 +77,17 @@ class _FocusableTabChipState extends State<FocusableTabChip>
} }
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
if (event is! KeyDownEvent && event is! KeyRepeatEvent) { return handleChipKeyEvent(
return KeyEventResult.ignored; node,
} event,
ChipKeyCallbacks(
final key = event.logicalKey; onSelect: widget.onSelect,
onNavigateLeft: widget.onNavigateLeft,
// SELECT key activates the tab onNavigateRight: widget.onNavigateRight,
if (key.isSelectKey) { onNavigateDown: widget.onNavigateDown,
widget.onSelect(); onBack: widget.onBack,
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;
} }
@override @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 /// Calculate top padding for macOS traffic lights
double _getTopPadding(BuildContext context) { double _getTopPadding(BuildContext context) {
double basePadding = MediaQuery.of(context).padding.top + 16; double basePadding = MediaQuery.of(context).padding.top + 16;
@@ -635,7 +620,7 @@ class SideNavigationRailState extends State<SideNavigationRail> {
return NavigationRailItem( return NavigationRailItem(
icon: _getLibraryIcon(library.type), icon: _getLibraryIcon(library.type),
selectedIcon: _getLibraryIconFilled(library.type), selectedIcon: _getLibraryIcon(library.type),
label: SizedBox( label: SizedBox(
height: 32, // Fixed height for consistent item sizing height: 32, // Fixed height for consistent item sizing
child: Column( child: Column(
@@ -181,42 +181,34 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
} }
} }
/// Handle key events for horizontal button navigation /// Handle directional navigation for bottom control row.
KeyEventResult _handleButtonKeyEvent( ///
FocusNode node, /// Returns [KeyEventResult.handled] if the key was processed,
KeyEvent event, /// [KeyEventResult.ignored] otherwise.
int index, /// UP always navigates to timeline.
) { KeyEventResult _handleDirectionalNavigation(
KeyEvent event, {
FocusNode? leftTarget,
FocusNode? rightTarget,
}) {
if (event is! KeyDownEvent && event is! KeyRepeatEvent) { if (event is! KeyDownEvent && event is! KeyRepeatEvent) {
return KeyEventResult.ignored; return KeyEventResult.ignored;
} }
final key = event.logicalKey; final key = event.logicalKey;
// LEFT arrow - move to previous button
if (key == LogicalKeyboardKey.arrowLeft) { if (key == LogicalKeyboardKey.arrowLeft) {
if (index > 0) { leftTarget?.requestFocus();
_buttonFocusNodes[index - 1].requestFocus(); widget.onFocusActivity?.call();
widget.onFocusActivity?.call(); return KeyEventResult.handled;
return KeyEventResult.handled;
}
return KeyEventResult.handled; // At start, consume to prevent bubbling
} }
// RIGHT arrow - move to next button or to volume
if (key == LogicalKeyboardKey.arrowRight) { if (key == LogicalKeyboardKey.arrowRight) {
if (index < _buttonFocusNodes.length - 1) { rightTarget?.requestFocus();
_buttonFocusNodes[index + 1].requestFocus();
widget.onFocusActivity?.call();
return KeyEventResult.handled;
}
// At end of playback buttons - move to volume
_volumeFocusNode.requestFocus();
widget.onFocusActivity?.call(); widget.onFocusActivity?.call();
return KeyEventResult.handled; return KeyEventResult.handled;
} }
// UP arrow - move focus to timeline
if (key == LogicalKeyboardKey.arrowUp) { if (key == LogicalKeyboardKey.arrowUp) {
_timelineFocusNode.requestFocus(); _timelineFocusNode.requestFocus();
widget.onFocusActivity?.call(); widget.onFocusActivity?.call();
@@ -226,38 +218,33 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
return KeyEventResult.ignored; 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 /// Handle key events for volume control navigation
KeyEventResult _handleVolumeKeyEvent(FocusNode node, KeyEvent event) { KeyEventResult _handleVolumeKeyEvent(FocusNode node, KeyEvent event) {
if (event is! KeyDownEvent && event is! KeyRepeatEvent) { return _handleDirectionalNavigation(
return KeyEventResult.ignored; event,
} leftTarget: _nextItemFocusNode,
rightTarget: _trackControlFocusNodes.isNotEmpty
final key = event.logicalKey; ? _trackControlFocusNodes[0]
: null,
// 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;
} }
/// Handle key events for timeline navigation /// 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:material_symbols_icons/symbols.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import '../../../focus/dpad_navigator.dart';
import '../../../mpv/mpv.dart'; import '../../../mpv/mpv.dart';
import '../../../services/settings_service.dart'; import '../../../services/settings_service.dart';
import '../../../i18n/strings.g.dart'; import '../../../i18n/strings.g.dart';
@@ -91,7 +92,7 @@ class _VolumeControlState extends State<VolumeControl> {
_adjustVolume(_volumeStep); _adjustVolume(_volumeStep);
return KeyEventResult.handled; return KeyEventResult.handled;
} }
if (_isBackKey(key) || _isSelectKey(key)) { if (key.isBackKey || key.isSelectKey) {
_exitAdjustMode(); _exitAdjustMode();
return KeyEventResult.handled; return KeyEventResult.handled;
} }
@@ -110,20 +111,6 @@ class _VolumeControlState extends State<VolumeControl> {
return widget.onKeyEvent?.call(node, event) ?? KeyEventResult.ignored; 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) { void _handleFocusChange(bool hasFocus) {
// Exit adjust mode when focus is lost // Exit adjust mode when focus is lost
if (!hasFocus && _isAdjustMode) { if (!hasFocus && _isAdjustMode) {