refactor: deduplicate
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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) {
|
||||||
|
|||||||
@@ -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),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user