refactor: update formatting
This commit is contained in:
@@ -10,3 +10,6 @@ analyzer:
|
||||
dart_code_linter:
|
||||
rules:
|
||||
- avoid-unused-parameters
|
||||
|
||||
formatter:
|
||||
page_width: 120
|
||||
|
||||
@@ -11,9 +11,7 @@ import '../utils/app_logger.dart';
|
||||
part 'app_database.g.dart';
|
||||
|
||||
// Simplified database with API cache for offline support
|
||||
@DriftDatabase(
|
||||
tables: [DownloadedMedia, DownloadQueue, ApiCache, OfflineWatchProgress],
|
||||
)
|
||||
@DriftDatabase(tables: [DownloadedMedia, DownloadQueue, ApiCache, OfflineWatchProgress])
|
||||
class AppDatabase extends _$AppDatabase {
|
||||
AppDatabase() : super(_openConnection());
|
||||
|
||||
@@ -42,15 +40,11 @@ class AppDatabase extends _$AppDatabase {
|
||||
|
||||
/// Get all pending offline watch actions for sync
|
||||
Future<List<OfflineWatchProgressItem>> getPendingWatchActions() {
|
||||
return (select(
|
||||
offlineWatchProgress,
|
||||
)..orderBy([(t) => OrderingTerm.asc(t.createdAt)])).get();
|
||||
return (select(offlineWatchProgress)..orderBy([(t) => OrderingTerm.asc(t.createdAt)])).get();
|
||||
}
|
||||
|
||||
/// Get pending watch actions for a specific server
|
||||
Future<List<OfflineWatchProgressItem>> getPendingWatchActionsForServer(
|
||||
String serverId,
|
||||
) {
|
||||
Future<List<OfflineWatchProgressItem>> getPendingWatchActionsForServer(String serverId) {
|
||||
return (select(offlineWatchProgress)
|
||||
..where((t) => t.serverId.equals(serverId))
|
||||
..orderBy([(t) => OrderingTerm.asc(t.createdAt)]))
|
||||
@@ -70,9 +64,7 @@ class AppDatabase extends _$AppDatabase {
|
||||
///
|
||||
/// Returns a map of globalKey -> latest action for each key.
|
||||
/// Keys with no actions will not be present in the returned map.
|
||||
Future<Map<String, OfflineWatchProgressItem>> getLatestWatchActionsForKeys(
|
||||
Set<String> globalKeys,
|
||||
) async {
|
||||
Future<Map<String, OfflineWatchProgressItem>> getLatestWatchActionsForKeys(Set<String> globalKeys) async {
|
||||
if (globalKeys.isEmpty) return {};
|
||||
|
||||
// Query all actions for the given keys
|
||||
@@ -106,19 +98,13 @@ class AppDatabase extends _$AppDatabase {
|
||||
// Check for existing progress entry
|
||||
final existing =
|
||||
await (select(offlineWatchProgress)
|
||||
..where(
|
||||
(t) =>
|
||||
t.globalKey.equals(globalKey) &
|
||||
t.actionType.equals('progress'),
|
||||
)
|
||||
..where((t) => t.globalKey.equals(globalKey) & t.actionType.equals('progress'))
|
||||
..limit(1))
|
||||
.getSingleOrNull();
|
||||
|
||||
if (existing != null) {
|
||||
// Update existing progress entry
|
||||
await (update(
|
||||
offlineWatchProgress,
|
||||
)..where((t) => t.id.equals(existing.id))).write(
|
||||
await (update(offlineWatchProgress)..where((t) => t.id.equals(existing.id))).write(
|
||||
OfflineWatchProgressCompanion(
|
||||
viewOffset: Value(viewOffset),
|
||||
duration: Value(duration),
|
||||
@@ -155,9 +141,7 @@ class AppDatabase extends _$AppDatabase {
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
|
||||
// Remove conflicting actions (opposite action type and progress)
|
||||
await (delete(
|
||||
offlineWatchProgress,
|
||||
)..where((t) => t.globalKey.equals(globalKey))).go();
|
||||
await (delete(offlineWatchProgress)..where((t) => t.globalKey.equals(globalKey))).go();
|
||||
|
||||
// Insert the new action
|
||||
await into(offlineWatchProgress).insert(
|
||||
@@ -179,27 +163,20 @@ class AppDatabase extends _$AppDatabase {
|
||||
|
||||
/// Update sync attempt count and error message
|
||||
Future<void> updateSyncAttempt(int id, String? errorMessage) async {
|
||||
final existing = await (select(
|
||||
offlineWatchProgress,
|
||||
)..where((t) => t.id.equals(id))).getSingleOrNull();
|
||||
final existing = await (select(offlineWatchProgress)..where((t) => t.id.equals(id))).getSingleOrNull();
|
||||
|
||||
if (existing != null) {
|
||||
await (update(offlineWatchProgress)..where((t) => t.id.equals(id))).write(
|
||||
OfflineWatchProgressCompanion(
|
||||
syncAttempts: Value(existing.syncAttempts + 1),
|
||||
lastError: Value(errorMessage),
|
||||
),
|
||||
OfflineWatchProgressCompanion(syncAttempts: Value(existing.syncAttempts + 1), lastError: Value(errorMessage)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get count of pending sync items
|
||||
Future<int> getPendingSyncCount() async {
|
||||
final count =
|
||||
await (selectOnly(offlineWatchProgress)
|
||||
..addColumns([offlineWatchProgress.id.count()]))
|
||||
.map((row) => row.read(offlineWatchProgress.id.count()))
|
||||
.getSingle();
|
||||
final count = await (selectOnly(offlineWatchProgress)..addColumns([offlineWatchProgress.id.count()]))
|
||||
.map((row) => row.read(offlineWatchProgress.id.count()))
|
||||
.getSingle();
|
||||
return count ?? 0;
|
||||
}
|
||||
|
||||
@@ -214,9 +191,7 @@ class AppDatabase extends _$AppDatabase {
|
||||
|
||||
/// Get all downloaded media items (for syncing watch states)
|
||||
Future<List<DownloadedMediaItem>> getAllDownloadedMetadata() {
|
||||
return (select(
|
||||
downloadedMedia,
|
||||
)..where((t) => t.status.equals(DownloadStatus.completed.index))).get();
|
||||
return (select(downloadedMedia)..where((t) => t.status.equals(DownloadStatus.completed.index))).get();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+400
-1122
File diff suppressed because it is too large
Load Diff
@@ -25,10 +25,8 @@ class DownloadQueue extends Table {
|
||||
TextColumn get mediaGlobalKey => text().unique()();
|
||||
IntColumn get priority => integer().withDefault(const Constant(0))();
|
||||
IntColumn get addedAt => integer()();
|
||||
BoolColumn get downloadSubtitles =>
|
||||
boolean().withDefault(const Constant(true))();
|
||||
BoolColumn get downloadArtwork =>
|
||||
boolean().withDefault(const Constant(true))();
|
||||
BoolColumn get downloadSubtitles => boolean().withDefault(const Constant(true))();
|
||||
BoolColumn get downloadArtwork => boolean().withDefault(const Constant(true))();
|
||||
}
|
||||
|
||||
@DataClassName('DownloadedMediaItem')
|
||||
@@ -80,8 +78,7 @@ class OfflineWatchProgress extends Table {
|
||||
|
||||
/// Whether this item should be marked as watched (for progress sync)
|
||||
/// Auto-set to true when viewOffset >= 90% of duration
|
||||
BoolColumn get shouldMarkWatched =>
|
||||
boolean().withDefault(const Constant(false))();
|
||||
BoolColumn get shouldMarkWatched => boolean().withDefault(const Constant(false))();
|
||||
|
||||
/// Timestamp when this action was recorded (milliseconds since epoch)
|
||||
IntColumn get createdAt => integer()();
|
||||
|
||||
@@ -29,10 +29,7 @@ final _backKeys = {
|
||||
LogicalKeyboardKey.gameButtonB,
|
||||
};
|
||||
|
||||
final _contextMenuKeys = {
|
||||
LogicalKeyboardKey.contextMenu,
|
||||
LogicalKeyboardKey.gameButtonX,
|
||||
};
|
||||
final _contextMenuKeys = {LogicalKeyboardKey.contextMenu, LogicalKeyboardKey.gameButtonX};
|
||||
|
||||
/// Extension methods for checking D-pad related keys.
|
||||
extension DpadKeyExtension on LogicalKeyboardKey {
|
||||
|
||||
@@ -21,8 +21,7 @@ class FocusTheme {
|
||||
|
||||
/// Get the animation duration from MonoTokens.
|
||||
static Duration getAnimationDuration(BuildContext context) {
|
||||
return Theme.of(context).extension<MonoTokens>()?.fast ??
|
||||
const Duration(milliseconds: 150);
|
||||
return Theme.of(context).extension<MonoTokens>()?.fast ?? const Duration(milliseconds: 150);
|
||||
}
|
||||
|
||||
/// Build the focus border decoration.
|
||||
@@ -35,24 +34,16 @@ class FocusTheme {
|
||||
|
||||
return BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
border: Border.all(
|
||||
color: isFocused ? focusColor : Colors.transparent,
|
||||
width: focusBorderWidth,
|
||||
),
|
||||
border: Border.all(color: isFocused ? focusColor : Colors.transparent, width: focusBorderWidth),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build focus decoration with background color instead of border.
|
||||
/// Useful for video controls where it should match the native hover style.
|
||||
static BoxDecoration focusBackgroundDecoration({
|
||||
required bool isFocused,
|
||||
double borderRadius = defaultBorderRadius,
|
||||
}) {
|
||||
static BoxDecoration focusBackgroundDecoration({required bool isFocused, double borderRadius = defaultBorderRadius}) {
|
||||
return BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
color: isFocused
|
||||
? Colors.white.withValues(alpha: 0.2)
|
||||
: Colors.transparent,
|
||||
color: isFocused ? Colors.white.withValues(alpha: 0.2) : Colors.transparent,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,8 +61,7 @@ mixin FocusableChipStateMixin<T extends StatefulWidget> on State<T> {
|
||||
|
||||
/// The active focus node (external if provided, otherwise internal).
|
||||
FocusNode get focusNode {
|
||||
return widgetFocusNode ??
|
||||
(_internalFocusNode ??= FocusNode(debugLabel: debugLabel));
|
||||
return widgetFocusNode ?? (_internalFocusNode ??= FocusNode(debugLabel: debugLabel));
|
||||
}
|
||||
|
||||
/// Whether this widget is currently focused.
|
||||
@@ -102,11 +101,7 @@ mixin FocusableChipStateMixin<T extends StatefulWidget> on State<T> {
|
||||
///
|
||||
/// Returns [KeyEventResult.handled] if the event was consumed,
|
||||
/// [KeyEventResult.ignored] otherwise.
|
||||
KeyEventResult handleChipKeyEvent(
|
||||
FocusNode node,
|
||||
KeyEvent event,
|
||||
ChipKeyCallbacks callbacks,
|
||||
) {
|
||||
KeyEventResult handleChipKeyEvent(FocusNode node, KeyEvent event, ChipKeyCallbacks callbacks) {
|
||||
if (!event.isActionable) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
@@ -107,8 +107,7 @@ class FocusableWrapper extends StatefulWidget {
|
||||
State<FocusableWrapper> createState() => _FocusableWrapperState();
|
||||
}
|
||||
|
||||
class _FocusableWrapperState extends State<FocusableWrapper>
|
||||
with SingleTickerProviderStateMixin {
|
||||
class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerProviderStateMixin {
|
||||
late FocusNode _focusNode;
|
||||
bool _ownsNode = false;
|
||||
bool _isFocused = false;
|
||||
@@ -141,18 +140,12 @@ class _FocusableWrapperState extends State<FocusableWrapper>
|
||||
}
|
||||
|
||||
void _initAnimations() {
|
||||
_animationController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 150),
|
||||
);
|
||||
_animationController = AnimationController(vsync: this, duration: const Duration(milliseconds: 150));
|
||||
|
||||
_scaleAnimation = Tween<double>(begin: 1.0, end: FocusTheme.focusScale)
|
||||
.animate(
|
||||
CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.easeOutCubic,
|
||||
),
|
||||
);
|
||||
_scaleAnimation = Tween<double>(
|
||||
begin: 1.0,
|
||||
end: FocusTheme.focusScale,
|
||||
).animate(CurvedAnimation(parent: _animationController, curve: Curves.easeOutCubic));
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -223,10 +216,7 @@ class _FocusableWrapperState extends State<FocusableWrapper>
|
||||
|
||||
// Get item's position relative to viewport
|
||||
final itemBox = renderObject as RenderBox;
|
||||
final itemPosition = itemBox.localToGlobal(
|
||||
Offset.zero,
|
||||
ancestor: viewport,
|
||||
);
|
||||
final itemPosition = itemBox.localToGlobal(Offset.zero, ancestor: viewport);
|
||||
|
||||
// Check if item is already in the comfortable zone
|
||||
final viewportHeight = viewport.size.height;
|
||||
@@ -237,8 +227,7 @@ class _FocusableWrapperState extends State<FocusableWrapper>
|
||||
final comfortZoneTop = viewportHeight * 0.2;
|
||||
final comfortZoneBottom = viewportHeight * 0.8;
|
||||
|
||||
if (itemVerticalCenter >= comfortZoneTop &&
|
||||
itemVerticalCenter <= comfortZoneBottom) {
|
||||
if (itemVerticalCenter >= comfortZoneTop && itemVerticalCenter <= comfortZoneBottom) {
|
||||
// Item is in comfortable zone, no need to scroll
|
||||
return;
|
||||
}
|
||||
@@ -343,15 +332,8 @@ class _FocusableWrapperState extends State<FocusableWrapper>
|
||||
|
||||
// Choose decoration based on useBackgroundFocus
|
||||
final decoration = widget.useBackgroundFocus
|
||||
? FocusTheme.focusBackgroundDecoration(
|
||||
isFocused: showFocus,
|
||||
borderRadius: widget.borderRadius,
|
||||
)
|
||||
: FocusTheme.focusDecoration(
|
||||
context,
|
||||
isFocused: showFocus,
|
||||
borderRadius: widget.borderRadius,
|
||||
);
|
||||
? FocusTheme.focusBackgroundDecoration(isFocused: showFocus, borderRadius: widget.borderRadius)
|
||||
: FocusTheme.focusDecoration(context, isFocused: showFocus, borderRadius: widget.borderRadius);
|
||||
|
||||
Widget result = Focus(
|
||||
focusNode: _focusNode,
|
||||
@@ -377,11 +359,7 @@ class _FocusableWrapperState extends State<FocusableWrapper>
|
||||
|
||||
// Add semantics if label provided
|
||||
if (widget.semanticLabel != null) {
|
||||
result = Semantics(
|
||||
label: widget.semanticLabel,
|
||||
button: widget.onSelect != null,
|
||||
child: result,
|
||||
);
|
||||
result = Semantics(label: widget.semanticLabel, button: widget.onSelect != null, child: result);
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
@@ -29,8 +29,7 @@ class InputModeTracker extends StatefulWidget {
|
||||
|
||||
/// Get the current input mode.
|
||||
static InputMode of(BuildContext context) {
|
||||
final provider = context
|
||||
.dependOnInheritedWidgetOfExactType<_InputModeProvider>();
|
||||
final provider = context.dependOnInheritedWidgetOfExactType<_InputModeProvider>();
|
||||
return provider?.mode ?? InputMode.pointer;
|
||||
}
|
||||
|
||||
@@ -45,9 +44,7 @@ class InputModeTracker extends StatefulWidget {
|
||||
|
||||
class _InputModeTrackerState extends State<InputModeTracker> {
|
||||
// Default to keyboard mode on Android TV, pointer mode elsewhere
|
||||
InputMode _mode = TvDetectionService.isTVSync()
|
||||
? InputMode.keyboard
|
||||
: InputMode.pointer;
|
||||
InputMode _mode = TvDetectionService.isTVSync() ? InputMode.keyboard : InputMode.pointer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
||||
@@ -28,11 +28,7 @@ import 'dpad_navigator.dart';
|
||||
/// child: ...
|
||||
/// )
|
||||
/// ```
|
||||
KeyEventResult handleBackKeyNavigation<T>(
|
||||
BuildContext context,
|
||||
KeyEvent event, {
|
||||
T? result,
|
||||
}) {
|
||||
KeyEventResult handleBackKeyNavigation<T>(BuildContext context, KeyEvent event, {T? result}) {
|
||||
if (event is KeyDownEvent && event.logicalKey.isBackKey) {
|
||||
Navigator.pop(context, result);
|
||||
return KeyEventResult.handled;
|
||||
|
||||
+11402
-6167
File diff suppressed because it is too large
Load Diff
+28
-80
@@ -125,15 +125,9 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
|
||||
// Initialize API cache with database
|
||||
PlexApiCache.initialize(_appDatabase);
|
||||
|
||||
_downloadManager = DownloadManagerService(
|
||||
database: _appDatabase,
|
||||
storageService: DownloadStorageService.instance,
|
||||
);
|
||||
_downloadManager = DownloadManagerService(database: _appDatabase, storageService: DownloadStorageService.instance);
|
||||
|
||||
_offlineWatchSyncService = OfflineWatchSyncService(
|
||||
database: _appDatabase,
|
||||
serverManager: _serverManager,
|
||||
);
|
||||
_offlineWatchSyncService = OfflineWatchSyncService(database: _appDatabase, serverManager: _serverManager);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -157,10 +151,7 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
|
||||
// Legacy provider for backward compatibility
|
||||
ChangeNotifierProvider(create: (context) => PlexClientProvider()),
|
||||
// New multi-server providers
|
||||
ChangeNotifierProvider(
|
||||
create: (context) =>
|
||||
MultiServerProvider(_serverManager, _aggregationService),
|
||||
),
|
||||
ChangeNotifierProvider(create: (context) => MultiServerProvider(_serverManager, _aggregationService)),
|
||||
ChangeNotifierProvider(create: (context) => ServerStateProvider()),
|
||||
// Offline mode provider - depends on MultiServerProvider
|
||||
ChangeNotifierProxyProvider<MultiServerProvider, OfflineModeProvider>(
|
||||
@@ -176,10 +167,7 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
|
||||
},
|
||||
),
|
||||
// Download provider
|
||||
ChangeNotifierProvider(
|
||||
create: (context) =>
|
||||
DownloadProvider(downloadManager: _downloadManager),
|
||||
),
|
||||
ChangeNotifierProvider(create: (context) => DownloadProvider(downloadManager: _downloadManager)),
|
||||
// Offline watch sync service
|
||||
ChangeNotifierProvider<OfflineWatchSyncService>(
|
||||
create: (context) {
|
||||
@@ -191,18 +179,12 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
|
||||
downloadProvider.refreshMetadataFromCache();
|
||||
};
|
||||
|
||||
_offlineWatchSyncService.startConnectivityMonitoring(
|
||||
offlineModeProvider,
|
||||
);
|
||||
_offlineWatchSyncService.startConnectivityMonitoring(offlineModeProvider);
|
||||
return _offlineWatchSyncService;
|
||||
},
|
||||
),
|
||||
// Offline watch provider - depends on sync service and download provider
|
||||
ChangeNotifierProxyProvider2<
|
||||
OfflineWatchSyncService,
|
||||
DownloadProvider,
|
||||
OfflineWatchProvider
|
||||
>(
|
||||
ChangeNotifierProxyProvider2<OfflineWatchSyncService, DownloadProvider, OfflineWatchProvider>(
|
||||
create: (context) => OfflineWatchProvider(
|
||||
syncService: _offlineWatchSyncService,
|
||||
downloadProvider: context.read<DownloadProvider>(),
|
||||
@@ -220,14 +202,8 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
|
||||
// Existing providers
|
||||
ChangeNotifierProvider(create: (context) => UserProfileProvider()),
|
||||
ChangeNotifierProvider(create: (context) => ThemeProvider()),
|
||||
ChangeNotifierProvider(
|
||||
create: (context) => SettingsProvider(),
|
||||
lazy: true,
|
||||
),
|
||||
ChangeNotifierProvider(
|
||||
create: (context) => HiddenLibrariesProvider(),
|
||||
lazy: true,
|
||||
),
|
||||
ChangeNotifierProvider(create: (context) => SettingsProvider(), lazy: true),
|
||||
ChangeNotifierProvider(create: (context) => HiddenLibrariesProvider(), lazy: true),
|
||||
ChangeNotifierProvider(create: (context) => PlaybackStateProvider()),
|
||||
],
|
||||
child: Consumer<ThemeProvider>(
|
||||
@@ -374,20 +350,14 @@ class _SetupScreenState extends State<SetupScreen> {
|
||||
if (servers.isEmpty) {
|
||||
// No servers configured - show auth screen
|
||||
if (mounted) {
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const AuthScreen()),
|
||||
);
|
||||
Navigator.pushReplacement(context, MaterialPageRoute(builder: (context) => const AuthScreen()));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Get multi-server provider
|
||||
if (!mounted) return;
|
||||
final multiServerProvider = Provider.of<MultiServerProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
final multiServerProvider = Provider.of<MultiServerProvider>(context, listen: false);
|
||||
|
||||
try {
|
||||
appLogger.i('Connecting to ${servers.length} enabled servers...');
|
||||
@@ -396,22 +366,18 @@ class _SetupScreenState extends State<SetupScreen> {
|
||||
final clientId = storage.getClientIdentifier();
|
||||
|
||||
// Connect to all servers in parallel
|
||||
final connectedCount = await multiServerProvider.serverManager
|
||||
.connectToAllServers(
|
||||
servers,
|
||||
clientIdentifier: clientId,
|
||||
timeout: const Duration(seconds: 10),
|
||||
onServerConnected: (serverId, client) {
|
||||
// Set first connected client in legacy provider for backward compatibility
|
||||
final legacyProvider = Provider.of<PlexClientProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
if (legacyProvider.client == null) {
|
||||
legacyProvider.setClient(client);
|
||||
}
|
||||
},
|
||||
);
|
||||
final connectedCount = await multiServerProvider.serverManager.connectToAllServers(
|
||||
servers,
|
||||
clientIdentifier: clientId,
|
||||
timeout: const Duration(seconds: 10),
|
||||
onServerConnected: (serverId, client) {
|
||||
// Set first connected client in legacy provider for backward compatibility
|
||||
final legacyProvider = Provider.of<PlexClientProvider>(context, listen: false);
|
||||
if (legacyProvider.client == null) {
|
||||
legacyProvider.setClient(client);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (connectedCount > 0) {
|
||||
// At least one server connected successfully
|
||||
@@ -423,15 +389,9 @@ class _SetupScreenState extends State<SetupScreen> {
|
||||
|
||||
// Navigate to main screen immediately
|
||||
// Get first connected client for backward compatibility
|
||||
final firstClient =
|
||||
multiServerProvider.serverManager.onlineClients.values.first;
|
||||
final firstClient = multiServerProvider.serverManager.onlineClients.values.first;
|
||||
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => MainScreen(client: firstClient),
|
||||
),
|
||||
);
|
||||
Navigator.pushReplacement(context, MaterialPageRoute(builder: (context) => MainScreen(client: firstClient)));
|
||||
|
||||
// Check for updates in background after navigation
|
||||
_checkForUpdatesOnStartup();
|
||||
@@ -445,26 +405,18 @@ class _SetupScreenState extends State<SetupScreen> {
|
||||
// User can still access Downloads and Settings
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const MainScreen(isOfflineMode: true),
|
||||
),
|
||||
MaterialPageRoute(builder: (context) => const MainScreen(isOfflineMode: true)),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
appLogger.e(
|
||||
'Error during multi-server connection',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
appLogger.e('Error during multi-server connection', error: e, stackTrace: stackTrace);
|
||||
|
||||
if (mounted) {
|
||||
// Navigate to MainScreen in offline mode
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const MainScreen(isOfflineMode: true),
|
||||
),
|
||||
MaterialPageRoute(builder: (context) => const MainScreen(isOfflineMode: true)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -476,11 +428,7 @@ class _SetupScreenState extends State<SetupScreen> {
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const CircularProgressIndicator(),
|
||||
const SizedBox(height: 16),
|
||||
Text(t.app.loading),
|
||||
],
|
||||
children: [const CircularProgressIndicator(), const SizedBox(height: 16), Text(t.app.loading)],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -18,8 +18,7 @@ class DownloadProgress {
|
||||
final int totalBytes;
|
||||
final double speed; // bytes per second
|
||||
final String? errorMessage;
|
||||
final String?
|
||||
currentFile; // What's being downloaded (video, subtitles, artwork)
|
||||
final String? currentFile; // What's being downloaded (video, subtitles, artwork)
|
||||
|
||||
// Thumbnail path (populated after artwork download completes)
|
||||
final String? thumbPath;
|
||||
@@ -92,8 +91,7 @@ class DeletionProgress {
|
||||
this.currentOperation,
|
||||
});
|
||||
|
||||
double get progressPercent =>
|
||||
totalItems > 0 ? (currentItem / totalItems) : 0.0;
|
||||
double get progressPercent => totalItems > 0 ? (currentItem / totalItems) : 0.0;
|
||||
|
||||
int get progressPercentInt => (progressPercent * 100).round();
|
||||
|
||||
|
||||
@@ -49,11 +49,7 @@ class PlayQueueResponse {
|
||||
this.items,
|
||||
});
|
||||
|
||||
factory PlayQueueResponse.fromJson(
|
||||
Map<String, dynamic> json, {
|
||||
String? serverId,
|
||||
String? serverName,
|
||||
}) {
|
||||
factory PlayQueueResponse.fromJson(Map<String, dynamic> json, {String? serverId, String? serverName}) {
|
||||
// The API returns data wrapped in MediaContainer
|
||||
final container = json['MediaContainer'] as Map<String, dynamic>? ?? json;
|
||||
final response = _$PlayQueueResponseFromJson(container);
|
||||
@@ -61,16 +57,13 @@ class PlayQueueResponse {
|
||||
// Tag all items with server info
|
||||
if (response.items != null && (serverId != null || serverName != null)) {
|
||||
final taggedItems = response.items!
|
||||
.map(
|
||||
(item) => item.copyWith(serverId: serverId, serverName: serverName),
|
||||
)
|
||||
.map((item) => item.copyWith(serverId: serverId, serverName: serverName))
|
||||
.toList();
|
||||
return PlayQueueResponse(
|
||||
playQueueID: response.playQueueID,
|
||||
playQueueSelectedItemID: response.playQueueSelectedItemID,
|
||||
playQueueSelectedItemOffset: response.playQueueSelectedItemOffset,
|
||||
playQueueSelectedMetadataItemID:
|
||||
response.playQueueSelectedMetadataItemID,
|
||||
playQueueSelectedMetadataItemID: response.playQueueSelectedMetadataItemID,
|
||||
playQueueShuffled: response.playQueueShuffled,
|
||||
playQueueSourceURI: response.playQueueSourceURI,
|
||||
playQueueTotalCount: response.playQueueTotalCount,
|
||||
@@ -87,9 +80,7 @@ class PlayQueueResponse {
|
||||
PlexMetadata? get selectedItem {
|
||||
if (items == null || playQueueSelectedItemID == null) return null;
|
||||
try {
|
||||
return items!.firstWhere(
|
||||
(item) => item.playQueueItemID == playQueueSelectedItemID,
|
||||
);
|
||||
return items!.firstWhere((item) => item.playQueueItemID == playQueueSelectedItemID);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
@@ -98,8 +89,6 @@ class PlayQueueResponse {
|
||||
/// Get the index of the selected item in the current window
|
||||
int? get selectedItemIndex {
|
||||
if (items == null || playQueueSelectedItemID == null) return null;
|
||||
return items!.indexWhere(
|
||||
(item) => item.playQueueItemID == playQueueSelectedItemID,
|
||||
);
|
||||
return items!.indexWhere((item) => item.playQueueItemID == playQueueSelectedItemID);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,23 +6,15 @@ part of 'play_queue_response.dart';
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
PlayQueueResponse _$PlayQueueResponseFromJson(Map<String, dynamic> json) =>
|
||||
PlayQueueResponse(
|
||||
playQueueID: (json['playQueueID'] as num).toInt(),
|
||||
playQueueSelectedItemID: (json['playQueueSelectedItemID'] as num?)
|
||||
?.toInt(),
|
||||
playQueueSelectedItemOffset: (json['playQueueSelectedItemOffset'] as num?)
|
||||
?.toInt(),
|
||||
playQueueSelectedMetadataItemID:
|
||||
json['playQueueSelectedMetadataItemID'] as String?,
|
||||
playQueueShuffled: const BoolOrIntConverter().fromJson(
|
||||
json['playQueueShuffled'] as Object,
|
||||
),
|
||||
playQueueSourceURI: json['playQueueSourceURI'] as String?,
|
||||
playQueueTotalCount: (json['playQueueTotalCount'] as num?)?.toInt(),
|
||||
playQueueVersion: (json['playQueueVersion'] as num).toInt(),
|
||||
size: (json['size'] as num?)?.toInt(),
|
||||
items: (json['Metadata'] as List<dynamic>?)
|
||||
?.map((e) => PlexMetadata.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
PlayQueueResponse _$PlayQueueResponseFromJson(Map<String, dynamic> json) => PlayQueueResponse(
|
||||
playQueueID: (json['playQueueID'] as num).toInt(),
|
||||
playQueueSelectedItemID: (json['playQueueSelectedItemID'] as num?)?.toInt(),
|
||||
playQueueSelectedItemOffset: (json['playQueueSelectedItemOffset'] as num?)?.toInt(),
|
||||
playQueueSelectedMetadataItemID: json['playQueueSelectedMetadataItemID'] as String?,
|
||||
playQueueShuffled: const BoolOrIntConverter().fromJson(json['playQueueShuffled'] as Object),
|
||||
playQueueSourceURI: json['playQueueSourceURI'] as String?,
|
||||
playQueueTotalCount: (json['playQueueTotalCount'] as num?)?.toInt(),
|
||||
playQueueVersion: (json['playQueueVersion'] as num).toInt(),
|
||||
size: (json['size'] as num?)?.toInt(),
|
||||
items: (json['Metadata'] as List<dynamic>?)?.map((e) => PlexMetadata.fromJson(e as Map<String, dynamic>)).toList(),
|
||||
);
|
||||
|
||||
@@ -119,8 +119,7 @@ class PlexFileInfo {
|
||||
/// Format audio channels (e.g., "2 channels (stereo)")
|
||||
String get audioChannelsFormatted {
|
||||
if (audioChannels != null) {
|
||||
String channelText =
|
||||
'$audioChannels channel${audioChannels! > 1 ? 's' : ''}';
|
||||
String channelText = '$audioChannels channel${audioChannels! > 1 ? 's' : ''}';
|
||||
if (audioChannelLayout != null) {
|
||||
channelText += ' ($audioChannelLayout)';
|
||||
}
|
||||
|
||||
@@ -24,13 +24,7 @@ class PlexFilter {
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'filter': filter,
|
||||
'filterType': filterType,
|
||||
'key': key,
|
||||
'title': title,
|
||||
'type': type,
|
||||
};
|
||||
return {'filter': filter, 'filterType': filterType, 'key': key, 'title': title, 'type': type};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,11 +36,7 @@ class PlexFilterValue {
|
||||
PlexFilterValue({required this.key, required this.title, this.type});
|
||||
|
||||
factory PlexFilterValue.fromJson(Map<String, dynamic> json) {
|
||||
return PlexFilterValue(
|
||||
key: json['key'] ?? '',
|
||||
title: json['title'] ?? '',
|
||||
type: json['type'],
|
||||
);
|
||||
return PlexFilterValue(key: json['key'] ?? '', title: json['title'] ?? '', type: json['type']);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
|
||||
@@ -21,11 +21,7 @@ class PlexHome {
|
||||
|
||||
factory PlexHome.fromJson(Map<String, dynamic> json) {
|
||||
final List<dynamic> usersJson = json['users'] as List<dynamic>;
|
||||
final users = usersJson
|
||||
.map(
|
||||
(userJson) => PlexHomeUser.fromJson(userJson as Map<String, dynamic>),
|
||||
)
|
||||
.toList();
|
||||
final users = usersJson.map((userJson) => PlexHomeUser.fromJson(userJson as Map<String, dynamic>)).toList();
|
||||
|
||||
return PlexHome(
|
||||
id: (json['id'] as num?)?.toInt() ?? 0,
|
||||
@@ -52,11 +48,9 @@ class PlexHome {
|
||||
|
||||
PlexHomeUser? get adminUser => users.where((user) => user.admin).firstOrNull;
|
||||
|
||||
List<PlexHomeUser> get managedUsers =>
|
||||
users.where((user) => !user.admin).toList();
|
||||
List<PlexHomeUser> get managedUsers => users.where((user) => !user.admin).toList();
|
||||
|
||||
List<PlexHomeUser> get restrictedUsers =>
|
||||
users.where((user) => user.restricted).toList();
|
||||
List<PlexHomeUser> get restrictedUsers => users.where((user) => user.restricted).toList();
|
||||
|
||||
PlexHomeUser? getUserByUUID(String uuid) {
|
||||
try {
|
||||
|
||||
@@ -43,8 +43,7 @@ class PlexLibrary with MultiServerFields {
|
||||
this.serverName,
|
||||
});
|
||||
|
||||
factory PlexLibrary.fromJson(Map<String, dynamic> json) =>
|
||||
_$PlexLibraryFromJson(json);
|
||||
factory PlexLibrary.fromJson(Map<String, dynamic> json) => _$PlexLibraryFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$PlexLibraryToJson(this);
|
||||
|
||||
|
||||
@@ -19,16 +19,15 @@ PlexLibrary _$PlexLibraryFromJson(Map<String, dynamic> json) => PlexLibrary(
|
||||
hidden: (json['hidden'] as num?)?.toInt(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$PlexLibraryToJson(PlexLibrary instance) =>
|
||||
<String, dynamic>{
|
||||
'key': instance.key,
|
||||
'title': instance.title,
|
||||
'type': instance.type,
|
||||
'agent': instance.agent,
|
||||
'scanner': instance.scanner,
|
||||
'language': instance.language,
|
||||
'uuid': instance.uuid,
|
||||
'updatedAt': instance.updatedAt,
|
||||
'createdAt': instance.createdAt,
|
||||
'hidden': instance.hidden,
|
||||
};
|
||||
Map<String, dynamic> _$PlexLibraryToJson(PlexLibrary instance) => <String, dynamic>{
|
||||
'key': instance.key,
|
||||
'title': instance.title,
|
||||
'type': instance.type,
|
||||
'agent': instance.agent,
|
||||
'scanner': instance.scanner,
|
||||
'language': instance.language,
|
||||
'uuid': instance.uuid,
|
||||
'updatedAt': instance.updatedAt,
|
||||
'createdAt': instance.createdAt,
|
||||
'hidden': instance.hidden,
|
||||
};
|
||||
|
||||
@@ -135,20 +135,12 @@ class PlexChapter {
|
||||
final String? title;
|
||||
final String? thumb;
|
||||
|
||||
PlexChapter({
|
||||
required this.id,
|
||||
this.index,
|
||||
this.startTimeOffset,
|
||||
this.endTimeOffset,
|
||||
this.title,
|
||||
this.thumb,
|
||||
});
|
||||
PlexChapter({required this.id, this.index, this.startTimeOffset, this.endTimeOffset, this.title, this.thumb});
|
||||
|
||||
String get label => title ?? 'Chapter ${(index ?? 0) + 1}';
|
||||
|
||||
Duration get startTime => Duration(milliseconds: startTimeOffset ?? 0);
|
||||
Duration? get endTime =>
|
||||
endTimeOffset != null ? Duration(milliseconds: endTimeOffset!) : null;
|
||||
Duration? get endTime => endTimeOffset != null ? Duration(milliseconds: endTimeOffset!) : null;
|
||||
}
|
||||
|
||||
class PlexMarker {
|
||||
@@ -157,12 +149,7 @@ class PlexMarker {
|
||||
final int startTimeOffset;
|
||||
final int endTimeOffset;
|
||||
|
||||
PlexMarker({
|
||||
required this.id,
|
||||
required this.type,
|
||||
required this.startTimeOffset,
|
||||
required this.endTimeOffset,
|
||||
});
|
||||
PlexMarker({required this.id, required this.type, required this.startTimeOffset, required this.endTimeOffset});
|
||||
|
||||
Duration get startTime => Duration(milliseconds: startTimeOffset);
|
||||
Duration get endTime => Duration(milliseconds: endTimeOffset);
|
||||
|
||||
@@ -26,9 +26,7 @@ class PlexMediaVersion {
|
||||
factory PlexMediaVersion.fromJson(Map<String, dynamic> json) {
|
||||
// Get the first Part key for playback
|
||||
final parts = json['Part'] as List<dynamic>?;
|
||||
final partKey = parts != null && parts.isNotEmpty
|
||||
? parts[0]['key'] as String? ?? ''
|
||||
: '';
|
||||
final partKey = parts != null && parts.isNotEmpty ? parts[0]['key'] as String? ?? '' : '';
|
||||
|
||||
return PlexMediaVersion(
|
||||
id: json['id'] as int? ?? 0,
|
||||
|
||||
@@ -271,8 +271,7 @@ class PlexMetadata with MultiServerFields {
|
||||
final itemType = type.toLowerCase();
|
||||
|
||||
// For episodes and seasons, prefer grandparent title (show name)
|
||||
if ((itemType == 'episode' || itemType == 'season') &&
|
||||
grandparentTitle != null) {
|
||||
if ((itemType == 'episode' || itemType == 'season') && grandparentTitle != null) {
|
||||
return grandparentTitle!;
|
||||
}
|
||||
// For seasons without grandparent, check if this IS the show (parentTitle might have show name)
|
||||
@@ -288,8 +287,7 @@ class PlexMetadata with MultiServerFields {
|
||||
|
||||
if (itemType == 'episode' || itemType == 'season') {
|
||||
// If we showed grandparent/parent as title, show this item's title as subtitle
|
||||
if (grandparentTitle != null ||
|
||||
(itemType == 'season' && parentTitle != null)) {
|
||||
if (grandparentTitle != null || (itemType == 'season' && parentTitle != null)) {
|
||||
return title;
|
||||
}
|
||||
}
|
||||
@@ -328,8 +326,7 @@ class PlexMetadata with MultiServerFields {
|
||||
return viewCount != null && viewCount! > 0;
|
||||
}
|
||||
|
||||
factory PlexMetadata.fromJson(Map<String, dynamic> json) =>
|
||||
_$PlexMetadataFromJson(json);
|
||||
factory PlexMetadata.fromJson(Map<String, dynamic> json) => _$PlexMetadataFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$PlexMetadataToJson(this);
|
||||
}
|
||||
|
||||
@@ -39,9 +39,7 @@ PlexMetadata _$PlexMetadataFromJson(Map<String, dynamic> json) => PlexMetadata(
|
||||
leafCount: (json['leafCount'] as num?)?.toInt(),
|
||||
viewedLeafCount: (json['viewedLeafCount'] as num?)?.toInt(),
|
||||
childCount: (json['childCount'] as num?)?.toInt(),
|
||||
role: (json['Role'] as List<dynamic>?)
|
||||
?.map((e) => PlexRole.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
role: (json['Role'] as List<dynamic>?)?.map((e) => PlexRole.fromJson(e as Map<String, dynamic>)).toList(),
|
||||
audioLanguage: json['audioLanguage'] as String?,
|
||||
subtitleLanguage: json['subtitleLanguage'] as String?,
|
||||
playlistItemID: (json['playlistItemID'] as num?)?.toInt(),
|
||||
@@ -50,45 +48,44 @@ PlexMetadata _$PlexMetadataFromJson(Map<String, dynamic> json) => PlexMetadata(
|
||||
clearLogo: json['clearLogo'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$PlexMetadataToJson(PlexMetadata instance) =>
|
||||
<String, dynamic>{
|
||||
'ratingKey': instance.ratingKey,
|
||||
'key': instance.key,
|
||||
'guid': instance.guid,
|
||||
'studio': instance.studio,
|
||||
'type': instance.type,
|
||||
'title': instance.title,
|
||||
'contentRating': instance.contentRating,
|
||||
'summary': instance.summary,
|
||||
'rating': instance.rating,
|
||||
'audienceRating': instance.audienceRating,
|
||||
'year': instance.year,
|
||||
'thumb': instance.thumb,
|
||||
'art': instance.art,
|
||||
'duration': instance.duration,
|
||||
'addedAt': instance.addedAt,
|
||||
'updatedAt': instance.updatedAt,
|
||||
'lastViewedAt': instance.lastViewedAt,
|
||||
'grandparentTitle': instance.grandparentTitle,
|
||||
'grandparentThumb': instance.grandparentThumb,
|
||||
'grandparentArt': instance.grandparentArt,
|
||||
'grandparentRatingKey': instance.grandparentRatingKey,
|
||||
'parentTitle': instance.parentTitle,
|
||||
'parentThumb': instance.parentThumb,
|
||||
'parentRatingKey': instance.parentRatingKey,
|
||||
'parentIndex': instance.parentIndex,
|
||||
'index': instance.index,
|
||||
'grandparentTheme': instance.grandparentTheme,
|
||||
'viewOffset': instance.viewOffset,
|
||||
'viewCount': instance.viewCount,
|
||||
'leafCount': instance.leafCount,
|
||||
'viewedLeafCount': instance.viewedLeafCount,
|
||||
'childCount': instance.childCount,
|
||||
'Role': instance.role,
|
||||
'audioLanguage': instance.audioLanguage,
|
||||
'subtitleLanguage': instance.subtitleLanguage,
|
||||
'playlistItemID': instance.playlistItemID,
|
||||
'playQueueItemID': instance.playQueueItemID,
|
||||
'librarySectionID': instance.librarySectionID,
|
||||
'clearLogo': instance.clearLogo,
|
||||
};
|
||||
Map<String, dynamic> _$PlexMetadataToJson(PlexMetadata instance) => <String, dynamic>{
|
||||
'ratingKey': instance.ratingKey,
|
||||
'key': instance.key,
|
||||
'guid': instance.guid,
|
||||
'studio': instance.studio,
|
||||
'type': instance.type,
|
||||
'title': instance.title,
|
||||
'contentRating': instance.contentRating,
|
||||
'summary': instance.summary,
|
||||
'rating': instance.rating,
|
||||
'audienceRating': instance.audienceRating,
|
||||
'year': instance.year,
|
||||
'thumb': instance.thumb,
|
||||
'art': instance.art,
|
||||
'duration': instance.duration,
|
||||
'addedAt': instance.addedAt,
|
||||
'updatedAt': instance.updatedAt,
|
||||
'lastViewedAt': instance.lastViewedAt,
|
||||
'grandparentTitle': instance.grandparentTitle,
|
||||
'grandparentThumb': instance.grandparentThumb,
|
||||
'grandparentArt': instance.grandparentArt,
|
||||
'grandparentRatingKey': instance.grandparentRatingKey,
|
||||
'parentTitle': instance.parentTitle,
|
||||
'parentThumb': instance.parentThumb,
|
||||
'parentRatingKey': instance.parentRatingKey,
|
||||
'parentIndex': instance.parentIndex,
|
||||
'index': instance.index,
|
||||
'grandparentTheme': instance.grandparentTheme,
|
||||
'viewOffset': instance.viewOffset,
|
||||
'viewCount': instance.viewCount,
|
||||
'leafCount': instance.leafCount,
|
||||
'viewedLeafCount': instance.viewedLeafCount,
|
||||
'childCount': instance.childCount,
|
||||
'Role': instance.role,
|
||||
'audioLanguage': instance.audioLanguage,
|
||||
'subtitleLanguage': instance.subtitleLanguage,
|
||||
'playlistItemID': instance.playlistItemID,
|
||||
'playQueueItemID': instance.playQueueItemID,
|
||||
'librarySectionID': instance.librarySectionID,
|
||||
'clearLogo': instance.clearLogo,
|
||||
};
|
||||
|
||||
@@ -94,8 +94,7 @@ class PlexPlaylist with MultiServerFields {
|
||||
/// Playlists don't track viewed leaf count
|
||||
int? get viewedLeafCount => null;
|
||||
|
||||
factory PlexPlaylist.fromJson(Map<String, dynamic> json) =>
|
||||
_$PlexPlaylistFromJson(json);
|
||||
factory PlexPlaylist.fromJson(Map<String, dynamic> json) => _$PlexPlaylistFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$PlexPlaylistToJson(this);
|
||||
|
||||
|
||||
@@ -26,23 +26,22 @@ PlexPlaylist _$PlexPlaylistFromJson(Map<String, dynamic> json) => PlexPlaylist(
|
||||
thumb: json['thumb'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$PlexPlaylistToJson(PlexPlaylist instance) =>
|
||||
<String, dynamic>{
|
||||
'ratingKey': instance.ratingKey,
|
||||
'key': instance.key,
|
||||
'type': instance.type,
|
||||
'title': instance.title,
|
||||
'summary': instance.summary,
|
||||
'smart': instance.smart,
|
||||
'playlistType': instance.playlistType,
|
||||
'duration': instance.duration,
|
||||
'leafCount': instance.leafCount,
|
||||
'composite': instance.composite,
|
||||
'addedAt': instance.addedAt,
|
||||
'updatedAt': instance.updatedAt,
|
||||
'lastViewedAt': instance.lastViewedAt,
|
||||
'viewCount': instance.viewCount,
|
||||
'content': instance.content,
|
||||
'guid': instance.guid,
|
||||
'thumb': instance.thumb,
|
||||
};
|
||||
Map<String, dynamic> _$PlexPlaylistToJson(PlexPlaylist instance) => <String, dynamic>{
|
||||
'ratingKey': instance.ratingKey,
|
||||
'key': instance.key,
|
||||
'type': instance.type,
|
||||
'title': instance.title,
|
||||
'summary': instance.summary,
|
||||
'smart': instance.smart,
|
||||
'playlistType': instance.playlistType,
|
||||
'duration': instance.duration,
|
||||
'leafCount': instance.leafCount,
|
||||
'composite': instance.composite,
|
||||
'addedAt': instance.addedAt,
|
||||
'updatedAt': instance.updatedAt,
|
||||
'lastViewedAt': instance.lastViewedAt,
|
||||
'viewCount': instance.viewCount,
|
||||
'content': instance.content,
|
||||
'guid': instance.guid,
|
||||
'thumb': instance.thumb,
|
||||
};
|
||||
|
||||
@@ -12,18 +12,9 @@ class PlexRole {
|
||||
final String? thumb;
|
||||
final int? count;
|
||||
|
||||
PlexRole({
|
||||
this.id,
|
||||
this.filter,
|
||||
required this.tag,
|
||||
this.tagKey,
|
||||
this.role,
|
||||
this.thumb,
|
||||
this.count,
|
||||
});
|
||||
PlexRole({this.id, this.filter, required this.tag, this.tagKey, this.role, this.thumb, this.count});
|
||||
|
||||
factory PlexRole.fromJson(Map<String, dynamic> json) =>
|
||||
_$PlexRoleFromJson(json);
|
||||
factory PlexRole.fromJson(Map<String, dynamic> json) => _$PlexRoleFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$PlexRoleToJson(this);
|
||||
}
|
||||
|
||||
@@ -4,12 +4,7 @@ class PlexSort {
|
||||
final String title;
|
||||
final String? defaultDirection;
|
||||
|
||||
PlexSort({
|
||||
required this.key,
|
||||
this.descKey,
|
||||
required this.title,
|
||||
this.defaultDirection,
|
||||
});
|
||||
PlexSort({required this.key, this.descKey, required this.title, this.defaultDirection});
|
||||
|
||||
factory PlexSort.fromJson(Map<String, dynamic> json) {
|
||||
return PlexSort(
|
||||
|
||||
@@ -34,8 +34,7 @@ class PlexUserProfile {
|
||||
|
||||
return PlexUserProfile(
|
||||
autoSelectAudio: profile['autoSelectAudio'] as bool? ?? true,
|
||||
defaultAudioAccessibility:
|
||||
profile['defaultAudioAccessibility'] as int? ?? 0,
|
||||
defaultAudioAccessibility: profile['defaultAudioAccessibility'] as int? ?? 0,
|
||||
defaultAudioLanguage: profile['defaultAudioLanguage'] as String?,
|
||||
defaultAudioLanguages: profile['defaultAudioLanguages'] != null
|
||||
? List<String>.from(profile['defaultAudioLanguages'] as List)
|
||||
@@ -45,8 +44,7 @@ class PlexUserProfile {
|
||||
? List<String>.from(profile['defaultSubtitleLanguages'] as List)
|
||||
: null,
|
||||
autoSelectSubtitle: profile['autoSelectSubtitle'] as int? ?? 0,
|
||||
defaultSubtitleAccessibility:
|
||||
profile['defaultSubtitleAccessibility'] as int? ?? 0,
|
||||
defaultSubtitleAccessibility: profile['defaultSubtitleAccessibility'] as int? ?? 0,
|
||||
defaultSubtitleForced: profile['defaultSubtitleForced'] as int? ?? 1,
|
||||
watchedIndicator: profile['watchedIndicator'] as int? ?? 1,
|
||||
mediaReviewsVisibility: profile['mediaReviewsVisibility'] as int? ?? 0,
|
||||
|
||||
@@ -13,11 +13,7 @@ class PlexVideoPlaybackData {
|
||||
/// Available media versions/qualities for this content
|
||||
final List<PlexMediaVersion> availableVersions;
|
||||
|
||||
PlexVideoPlaybackData({
|
||||
required this.videoUrl,
|
||||
required this.mediaInfo,
|
||||
required this.availableVersions,
|
||||
});
|
||||
PlexVideoPlaybackData({required this.videoUrl, required this.mediaInfo, required this.availableVersions});
|
||||
|
||||
/// Returns true if this playback data has a valid video URL
|
||||
bool get hasValidVideoUrl => videoUrl != null && videoUrl!.isNotEmpty;
|
||||
|
||||
@@ -25,9 +25,7 @@ class AndroidFontLoader {
|
||||
await fontDir.create(recursive: true);
|
||||
}
|
||||
|
||||
final fontFile = File(
|
||||
path.join(fontDir.path, 'go-noto-current-regular.ttf'),
|
||||
);
|
||||
final fontFile = File(path.join(fontDir.path, 'go-noto-current-regular.ttf'));
|
||||
|
||||
// Load font from assets and write to cache if it doesn't exist
|
||||
if (!await fontFile.exists()) {
|
||||
|
||||
+9
-36
@@ -87,8 +87,7 @@ class AudioTrack {
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is AudioTrack && runtimeType == other.runtimeType && id == other.id;
|
||||
identical(this, other) || other is AudioTrack && runtimeType == other.runtimeType && id == other.id;
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode;
|
||||
@@ -133,13 +132,7 @@ class SubtitleTrack {
|
||||
|
||||
/// Create a subtitle track from an external URI.
|
||||
factory SubtitleTrack.uri(String uri, {String? title, String? language}) {
|
||||
return SubtitleTrack(
|
||||
id: 'external:$uri',
|
||||
title: title,
|
||||
language: language,
|
||||
isExternal: true,
|
||||
uri: uri,
|
||||
);
|
||||
return SubtitleTrack(id: 'external:$uri', title: title, language: language, isExternal: true, uri: uri);
|
||||
}
|
||||
|
||||
/// Auto-select track.
|
||||
@@ -161,10 +154,7 @@ class SubtitleTrack {
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is SubtitleTrack &&
|
||||
runtimeType == other.runtimeType &&
|
||||
id == other.id;
|
||||
identical(this, other) || other is SubtitleTrack && runtimeType == other.runtimeType && id == other.id;
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode;
|
||||
@@ -182,15 +172,11 @@ class Tracks {
|
||||
|
||||
/// Creates a copy with the given fields replaced.
|
||||
Tracks copyWith({List<AudioTrack>? audio, List<SubtitleTrack>? subtitle}) {
|
||||
return Tracks(
|
||||
audio: audio ?? this.audio,
|
||||
subtitle: subtitle ?? this.subtitle,
|
||||
);
|
||||
return Tracks(audio: audio ?? this.audio, subtitle: subtitle ?? this.subtitle);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'Tracks(audio: ${audio.length}, subtitle: ${subtitle.length})';
|
||||
String toString() => 'Tracks(audio: ${audio.length}, subtitle: ${subtitle.length})';
|
||||
}
|
||||
|
||||
/// Represents the currently selected tracks.
|
||||
@@ -205,10 +191,7 @@ class TrackSelection {
|
||||
|
||||
/// Creates a copy with the given fields replaced.
|
||||
TrackSelection copyWith({AudioTrack? audio, SubtitleTrack? subtitle}) {
|
||||
return TrackSelection(
|
||||
audio: audio ?? this.audio,
|
||||
subtitle: subtitle ?? this.subtitle,
|
||||
);
|
||||
return TrackSelection(audio: audio ?? this.audio, subtitle: subtitle ?? this.subtitle);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -233,10 +216,7 @@ class AudioDevice {
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is AudioDevice &&
|
||||
runtimeType == other.runtimeType &&
|
||||
name == other.name;
|
||||
identical(this, other) || other is AudioDevice && runtimeType == other.runtimeType && name == other.name;
|
||||
|
||||
@override
|
||||
int get hashCode => name.hashCode;
|
||||
@@ -253,11 +233,7 @@ class PlayerLog {
|
||||
/// The log message text.
|
||||
final String text;
|
||||
|
||||
const PlayerLog({
|
||||
required this.level,
|
||||
required this.prefix,
|
||||
required this.text,
|
||||
});
|
||||
const PlayerLog({required this.level, required this.prefix, required this.text});
|
||||
|
||||
@override
|
||||
String toString() => '[$prefix] ${level.name}: $text';
|
||||
@@ -282,10 +258,7 @@ class Media {
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is Media &&
|
||||
runtimeType == other.runtimeType &&
|
||||
uri == other.uri &&
|
||||
start == other.start;
|
||||
other is Media && runtimeType == other.runtimeType && uri == other.uri && start == other.start;
|
||||
|
||||
@override
|
||||
int get hashCode => uri.hashCode ^ start.hashCode;
|
||||
|
||||
@@ -22,8 +22,6 @@ class PlayerLinux extends PlayerNative {
|
||||
/// limitation.
|
||||
@override
|
||||
Future<void> setControlsVisible(bool visible) async {
|
||||
await _methodChannel.invokeMethod('setControlsVisible', {
|
||||
'visible': visible,
|
||||
});
|
||||
await _methodChannel.invokeMethod('setControlsVisible', {'visible': visible});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,12 +90,7 @@ abstract class Player {
|
||||
/// [title] - Optional display title.
|
||||
/// [language] - Optional language code.
|
||||
/// [select] - Whether to select this track immediately.
|
||||
Future<void> addSubtitleTrack({
|
||||
required String uri,
|
||||
String? title,
|
||||
String? language,
|
||||
bool select = false,
|
||||
});
|
||||
Future<void> addSubtitleTrack({required String uri, String? title, String? language, bool select = false});
|
||||
|
||||
// ============================================
|
||||
// Volume and Rate
|
||||
|
||||
@@ -43,8 +43,7 @@ class PlayerNative implements Player {
|
||||
final _logController = StreamController<PlayerLog>.broadcast();
|
||||
final _errorController = StreamController<String>.broadcast();
|
||||
final _audioDeviceController = StreamController<AudioDevice>.broadcast();
|
||||
final _audioDevicesController =
|
||||
StreamController<List<AudioDevice>>.broadcast();
|
||||
final _audioDevicesController = StreamController<List<AudioDevice>>.broadcast();
|
||||
|
||||
StreamSubscription? _eventSubscription;
|
||||
bool _disposed = false;
|
||||
@@ -271,15 +270,10 @@ class PlayerNative implements Player {
|
||||
T? selectedTrack;
|
||||
|
||||
if (id != null && id != 'no') {
|
||||
selectedTrack = tracks.firstWhere(
|
||||
(track) => _getTrackId(track) == id,
|
||||
orElse: () => null,
|
||||
);
|
||||
selectedTrack = tracks.firstWhere((track) => _getTrackId(track) == id, orElse: () => null);
|
||||
}
|
||||
|
||||
_state = _state.copyWith(
|
||||
track: selectionSetter(_state.track, selectedTrack),
|
||||
);
|
||||
_state = _state.copyWith(track: selectionSetter(_state.track, selectedTrack));
|
||||
_trackController.add(_state.track);
|
||||
}
|
||||
|
||||
@@ -308,10 +302,7 @@ class PlayerNative implements Player {
|
||||
await _observeProperty('duration', 'double');
|
||||
await _observeProperty('pause', 'flag');
|
||||
await _observeProperty('paused-for-cache', 'flag');
|
||||
await _observeProperty(
|
||||
'track-list',
|
||||
(Platform.isAndroid || Platform.isWindows) ? 'string' : 'node',
|
||||
);
|
||||
await _observeProperty('track-list', (Platform.isAndroid || Platform.isWindows) ? 'string' : 'node');
|
||||
await _observeProperty('eof-reached', 'flag');
|
||||
await _observeProperty('volume', 'double');
|
||||
await _observeProperty('aid', 'string');
|
||||
@@ -323,10 +314,7 @@ class PlayerNative implements Player {
|
||||
}
|
||||
|
||||
Future<void> _observeProperty(String name, String format) async {
|
||||
await _methodChannel.invokeMethod('observeProperty', {
|
||||
'name': name,
|
||||
'format': format,
|
||||
});
|
||||
await _methodChannel.invokeMethod('observeProperty', {'name': name, 'format': format});
|
||||
}
|
||||
|
||||
/// Configures subtitle fonts for Android libass support.
|
||||
@@ -411,11 +399,7 @@ class PlayerNative implements Player {
|
||||
@override
|
||||
Future<void> seek(Duration position) async {
|
||||
_checkDisposed();
|
||||
await command([
|
||||
'seek',
|
||||
(position.inMilliseconds / 1000.0).toString(),
|
||||
'absolute',
|
||||
]);
|
||||
await command(['seek', (position.inMilliseconds / 1000.0).toString(), 'absolute']);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
@@ -435,12 +419,7 @@ class PlayerNative implements Player {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> addSubtitleTrack({
|
||||
required String uri,
|
||||
String? title,
|
||||
String? language,
|
||||
bool select = false,
|
||||
}) async {
|
||||
Future<void> addSubtitleTrack({required String uri, String? title, String? language, bool select = false}) async {
|
||||
_checkDisposed();
|
||||
final args = ['sub-add', uri, select ? 'select' : 'auto'];
|
||||
if (title != null) args.add('title=$title');
|
||||
@@ -478,19 +457,14 @@ class PlayerNative implements Player {
|
||||
Future<void> setProperty(String name, String value) async {
|
||||
_checkDisposed();
|
||||
await _ensureInitialized();
|
||||
await _methodChannel.invokeMethod('setProperty', {
|
||||
'name': name,
|
||||
'value': value,
|
||||
});
|
||||
await _methodChannel.invokeMethod('setProperty', {'name': name, 'value': value});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> getProperty(String name) async {
|
||||
_checkDisposed();
|
||||
await _ensureInitialized();
|
||||
return await _methodChannel.invokeMethod<String>('getProperty', {
|
||||
'name': name,
|
||||
});
|
||||
return await _methodChannel.invokeMethod<String>('getProperty', {'name': name});
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -106,6 +106,5 @@ class PlayerState {
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'PlayerState(playing: $playing, position: $position, duration: $duration)';
|
||||
String toString() => 'PlayerState(playing: $playing, position: $position, duration: $duration)';
|
||||
}
|
||||
|
||||
+2
-12
@@ -27,12 +27,7 @@ class Video extends StatefulWidget {
|
||||
/// Background color shown behind the video.
|
||||
final Color backgroundColor;
|
||||
|
||||
const Video({
|
||||
super.key,
|
||||
required this.player,
|
||||
this.controls,
|
||||
this.backgroundColor = Colors.black,
|
||||
});
|
||||
const Video({super.key, required this.player, this.controls, this.backgroundColor = Colors.black});
|
||||
|
||||
@override
|
||||
State<Video> createState() => _VideoState();
|
||||
@@ -82,12 +77,7 @@ class _VideoState extends State<Video> {
|
||||
final size = renderBox.size;
|
||||
final dpr = MediaQuery.of(context).devicePixelRatio;
|
||||
|
||||
final newRect = Rect.fromLTWH(
|
||||
position.dx,
|
||||
position.dy,
|
||||
size.width,
|
||||
size.height,
|
||||
);
|
||||
final newRect = Rect.fromLTWH(position.dx, position.dy, size.width, size.height);
|
||||
|
||||
// Only update if the rect has changed significantly
|
||||
if (_lastRect != null &&
|
||||
|
||||
@@ -5,13 +5,7 @@ import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
|
||||
/// Navigation tab identifiers
|
||||
enum NavigationTabId {
|
||||
discover,
|
||||
libraries,
|
||||
search,
|
||||
downloads,
|
||||
settings,
|
||||
}
|
||||
enum NavigationTabId { discover, libraries, search, downloads, settings }
|
||||
|
||||
/// Represents a navigation tab with its configuration
|
||||
class NavigationTab {
|
||||
@@ -20,19 +14,10 @@ class NavigationTab {
|
||||
final IconData icon;
|
||||
final String Function() getLabel;
|
||||
|
||||
const NavigationTab({
|
||||
required this.id,
|
||||
required this.onlineOnly,
|
||||
required this.icon,
|
||||
required this.getLabel,
|
||||
});
|
||||
const NavigationTab({required this.id, required this.onlineOnly, required this.icon, required this.getLabel});
|
||||
|
||||
NavigationDestination toDestination() {
|
||||
return NavigationDestination(
|
||||
icon: AppIcon(icon, fill: 1),
|
||||
selectedIcon: AppIcon(icon, fill: 1),
|
||||
label: getLabel(),
|
||||
);
|
||||
return NavigationDestination(icon: AppIcon(icon, fill: 1), selectedIcon: AppIcon(icon, fill: 1), label: getLabel());
|
||||
}
|
||||
|
||||
/// Get the index for a tab ID in the visible tabs list
|
||||
@@ -43,17 +28,11 @@ class NavigationTab {
|
||||
|
||||
/// Get tabs filtered by offline mode
|
||||
static List<NavigationTab> getVisibleTabs({required bool isOffline}) {
|
||||
return allNavigationTabs
|
||||
.where((tab) => !isOffline || !tab.onlineOnly)
|
||||
.toList();
|
||||
return allNavigationTabs.where((tab) => !isOffline || !tab.onlineOnly).toList();
|
||||
}
|
||||
|
||||
/// Check if a visual index corresponds to a specific tab ID
|
||||
static bool isTabAtIndex(
|
||||
NavigationTabId id,
|
||||
int index, {
|
||||
required bool isOffline,
|
||||
}) {
|
||||
static bool isTabAtIndex(NavigationTabId id, int index, {required bool isOffline}) {
|
||||
return indexFor(id, isOffline: isOffline) == index;
|
||||
}
|
||||
}
|
||||
@@ -67,24 +46,14 @@ String _getSettingsLabel() => t.navigation.settings;
|
||||
|
||||
/// All navigation tabs in display order
|
||||
const allNavigationTabs = [
|
||||
NavigationTab(
|
||||
id: NavigationTabId.discover,
|
||||
onlineOnly: true,
|
||||
icon: Symbols.home_rounded,
|
||||
getLabel: _getHomeLabel,
|
||||
),
|
||||
NavigationTab(id: NavigationTabId.discover, onlineOnly: true, icon: Symbols.home_rounded, getLabel: _getHomeLabel),
|
||||
NavigationTab(
|
||||
id: NavigationTabId.libraries,
|
||||
onlineOnly: true,
|
||||
icon: Symbols.video_library_rounded,
|
||||
getLabel: _getLibrariesLabel,
|
||||
),
|
||||
NavigationTab(
|
||||
id: NavigationTabId.search,
|
||||
onlineOnly: true,
|
||||
icon: Symbols.search_rounded,
|
||||
getLabel: _getSearchLabel,
|
||||
),
|
||||
NavigationTab(id: NavigationTabId.search, onlineOnly: true, icon: Symbols.search_rounded, getLabel: _getSearchLabel),
|
||||
NavigationTab(
|
||||
id: NavigationTabId.downloads,
|
||||
onlineOnly: false,
|
||||
|
||||
@@ -53,16 +53,12 @@ class DownloadProvider extends ChangeNotifier {
|
||||
// Key: globalKey (serverId:ratingKey), Value: total episode count
|
||||
final Map<String, int> _totalEpisodeCounts = {};
|
||||
|
||||
DownloadProvider({required DownloadManagerService downloadManager})
|
||||
: _downloadManager = downloadManager {
|
||||
DownloadProvider({required DownloadManagerService downloadManager}) : _downloadManager = downloadManager {
|
||||
// Listen to progress updates from the download manager
|
||||
_progressSubscription = _downloadManager.progressStream.listen(
|
||||
_onProgressUpdate,
|
||||
);
|
||||
_progressSubscription = _downloadManager.progressStream.listen(_onProgressUpdate);
|
||||
|
||||
// Listen to deletion progress updates
|
||||
_deletionProgressSubscription = _downloadManager.deletionProgressStream
|
||||
.listen(_onDeletionProgressUpdate);
|
||||
_deletionProgressSubscription = _downloadManager.deletionProgressStream.listen(_onDeletionProgressUpdate);
|
||||
|
||||
// Load persisted downloads from database
|
||||
_loadPersistedDownloads();
|
||||
@@ -95,20 +91,13 @@ class DownloadProvider extends ChangeNotifier {
|
||||
);
|
||||
|
||||
// Store Plex thumb path reference (file path computed from hash when needed)
|
||||
_artworkPaths[item.globalKey] = DownloadedArtwork(
|
||||
thumbPath: item.thumbPath,
|
||||
);
|
||||
_artworkPaths[item.globalKey] = DownloadedArtwork(thumbPath: item.thumbPath);
|
||||
|
||||
// Load metadata from API cache (base endpoint - chapters/markers included in data)
|
||||
final cached = await apiCache.get(
|
||||
item.serverId,
|
||||
'/library/metadata/${item.ratingKey}',
|
||||
);
|
||||
final cached = await apiCache.get(item.serverId, '/library/metadata/${item.ratingKey}');
|
||||
final firstMetadata = PlexCacheParser.extractFirstMetadata(cached);
|
||||
if (firstMetadata != null) {
|
||||
final metadata = PlexMetadata.fromJson(
|
||||
firstMetadata,
|
||||
).copyWith(serverId: item.serverId);
|
||||
final metadata = PlexMetadata.fromJson(firstMetadata).copyWith(serverId: item.serverId);
|
||||
_metadata[item.globalKey] = metadata;
|
||||
|
||||
// For episodes, also load parent (show and season) metadata
|
||||
@@ -142,15 +131,11 @@ class DownloadProvider extends ChangeNotifier {
|
||||
final count = prefs.getInt(key);
|
||||
if (count != null) {
|
||||
_totalEpisodeCounts[globalKey] = count;
|
||||
appLogger.d(
|
||||
'📂 Loaded episode count from SharedPrefs: $globalKey = $count',
|
||||
);
|
||||
appLogger.d('📂 Loaded episode count from SharedPrefs: $globalKey = $count');
|
||||
}
|
||||
}
|
||||
|
||||
appLogger.i(
|
||||
'📚 Loaded ${_totalEpisodeCounts.length} episode counts from SharedPreferences',
|
||||
);
|
||||
appLogger.i('📚 Loaded ${_totalEpisodeCounts.length} episode counts from SharedPreferences');
|
||||
} catch (e) {
|
||||
appLogger.w('Failed to load episode counts', error: e);
|
||||
}
|
||||
@@ -168,10 +153,7 @@ class DownloadProvider extends ChangeNotifier {
|
||||
}
|
||||
|
||||
/// Load parent (show and season) metadata from cache for an episode
|
||||
Future<void> _loadParentMetadataFromCache(
|
||||
PlexMetadata episode,
|
||||
PlexApiCache apiCache,
|
||||
) async {
|
||||
Future<void> _loadParentMetadataFromCache(PlexMetadata episode, PlexApiCache apiCache) async {
|
||||
final serverId = episode.serverId;
|
||||
if (serverId == null) return;
|
||||
|
||||
@@ -180,21 +162,14 @@ class DownloadProvider extends ChangeNotifier {
|
||||
if (showRatingKey != null) {
|
||||
final showGlobalKey = '$serverId:$showRatingKey';
|
||||
if (!_metadata.containsKey(showGlobalKey)) {
|
||||
final cached = await apiCache.get(
|
||||
serverId,
|
||||
'/library/metadata/$showRatingKey',
|
||||
);
|
||||
final cached = await apiCache.get(serverId, '/library/metadata/$showRatingKey');
|
||||
final showJson = PlexCacheParser.extractFirstMetadata(cached);
|
||||
if (showJson != null) {
|
||||
final showMetadata = PlexMetadata.fromJson(
|
||||
showJson,
|
||||
).copyWith(serverId: serverId);
|
||||
final showMetadata = PlexMetadata.fromJson(showJson).copyWith(serverId: serverId);
|
||||
_metadata[showGlobalKey] = showMetadata;
|
||||
// Store artwork reference for offline display
|
||||
if (showMetadata.thumb != null) {
|
||||
_artworkPaths[showGlobalKey] = DownloadedArtwork(
|
||||
thumbPath: showMetadata.thumb,
|
||||
);
|
||||
_artworkPaths[showGlobalKey] = DownloadedArtwork(thumbPath: showMetadata.thumb);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -205,21 +180,14 @@ class DownloadProvider extends ChangeNotifier {
|
||||
if (seasonRatingKey != null) {
|
||||
final seasonGlobalKey = '$serverId:$seasonRatingKey';
|
||||
if (!_metadata.containsKey(seasonGlobalKey)) {
|
||||
final cached = await apiCache.get(
|
||||
serverId,
|
||||
'/library/metadata/$seasonRatingKey',
|
||||
);
|
||||
final cached = await apiCache.get(serverId, '/library/metadata/$seasonRatingKey');
|
||||
final seasonJson = PlexCacheParser.extractFirstMetadata(cached);
|
||||
if (seasonJson != null) {
|
||||
final seasonMetadata = PlexMetadata.fromJson(
|
||||
seasonJson,
|
||||
).copyWith(serverId: serverId);
|
||||
final seasonMetadata = PlexMetadata.fromJson(seasonJson).copyWith(serverId: serverId);
|
||||
_metadata[seasonGlobalKey] = seasonMetadata;
|
||||
// Store artwork reference for offline display
|
||||
if (seasonMetadata.thumb != null) {
|
||||
_artworkPaths[seasonGlobalKey] = DownloadedArtwork(
|
||||
thumbPath: seasonMetadata.thumb,
|
||||
);
|
||||
_artworkPaths[seasonGlobalKey] = DownloadedArtwork(thumbPath: seasonMetadata.thumb);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -227,17 +195,13 @@ class DownloadProvider extends ChangeNotifier {
|
||||
}
|
||||
|
||||
void _onProgressUpdate(DownloadProgress progress) {
|
||||
appLogger.d(
|
||||
'Progress update received: ${progress.globalKey} - ${progress.status} - ${progress.progress}%',
|
||||
);
|
||||
appLogger.d('Progress update received: ${progress.globalKey} - ${progress.status} - ${progress.progress}%');
|
||||
|
||||
_downloads[progress.globalKey] = progress;
|
||||
|
||||
// Sync artwork paths when they are available
|
||||
if (progress.hasArtworkPaths) {
|
||||
_artworkPaths[progress.globalKey] = DownloadedArtwork(
|
||||
thumbPath: progress.thumbPath,
|
||||
);
|
||||
_artworkPaths[progress.globalKey] = DownloadedArtwork(thumbPath: progress.thumbPath);
|
||||
}
|
||||
|
||||
appLogger.d('Notifying listeners for ${progress.globalKey}');
|
||||
@@ -272,9 +236,7 @@ class DownloadProvider extends ChangeNotifier {
|
||||
|
||||
/// Get all completed downloads
|
||||
List<DownloadProgress> get completedDownloads {
|
||||
return _downloads.values
|
||||
.where((p) => p.status == DownloadStatus.completed)
|
||||
.toList();
|
||||
return _downloads.values.where((p) => p.status == DownloadStatus.completed).toList();
|
||||
}
|
||||
|
||||
/// Get completed TV episode downloads (individual episodes)
|
||||
@@ -282,8 +244,7 @@ class DownloadProvider extends ChangeNotifier {
|
||||
return _metadata.entries
|
||||
.where((entry) {
|
||||
final progress = _downloads[entry.key];
|
||||
return progress?.status == DownloadStatus.completed &&
|
||||
entry.value.type == 'episode';
|
||||
return progress?.status == DownloadStatus.completed && entry.value.type == 'episode';
|
||||
})
|
||||
.map((entry) => entry.value)
|
||||
.toList();
|
||||
@@ -299,8 +260,7 @@ class DownloadProvider extends ChangeNotifier {
|
||||
final meta = entry.value;
|
||||
final progress = _downloads[globalKey];
|
||||
|
||||
if (progress?.status == DownloadStatus.completed &&
|
||||
meta.type == 'episode') {
|
||||
if (progress?.status == DownloadStatus.completed && meta.type == 'episode') {
|
||||
final showRatingKey = meta.grandparentRatingKey;
|
||||
if (showRatingKey != null && !shows.containsKey(showRatingKey)) {
|
||||
// Try to get stored show metadata first
|
||||
@@ -334,8 +294,7 @@ class DownloadProvider extends ChangeNotifier {
|
||||
return _metadata.entries
|
||||
.where((entry) {
|
||||
final progress = _downloads[entry.key];
|
||||
return progress?.status == DownloadStatus.completed &&
|
||||
entry.value.type == 'movie';
|
||||
return progress?.status == DownloadStatus.completed && entry.value.type == 'movie';
|
||||
})
|
||||
.map((entry) => entry.value)
|
||||
.toList();
|
||||
@@ -345,17 +304,13 @@ class DownloadProvider extends ChangeNotifier {
|
||||
PlexMetadata? getMetadata(String globalKey) => _metadata[globalKey];
|
||||
|
||||
/// Get artwork paths for a specific download (for offline display)
|
||||
DownloadedArtwork? getArtworkPaths(String globalKey) =>
|
||||
_artworkPaths[globalKey];
|
||||
DownloadedArtwork? getArtworkPaths(String globalKey) => _artworkPaths[globalKey];
|
||||
|
||||
/// Get local file path for any artwork type (thumb, art, clearLogo, etc.)
|
||||
/// Returns null if artwork directory isn't initialized or artworkPath is null
|
||||
String? getArtworkLocalPath(String serverId, String? artworkPath) {
|
||||
if (artworkPath == null) return null;
|
||||
return DownloadStorageService.instance.getArtworkPathSync(
|
||||
serverId,
|
||||
artworkPath,
|
||||
);
|
||||
return DownloadStorageService.instance.getArtworkPathSync(serverId, artworkPath);
|
||||
}
|
||||
|
||||
/// Get downloaded episodes for a specific show (by grandparentRatingKey)
|
||||
@@ -377,8 +332,7 @@ class DownloadProvider extends ChangeNotifier {
|
||||
return _downloads.entries
|
||||
.where((entry) {
|
||||
final meta = _metadata[entry.key];
|
||||
return meta?.type == 'episode' &&
|
||||
meta?.grandparentRatingKey == showRatingKey;
|
||||
return meta?.type == 'episode' && meta?.grandparentRatingKey == showRatingKey;
|
||||
})
|
||||
.map((entry) => entry.value)
|
||||
.toList();
|
||||
@@ -389,8 +343,7 @@ class DownloadProvider extends ChangeNotifier {
|
||||
return _downloads.entries
|
||||
.where((entry) {
|
||||
final meta = _metadata[entry.key];
|
||||
return meta?.type == 'episode' &&
|
||||
meta?.parentRatingKey == seasonRatingKey;
|
||||
return meta?.type == 'episode' && meta?.parentRatingKey == seasonRatingKey;
|
||||
})
|
||||
.map((entry) => entry.value)
|
||||
.toList();
|
||||
@@ -398,10 +351,7 @@ class DownloadProvider extends ChangeNotifier {
|
||||
|
||||
/// Calculate aggregate progress for a show (based on all its episodes)
|
||||
/// Returns synthetic DownloadProgress with aggregated values
|
||||
DownloadProgress? getAggregateProgressForShow(
|
||||
String serverId,
|
||||
String showRatingKey,
|
||||
) {
|
||||
DownloadProgress? getAggregateProgressForShow(String serverId, String showRatingKey) {
|
||||
return _calculateAggregateProgress(
|
||||
serverId: serverId,
|
||||
ratingKey: showRatingKey,
|
||||
@@ -412,10 +362,7 @@ class DownloadProvider extends ChangeNotifier {
|
||||
|
||||
/// Calculate aggregate progress for a season (based on all its episodes)
|
||||
/// Returns synthetic DownloadProgress with aggregated values
|
||||
DownloadProgress? getAggregateProgressForSeason(
|
||||
String serverId,
|
||||
String seasonRatingKey,
|
||||
) {
|
||||
DownloadProgress? getAggregateProgressForSeason(String serverId, String seasonRatingKey) {
|
||||
return _calculateAggregateProgress(
|
||||
serverId: serverId,
|
||||
ratingKey: seasonRatingKey,
|
||||
@@ -464,15 +411,11 @@ class DownloadProvider extends ChangeNotifier {
|
||||
countSource = 'downloaded episodes (fallback)';
|
||||
}
|
||||
|
||||
appLogger.d(
|
||||
'✅ Using totalEpisodes=$totalEpisodes from [$countSource] for $entityType $ratingKey',
|
||||
);
|
||||
appLogger.d('✅ Using totalEpisodes=$totalEpisodes from [$countSource] for $entityType $ratingKey');
|
||||
|
||||
// If we have stored count but no downloads, check if it's a valid partial state
|
||||
if (totalEpisodes == 0 || (episodes.isEmpty && totalEpisodes > 0)) {
|
||||
appLogger.d(
|
||||
'⚠️ No valid downloads for $entityType $ratingKey, returning null',
|
||||
);
|
||||
appLogger.d('⚠️ No valid downloads for $entityType $ratingKey, returning null');
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -501,10 +444,7 @@ class DownloadProvider extends ChangeNotifier {
|
||||
final DownloadStatus overallStatus;
|
||||
if (completedCount == totalEpisodes) {
|
||||
overallStatus = DownloadStatus.completed;
|
||||
} else if (completedCount > 0 &&
|
||||
downloadingCount == 0 &&
|
||||
queuedCount == 0 &&
|
||||
completedCount < totalEpisodes) {
|
||||
} else if (completedCount > 0 && downloadingCount == 0 && queuedCount == 0 && completedCount < totalEpisodes) {
|
||||
overallStatus = DownloadStatus.partial;
|
||||
} else if (downloadingCount > 0) {
|
||||
overallStatus = DownloadStatus.downloading;
|
||||
@@ -517,9 +457,7 @@ class DownloadProvider extends ChangeNotifier {
|
||||
}
|
||||
|
||||
// Calculate overall progress percentage based on TOTAL episodes
|
||||
final int overallProgress = totalEpisodes > 0
|
||||
? ((completedCount * 100) / totalEpisodes).round()
|
||||
: 0;
|
||||
final int overallProgress = totalEpisodes > 0 ? ((completedCount * 100) / totalEpisodes).round() : 0;
|
||||
|
||||
appLogger.d(
|
||||
'Aggregate progress for $entityType $ratingKey: $overallProgress% '
|
||||
@@ -541,11 +479,8 @@ class DownloadProvider extends ChangeNotifier {
|
||||
bool get hasDownloads => _downloads.isNotEmpty;
|
||||
|
||||
/// Whether there are any active downloads
|
||||
bool get hasActiveDownloads => _downloads.values.any(
|
||||
(p) =>
|
||||
p.status == DownloadStatus.downloading ||
|
||||
p.status == DownloadStatus.queued,
|
||||
);
|
||||
bool get hasActiveDownloads =>
|
||||
_downloads.values.any((p) => p.status == DownloadStatus.downloading || p.status == DownloadStatus.queued);
|
||||
|
||||
/// Get download progress for a specific item
|
||||
/// For shows/seasons, returns aggregate progress of all child episodes
|
||||
@@ -704,17 +639,13 @@ class DownloadProvider extends ChangeNotifier {
|
||||
}
|
||||
|
||||
/// Queue a single movie or episode for download
|
||||
Future<void> _queueSingleDownload(
|
||||
PlexMetadata metadata,
|
||||
PlexClient client,
|
||||
) async {
|
||||
Future<void> _queueSingleDownload(PlexMetadata metadata, PlexClient client) async {
|
||||
final globalKey = '${metadata.serverId}:${metadata.ratingKey}';
|
||||
|
||||
// Don't re-queue if already downloading or completed
|
||||
if (_downloads.containsKey(globalKey)) {
|
||||
final existing = _downloads[globalKey]!;
|
||||
if (existing.status == DownloadStatus.downloading ||
|
||||
existing.status == DownloadStatus.completed) {
|
||||
if (existing.status == DownloadStatus.downloading || existing.status == DownloadStatus.completed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -723,20 +654,12 @@ class DownloadProvider extends ChangeNotifier {
|
||||
// The metadata from getChildren() is summarized and missing these fields
|
||||
PlexMetadata metadataToStore = metadata;
|
||||
try {
|
||||
final fullMetadata = await client.getMetadataWithImages(
|
||||
metadata.ratingKey,
|
||||
);
|
||||
final fullMetadata = await client.getMetadataWithImages(metadata.ratingKey);
|
||||
if (fullMetadata != null) {
|
||||
metadataToStore = fullMetadata.copyWith(
|
||||
serverId: metadata.serverId,
|
||||
serverName: metadata.serverName,
|
||||
);
|
||||
metadataToStore = fullMetadata.copyWith(serverId: metadata.serverId, serverName: metadata.serverName);
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.w(
|
||||
'Failed to fetch full metadata for ${metadata.ratingKey}, using partial',
|
||||
error: e,
|
||||
);
|
||||
appLogger.w('Failed to fetch full metadata for ${metadata.ratingKey}, using partial', error: e);
|
||||
}
|
||||
|
||||
// For episodes, also fetch and store show and season metadata for offline display
|
||||
@@ -748,25 +671,16 @@ class DownloadProvider extends ChangeNotifier {
|
||||
_metadata[globalKey] = metadataToStore;
|
||||
|
||||
// Update local state immediately for UI feedback
|
||||
_downloads[globalKey] = DownloadProgress(
|
||||
globalKey: globalKey,
|
||||
status: DownloadStatus.queued,
|
||||
);
|
||||
_downloads[globalKey] = DownloadProgress(globalKey: globalKey, status: DownloadStatus.queued);
|
||||
notifyListeners();
|
||||
|
||||
// Actually trigger download via DownloadManagerService
|
||||
await _downloadManager.queueDownload(
|
||||
metadata: metadataToStore,
|
||||
client: client,
|
||||
);
|
||||
await _downloadManager.queueDownload(metadata: metadataToStore, client: client);
|
||||
}
|
||||
|
||||
/// Fetch and store show and season metadata for an episode
|
||||
/// Also downloads artwork for show and season
|
||||
Future<void> _fetchAndStoreParentMetadata(
|
||||
PlexMetadata episode,
|
||||
PlexClient client,
|
||||
) async {
|
||||
Future<void> _fetchAndStoreParentMetadata(PlexMetadata episode, PlexClient client) async {
|
||||
final serverId = episode.serverId;
|
||||
if (serverId == null) return;
|
||||
final storageService = DownloadStorageService.instance;
|
||||
@@ -784,10 +698,7 @@ class DownloadProvider extends ChangeNotifier {
|
||||
try {
|
||||
showMetadata = await client.getMetadataWithImages(showRatingKey);
|
||||
} catch (e) {
|
||||
appLogger.w(
|
||||
'Failed to fetch show metadata for $showRatingKey',
|
||||
error: e,
|
||||
);
|
||||
appLogger.w('Failed to fetch show metadata for $showRatingKey', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -800,14 +711,9 @@ class DownloadProvider extends ChangeNotifier {
|
||||
|
||||
// Ensure show artwork is downloaded even if metadata already existed
|
||||
final thumbPath = showWithServer.thumb;
|
||||
final hasPoster =
|
||||
thumbPath != null &&
|
||||
await storageService.artworkExists(serverId, thumbPath);
|
||||
final hasPoster = thumbPath != null && await storageService.artworkExists(serverId, thumbPath);
|
||||
if (!hasPoster) {
|
||||
await _downloadManager.downloadArtworkForMetadata(
|
||||
showWithServer,
|
||||
client,
|
||||
);
|
||||
await _downloadManager.downloadArtworkForMetadata(showWithServer, client);
|
||||
appLogger.d('Downloaded show artwork for $showGlobalKey');
|
||||
}
|
||||
|
||||
@@ -826,10 +732,7 @@ class DownloadProvider extends ChangeNotifier {
|
||||
try {
|
||||
seasonMetadata = await client.getMetadataWithImages(seasonRatingKey);
|
||||
} catch (e) {
|
||||
appLogger.w(
|
||||
'Failed to fetch season metadata for $seasonRatingKey',
|
||||
error: e,
|
||||
);
|
||||
appLogger.w('Failed to fetch season metadata for $seasonRatingKey', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -842,21 +745,14 @@ class DownloadProvider extends ChangeNotifier {
|
||||
|
||||
// Ensure season artwork is downloaded even if metadata already existed
|
||||
final thumbPath = seasonWithServer.thumb;
|
||||
final hasPoster =
|
||||
thumbPath != null &&
|
||||
await storageService.artworkExists(serverId, thumbPath);
|
||||
final hasPoster = thumbPath != null && await storageService.artworkExists(serverId, thumbPath);
|
||||
if (!hasPoster) {
|
||||
await _downloadManager.downloadArtworkForMetadata(
|
||||
seasonWithServer,
|
||||
client,
|
||||
);
|
||||
await _downloadManager.downloadArtworkForMetadata(seasonWithServer, client);
|
||||
appLogger.d('Downloaded season artwork for $seasonGlobalKey');
|
||||
}
|
||||
|
||||
// Store artwork reference in provider's map for offline display
|
||||
_artworkPaths[seasonGlobalKey] = DownloadedArtwork(
|
||||
thumbPath: thumbPath,
|
||||
);
|
||||
_artworkPaths[seasonGlobalKey] = DownloadedArtwork(thumbPath: thumbPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -889,9 +785,7 @@ class DownloadProvider extends ChangeNotifier {
|
||||
for (final season in seasons) {
|
||||
if (season.type == 'season') {
|
||||
// Ensure season has serverId from parent show
|
||||
final seasonWithServer = season.serverId != null
|
||||
? season
|
||||
: season.copyWith(serverId: show.serverId);
|
||||
final seasonWithServer = season.serverId != null ? season : season.copyWith(serverId: show.serverId);
|
||||
count += await _queueSeasonDownload(seasonWithServer, client);
|
||||
}
|
||||
}
|
||||
@@ -900,10 +794,7 @@ class DownloadProvider extends ChangeNotifier {
|
||||
}
|
||||
|
||||
/// Queue all episodes from a season for download
|
||||
Future<int> _queueSeasonDownload(
|
||||
PlexMetadata season,
|
||||
PlexClient client,
|
||||
) async {
|
||||
Future<int> _queueSeasonDownload(PlexMetadata season, PlexClient client) async {
|
||||
final globalKey = '${season.serverId}:${season.ratingKey}';
|
||||
int count = 0;
|
||||
final episodes = await client.getChildren(season.ratingKey);
|
||||
@@ -930,9 +821,7 @@ class DownloadProvider extends ChangeNotifier {
|
||||
for (final episode in episodes) {
|
||||
if (episode.type == 'episode') {
|
||||
// Ensure episode has serverId from parent season
|
||||
final episodeWithServer = episode.serverId != null
|
||||
? episode
|
||||
: episode.copyWith(serverId: season.serverId);
|
||||
final episodeWithServer = episode.serverId != null ? episode : episode.copyWith(serverId: season.serverId);
|
||||
await _queueSingleDownload(episodeWithServer, client);
|
||||
count++;
|
||||
}
|
||||
@@ -944,10 +833,7 @@ class DownloadProvider extends ChangeNotifier {
|
||||
/// Queue only the missing (not downloaded) episodes for a show/season
|
||||
/// Used for resuming partial downloads
|
||||
/// Returns the number of episodes queued
|
||||
Future<int> queueMissingEpisodes(
|
||||
PlexMetadata metadata,
|
||||
PlexClient client,
|
||||
) async {
|
||||
Future<int> queueMissingEpisodes(PlexMetadata metadata, PlexClient client) async {
|
||||
final type = metadata.type.toLowerCase();
|
||||
|
||||
if (type == 'show') {
|
||||
@@ -960,10 +846,7 @@ class DownloadProvider extends ChangeNotifier {
|
||||
}
|
||||
|
||||
/// Queue missing episodes for a show
|
||||
Future<int> _queueMissingShowEpisodes(
|
||||
PlexMetadata show,
|
||||
PlexClient client,
|
||||
) async {
|
||||
Future<int> _queueMissingShowEpisodes(PlexMetadata show, PlexClient client) async {
|
||||
int queuedCount = 0;
|
||||
|
||||
// Fetch all seasons
|
||||
@@ -971,13 +854,8 @@ class DownloadProvider extends ChangeNotifier {
|
||||
|
||||
for (final season in seasons) {
|
||||
if (season.type == 'season') {
|
||||
final seasonWithServer = season.serverId != null
|
||||
? season
|
||||
: season.copyWith(serverId: show.serverId);
|
||||
queuedCount += await _queueMissingSeasonEpisodes(
|
||||
seasonWithServer,
|
||||
client,
|
||||
);
|
||||
final seasonWithServer = season.serverId != null ? season : season.copyWith(serverId: show.serverId);
|
||||
queuedCount += await _queueMissingSeasonEpisodes(seasonWithServer, client);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -986,10 +864,7 @@ class DownloadProvider extends ChangeNotifier {
|
||||
}
|
||||
|
||||
/// Queue missing episodes for a season
|
||||
Future<int> _queueMissingSeasonEpisodes(
|
||||
PlexMetadata season,
|
||||
PlexClient client,
|
||||
) async {
|
||||
Future<int> _queueMissingSeasonEpisodes(PlexMetadata season, PlexClient client) async {
|
||||
int queuedCount = 0;
|
||||
|
||||
// Fetch all episodes
|
||||
@@ -997,12 +872,9 @@ class DownloadProvider extends ChangeNotifier {
|
||||
|
||||
for (final episode in episodes) {
|
||||
if (episode.type == 'episode') {
|
||||
final episodeWithServer = episode.serverId != null
|
||||
? episode
|
||||
: episode.copyWith(serverId: season.serverId);
|
||||
final episodeWithServer = episode.serverId != null ? episode : episode.copyWith(serverId: season.serverId);
|
||||
|
||||
final episodeGlobalKey =
|
||||
'${episodeWithServer.serverId}:${episodeWithServer.ratingKey}';
|
||||
final episodeGlobalKey = '${episodeWithServer.serverId}:${episodeWithServer.ratingKey}';
|
||||
|
||||
// Only queue if NOT already downloaded or in progress
|
||||
final progress = _downloads[episodeGlobalKey];
|
||||
@@ -1012,9 +884,7 @@ class DownloadProvider extends ChangeNotifier {
|
||||
progress.status != DownloadStatus.queued)) {
|
||||
await _queueSingleDownload(episodeWithServer, client);
|
||||
queuedCount++;
|
||||
appLogger.d(
|
||||
'Queued missing episode: ${episode.title} ($episodeGlobalKey)',
|
||||
);
|
||||
appLogger.d('Queued missing episode: ${episode.title} ($episodeGlobalKey)');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1026,8 +896,7 @@ class DownloadProvider extends ChangeNotifier {
|
||||
Future<void> pauseDownload(String globalKey) async {
|
||||
final progress = _downloads[globalKey];
|
||||
if (progress != null &&
|
||||
(progress.status == DownloadStatus.downloading ||
|
||||
progress.status == DownloadStatus.queued)) {
|
||||
(progress.status == DownloadStatus.downloading || progress.status == DownloadStatus.queued)) {
|
||||
await _downloadManager.pauseDownload(globalKey);
|
||||
}
|
||||
}
|
||||
@@ -1110,12 +979,10 @@ class DownloadProvider extends ChangeNotifier {
|
||||
bool isDeleting(String globalKey) => _deletionProgress.containsKey(globalKey);
|
||||
|
||||
/// Get deletion progress for an item
|
||||
DeletionProgress? getDeletionProgress(String globalKey) =>
|
||||
_deletionProgress[globalKey];
|
||||
DeletionProgress? getDeletionProgress(String globalKey) => _deletionProgress[globalKey];
|
||||
|
||||
/// Get all items currently being deleted
|
||||
UnmodifiableMapView<String, DeletionProgress> get deletionProgress =>
|
||||
UnmodifiableMapView(_deletionProgress);
|
||||
UnmodifiableMapView<String, DeletionProgress> get deletionProgress => UnmodifiableMapView(_deletionProgress);
|
||||
|
||||
/// Refresh the downloads list from database
|
||||
Future<void> refresh() async {
|
||||
@@ -1138,10 +1005,7 @@ class DownloadProvider extends ChangeNotifier {
|
||||
final ratingKey = parts[1];
|
||||
|
||||
try {
|
||||
final cached = await apiCache.get(
|
||||
serverId,
|
||||
'/library/metadata/$ratingKey',
|
||||
);
|
||||
final cached = await apiCache.get(serverId, '/library/metadata/$ratingKey');
|
||||
|
||||
final firstMetadata = PlexCacheParser.extractFirstMetadata(cached);
|
||||
if (firstMetadata != null) {
|
||||
|
||||
@@ -57,8 +57,7 @@ class HiddenLibrariesProvider extends ChangeNotifier {
|
||||
}
|
||||
|
||||
/// Check if a specific library is hidden
|
||||
bool isLibraryHidden(String libraryKey) =>
|
||||
_hiddenLibraryKeys.contains(libraryKey);
|
||||
bool isLibraryHidden(String libraryKey) => _hiddenLibraryKeys.contains(libraryKey);
|
||||
|
||||
/// Refresh hidden libraries from storage
|
||||
/// Useful if storage was modified outside the provider
|
||||
|
||||
@@ -58,9 +58,7 @@ class MultiServerProvider extends ChangeNotifier {
|
||||
appLogger.d('MultiServerProvider: Token updated for server $serverId');
|
||||
notifyListeners();
|
||||
} else {
|
||||
appLogger.w(
|
||||
'MultiServerProvider: Cannot update token - server $serverId not found',
|
||||
);
|
||||
appLogger.w('MultiServerProvider: Cannot update token - server $serverId not found');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,26 +72,16 @@ class MultiServerProvider extends ChangeNotifier {
|
||||
|
||||
/// Reconnect all servers after a profile switch
|
||||
/// Clears existing connections and connects to all provided servers
|
||||
Future<int> reconnectWithServers(
|
||||
List<PlexServer> servers, {
|
||||
String? clientIdentifier,
|
||||
}) async {
|
||||
Future<int> reconnectWithServers(List<PlexServer> servers, {String? clientIdentifier}) async {
|
||||
// Clear existing connections first
|
||||
_serverManager.disconnectAll();
|
||||
_aggregationService.clearCache(); // Clear cached data when servers change
|
||||
appLogger.d(
|
||||
'MultiServerProvider: Cleared connections, reconnecting to ${servers.length} servers',
|
||||
);
|
||||
appLogger.d('MultiServerProvider: Cleared connections, reconnecting to ${servers.length} servers');
|
||||
|
||||
// Connect with new server tokens
|
||||
final connectedCount = await _serverManager.connectToAllServers(
|
||||
servers,
|
||||
clientIdentifier: clientIdentifier,
|
||||
);
|
||||
final connectedCount = await _serverManager.connectToAllServers(servers, clientIdentifier: clientIdentifier);
|
||||
|
||||
appLogger.i(
|
||||
'MultiServerProvider: Reconnected to $connectedCount/${servers.length} servers after profile switch',
|
||||
);
|
||||
appLogger.i('MultiServerProvider: Reconnected to $connectedCount/${servers.length} servers after profile switch');
|
||||
notifyListeners();
|
||||
return connectedCount;
|
||||
}
|
||||
|
||||
@@ -14,8 +14,7 @@ class OfflineModeProvider extends ChangeNotifier {
|
||||
late bool _hasServerConnection;
|
||||
bool _isInitialized = false;
|
||||
|
||||
OfflineModeProvider(this._serverManager)
|
||||
: _hasServerConnection = _serverManager.onlineServerIds.isNotEmpty;
|
||||
OfflineModeProvider(this._serverManager) : _hasServerConnection = _serverManager.onlineServerIds.isNotEmpty;
|
||||
|
||||
/// Whether the app is currently in offline mode
|
||||
/// Offline = no network OR no servers reachable
|
||||
@@ -30,9 +29,7 @@ class OfflineModeProvider extends ChangeNotifier {
|
||||
/// Updates network and server connection flags
|
||||
Future<void> _updateConnectionFlags() async {
|
||||
final connectivityResult = await Connectivity().checkConnectivity();
|
||||
_hasNetworkConnection = !connectivityResult.contains(
|
||||
ConnectivityResult.none,
|
||||
);
|
||||
_hasNetworkConnection = !connectivityResult.contains(ConnectivityResult.none);
|
||||
_hasServerConnection = _serverManager.onlineServerIds.isNotEmpty;
|
||||
}
|
||||
|
||||
@@ -45,9 +42,7 @@ class OfflineModeProvider extends ChangeNotifier {
|
||||
await _updateConnectionFlags();
|
||||
|
||||
// Monitor connectivity changes
|
||||
_connectivitySubscription = Connectivity().onConnectivityChanged.listen((
|
||||
results,
|
||||
) {
|
||||
_connectivitySubscription = Connectivity().onConnectivityChanged.listen((results) {
|
||||
final wasOffline = isOffline;
|
||||
_hasNetworkConnection = !results.contains(ConnectivityResult.none);
|
||||
|
||||
|
||||
@@ -94,9 +94,7 @@ class OfflineWatchProvider extends ChangeNotifier {
|
||||
|
||||
/// Get sorted episodes for a show (by season, then episode number).
|
||||
List<PlexMetadata> _getSortedEpisodes(String showRatingKey) {
|
||||
final episodes = _downloadProvider.getDownloadedEpisodesForShow(
|
||||
showRatingKey,
|
||||
);
|
||||
final episodes = _downloadProvider.getDownloadedEpisodesForShow(showRatingKey);
|
||||
if (episodes.isEmpty) return episodes;
|
||||
|
||||
episodes.sort((a, b) {
|
||||
@@ -122,14 +120,12 @@ class OfflineWatchProvider extends ChangeNotifier {
|
||||
|
||||
// Batch fetch all watch statuses in a single query
|
||||
final globalKeys = episodes.map((e) => e.globalKey).toSet();
|
||||
final localStatuses =
|
||||
await _syncService.getLocalWatchStatusesBatched(globalKeys);
|
||||
final localStatuses = await _syncService.getLocalWatchStatusesBatched(globalKeys);
|
||||
|
||||
// Find first unwatched episode
|
||||
for (final episode in episodes) {
|
||||
final localStatus = localStatuses[episode.globalKey];
|
||||
final watched =
|
||||
localStatus ?? _downloadProvider.getMetadata(episode.globalKey)?.isWatched ?? false;
|
||||
final watched = localStatus ?? _downloadProvider.getMetadata(episode.globalKey)?.isWatched ?? false;
|
||||
if (!watched) {
|
||||
return episode;
|
||||
}
|
||||
@@ -161,28 +157,16 @@ class OfflineWatchProvider extends ChangeNotifier {
|
||||
/// Mark an item as watched while offline.
|
||||
///
|
||||
/// This queues the action for sync when online.
|
||||
Future<void> markAsWatched({
|
||||
required String serverId,
|
||||
required String ratingKey,
|
||||
}) async {
|
||||
await _syncService.queueMarkWatched(
|
||||
serverId: serverId,
|
||||
ratingKey: ratingKey,
|
||||
);
|
||||
Future<void> markAsWatched({required String serverId, required String ratingKey}) async {
|
||||
await _syncService.queueMarkWatched(serverId: serverId, ratingKey: ratingKey);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Mark an item as unwatched while offline.
|
||||
///
|
||||
/// This queues the action for sync when online.
|
||||
Future<void> markAsUnwatched({
|
||||
required String serverId,
|
||||
required String ratingKey,
|
||||
}) async {
|
||||
await _syncService.queueMarkUnwatched(
|
||||
serverId: serverId,
|
||||
ratingKey: ratingKey,
|
||||
);
|
||||
Future<void> markAsUnwatched({required String serverId, required String ratingKey}) async {
|
||||
await _syncService.queueMarkUnwatched(serverId: serverId, ratingKey: ratingKey);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -190,24 +174,19 @@ class OfflineWatchProvider extends ChangeNotifier {
|
||||
///
|
||||
/// Returns a list of (episode, isWatched) pairs.
|
||||
/// Uses batched database query for efficiency.
|
||||
Future<List<(PlexMetadata episode, bool isWatched)>>
|
||||
getEpisodesWithWatchStatus(String showRatingKey) async {
|
||||
final episodes = _downloadProvider.getDownloadedEpisodesForShow(
|
||||
showRatingKey,
|
||||
);
|
||||
Future<List<(PlexMetadata episode, bool isWatched)>> getEpisodesWithWatchStatus(String showRatingKey) async {
|
||||
final episodes = _downloadProvider.getDownloadedEpisodesForShow(showRatingKey);
|
||||
|
||||
if (episodes.isEmpty) return [];
|
||||
|
||||
// Batch fetch all watch statuses in a single query
|
||||
final globalKeys = episodes.map((e) => e.globalKey).toSet();
|
||||
final localStatuses =
|
||||
await _syncService.getLocalWatchStatusesBatched(globalKeys);
|
||||
final localStatuses = await _syncService.getLocalWatchStatusesBatched(globalKeys);
|
||||
|
||||
final results = <(PlexMetadata, bool)>[];
|
||||
for (final episode in episodes) {
|
||||
final localStatus = localStatuses[episode.globalKey];
|
||||
final watched =
|
||||
localStatus ?? _downloadProvider.getMetadata(episode.globalKey)?.isWatched ?? false;
|
||||
final watched = localStatus ?? _downloadProvider.getMetadata(episode.globalKey)?.isWatched ?? false;
|
||||
results.add((episode, watched));
|
||||
}
|
||||
|
||||
|
||||
@@ -16,11 +16,7 @@ class _IndexLookupResult {
|
||||
final bool attemptedLoad;
|
||||
final bool loadFailed;
|
||||
|
||||
const _IndexLookupResult({
|
||||
this.index,
|
||||
this.attemptedLoad = false,
|
||||
this.loadFailed = false,
|
||||
});
|
||||
const _IndexLookupResult({this.index, this.attemptedLoad = false, this.loadFailed = false});
|
||||
}
|
||||
|
||||
/// Manages playback state using Plex's play queue API.
|
||||
@@ -53,8 +49,7 @@ class PlaybackStateProvider with ChangeNotifier {
|
||||
bool get isPlaylistActive => _playbackMode == PlaybackMode.playQueue;
|
||||
|
||||
/// Whether any queue-based playback is active
|
||||
bool get isQueueActive =>
|
||||
_playQueueId != null && _playbackMode == PlaybackMode.playQueue;
|
||||
bool get isQueueActive => _playQueueId != null && _playbackMode == PlaybackMode.playQueue;
|
||||
|
||||
/// The context key (show/season/playlist ratingKey) for the current session
|
||||
String? get shuffleContextKey => _contextKey;
|
||||
@@ -68,9 +63,7 @@ class PlaybackStateProvider with ChangeNotifier {
|
||||
/// Gets the current position in the queue (1-indexed)
|
||||
int get currentPosition {
|
||||
if (_currentPlayQueueItemID == null || _loadedItems.isEmpty) return 0;
|
||||
final index = _loadedItems.indexWhere(
|
||||
(item) => item.playQueueItemID == _currentPlayQueueItemID,
|
||||
);
|
||||
final index = _loadedItems.indexWhere((item) => item.playQueueItemID == _currentPlayQueueItemID);
|
||||
return index != -1 ? index + 1 : 0;
|
||||
}
|
||||
|
||||
@@ -81,8 +74,7 @@ class PlaybackStateProvider with ChangeNotifier {
|
||||
|
||||
/// Update the current play queue item when playing a new item
|
||||
void setCurrentItem(PlexMetadata metadata) {
|
||||
if (_playbackMode == PlaybackMode.playQueue &&
|
||||
metadata.playQueueItemID != null) {
|
||||
if (_playbackMode == PlaybackMode.playQueue && metadata.playQueueItemID != null) {
|
||||
_currentPlayQueueItemID = metadata.playQueueItemID;
|
||||
notifyListeners();
|
||||
}
|
||||
@@ -98,10 +90,7 @@ class PlaybackStateProvider with ChangeNotifier {
|
||||
}) async {
|
||||
_playQueueId = playQueue.playQueueID;
|
||||
// Use size or items length as fallback if totalCount is null
|
||||
_playQueueTotalCount =
|
||||
playQueue.playQueueTotalCount ??
|
||||
playQueue.size ??
|
||||
(playQueue.items?.length ?? 0);
|
||||
_playQueueTotalCount = playQueue.playQueueTotalCount ?? playQueue.size ?? (playQueue.items?.length ?? 0);
|
||||
_playQueueShuffled = playQueue.playQueueShuffled;
|
||||
_currentPlayQueueItemID = playQueue.playQueueSelectedItemID;
|
||||
|
||||
@@ -119,9 +108,7 @@ class PlaybackStateProvider with ChangeNotifier {
|
||||
if (_client == null || _playQueueId == null) return false;
|
||||
|
||||
// Check if the target item is already loaded
|
||||
final hasItem = _loadedItems.any(
|
||||
(item) => item.playQueueItemID == targetPlayQueueItemID,
|
||||
);
|
||||
final hasItem = _loadedItems.any((item) => item.playQueueItemID == targetPlayQueueItemID);
|
||||
|
||||
if (hasItem) return true;
|
||||
|
||||
@@ -137,10 +124,7 @@ class PlaybackStateProvider with ChangeNotifier {
|
||||
// Items are already tagged with server info by PlexClient
|
||||
_loadedItems = response.items!;
|
||||
// Use size or items length as fallback if totalCount is null
|
||||
_playQueueTotalCount =
|
||||
response.playQueueTotalCount ??
|
||||
response.size ??
|
||||
response.items!.length;
|
||||
_playQueueTotalCount = response.playQueueTotalCount ?? response.size ?? response.items!.length;
|
||||
_playQueueShuffled = response.playQueueShuffled;
|
||||
notifyListeners();
|
||||
return true;
|
||||
@@ -153,18 +137,12 @@ class PlaybackStateProvider with ChangeNotifier {
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<_IndexLookupResult> _getCurrentIndex({
|
||||
bool loadIfMissing = false,
|
||||
}) async {
|
||||
if (_playbackMode != PlaybackMode.playQueue ||
|
||||
_loadedItems.isEmpty ||
|
||||
_currentPlayQueueItemID == null) {
|
||||
Future<_IndexLookupResult> _getCurrentIndex({bool loadIfMissing = false}) async {
|
||||
if (_playbackMode != PlaybackMode.playQueue || _loadedItems.isEmpty || _currentPlayQueueItemID == null) {
|
||||
return const _IndexLookupResult();
|
||||
}
|
||||
|
||||
var currentIndex = _loadedItems.indexWhere(
|
||||
(item) => item.playQueueItemID == _currentPlayQueueItemID,
|
||||
);
|
||||
var currentIndex = _loadedItems.indexWhere((item) => item.playQueueItemID == _currentPlayQueueItemID);
|
||||
|
||||
if (currentIndex != -1) {
|
||||
return _IndexLookupResult(index: currentIndex);
|
||||
@@ -179,9 +157,7 @@ class PlaybackStateProvider with ChangeNotifier {
|
||||
return const _IndexLookupResult(attemptedLoad: true, loadFailed: true);
|
||||
}
|
||||
|
||||
currentIndex = _loadedItems.indexWhere(
|
||||
(item) => item.playQueueItemID == _currentPlayQueueItemID,
|
||||
);
|
||||
currentIndex = _loadedItems.indexWhere((item) => item.playQueueItemID == _currentPlayQueueItemID);
|
||||
|
||||
if (currentIndex == -1) {
|
||||
return const _IndexLookupResult(attemptedLoad: true, loadFailed: true);
|
||||
@@ -193,10 +169,7 @@ class PlaybackStateProvider with ChangeNotifier {
|
||||
/// Gets the next item in the playback queue.
|
||||
/// Returns null if queue is exhausted or current item is not in queue.
|
||||
/// [loopQueue] - If true, restart from beginning when queue is exhausted
|
||||
Future<PlexMetadata?> getNextEpisode(
|
||||
String currentItemKey, {
|
||||
bool loopQueue = false,
|
||||
}) async {
|
||||
Future<PlexMetadata?> getNextEpisode(String currentItemKey, {bool loopQueue = false}) async {
|
||||
if (_playbackMode != PlaybackMode.playQueue) {
|
||||
// For sequential mode, let the video player handle next episode
|
||||
return null;
|
||||
@@ -224,9 +197,7 @@ class PlaybackStateProvider with ChangeNotifier {
|
||||
// Loop back to beginning - load first item
|
||||
if (_client != null && _playQueueId != null) {
|
||||
final response = await _client!.getPlayQueue(_playQueueId!);
|
||||
if (response != null &&
|
||||
response.items != null &&
|
||||
response.items!.isNotEmpty) {
|
||||
if (response != null && response.items != null && response.items!.isNotEmpty) {
|
||||
// Items are already tagged with server info by PlexClient
|
||||
_loadedItems = response.items!;
|
||||
final firstItem = _loadedItems.first;
|
||||
|
||||
@@ -9,19 +9,16 @@ class ThemeProvider extends ChangeNotifier {
|
||||
late Brightness _systemBrightness;
|
||||
|
||||
ThemeProvider() {
|
||||
_systemBrightness =
|
||||
WidgetsBinding.instance.platformDispatcher.platformBrightness;
|
||||
_systemBrightness = WidgetsBinding.instance.platformDispatcher.platformBrightness;
|
||||
_initializeSettings();
|
||||
|
||||
// Listen to system theme changes
|
||||
WidgetsBinding.instance.platformDispatcher.onPlatformBrightnessChanged =
|
||||
() {
|
||||
_systemBrightness =
|
||||
WidgetsBinding.instance.platformDispatcher.platformBrightness;
|
||||
if (_themeMode == settings.ThemeMode.system) {
|
||||
notifyListeners();
|
||||
}
|
||||
};
|
||||
WidgetsBinding.instance.platformDispatcher.onPlatformBrightnessChanged = () {
|
||||
_systemBrightness = WidgetsBinding.instance.platformDispatcher.platformBrightness;
|
||||
if (_themeMode == settings.ThemeMode.system) {
|
||||
notifyListeners();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Future<void> _initializeSettings() async {
|
||||
|
||||
@@ -25,9 +25,7 @@ class UserProfileProvider extends ChangeNotifier {
|
||||
String? get error => _error;
|
||||
bool get hasMultipleUsers {
|
||||
final result = _home?.hasMultipleUsers ?? false;
|
||||
appLogger.d(
|
||||
'hasMultipleUsers: _home=${_home != null}, users count=${_home?.users.length ?? 0}, result=$result',
|
||||
);
|
||||
appLogger.d('hasMultipleUsers: _home=${_home != null}, users count=${_home?.users.length ?? 0}, result=$result');
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -40,9 +38,7 @@ class UserProfileProvider extends ChangeNotifier {
|
||||
|
||||
/// Set a callback to be called when profile switching requires data invalidation
|
||||
/// The callback receives the list of servers with the new profile's access tokens
|
||||
void setDataInvalidationCallback(
|
||||
Future<void> Function(List<PlexServer>)? callback,
|
||||
) {
|
||||
void setDataInvalidationCallback(Future<void> Function(List<PlexServer>)? callback) {
|
||||
_onDataInvalidationRequested = callback;
|
||||
}
|
||||
|
||||
@@ -50,9 +46,7 @@ class UserProfileProvider extends ChangeNotifier {
|
||||
Future<void> _invalidateAllData(List<PlexServer> servers) async {
|
||||
if (_onDataInvalidationRequested != null) {
|
||||
await _onDataInvalidationRequested!(servers);
|
||||
appLogger.d(
|
||||
'Data invalidation triggered for profile switch with ${servers.length} servers',
|
||||
);
|
||||
appLogger.d('Data invalidation triggered for profile switch with ${servers.length} servers');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,16 +65,11 @@ class UserProfileProvider extends ChangeNotifier {
|
||||
|
||||
// If no cached home data or it's expired, try to load from API
|
||||
if (_home == null) {
|
||||
appLogger.d(
|
||||
'UserProfileProvider: No cached home data, attempting to load from API',
|
||||
);
|
||||
appLogger.d('UserProfileProvider: No cached home data, attempting to load from API');
|
||||
try {
|
||||
await loadHomeUsers();
|
||||
} catch (e) {
|
||||
appLogger.w(
|
||||
'UserProfileProvider: Failed to load home users during initialization',
|
||||
error: e,
|
||||
);
|
||||
appLogger.w('UserProfileProvider: Failed to load home users during initialization', error: e);
|
||||
// Don't set error here as it's not critical for app startup
|
||||
}
|
||||
}
|
||||
@@ -90,20 +79,14 @@ class UserProfileProvider extends ChangeNotifier {
|
||||
try {
|
||||
await refreshProfileSettings();
|
||||
} catch (e) {
|
||||
appLogger.w(
|
||||
'UserProfileProvider: Failed to fetch profile settings during initialization',
|
||||
error: e,
|
||||
);
|
||||
appLogger.w('UserProfileProvider: Failed to fetch profile settings during initialization', error: e);
|
||||
// Don't set error here, cached profile (if any) was already loaded
|
||||
}
|
||||
|
||||
_isInitialized = true;
|
||||
appLogger.d('UserProfileProvider: Initialization complete');
|
||||
} catch (e) {
|
||||
appLogger.e(
|
||||
'UserProfileProvider: Critical initialization failure',
|
||||
error: e,
|
||||
);
|
||||
appLogger.e('UserProfileProvider: Critical initialization failure', error: e);
|
||||
_setError('Failed to initialize profile services');
|
||||
// Ensure services are null on failure
|
||||
_authService = null;
|
||||
@@ -148,9 +131,7 @@ class UserProfileProvider extends ChangeNotifier {
|
||||
try {
|
||||
final currentToken = _storageService!.getPlexToken();
|
||||
if (currentToken == null) {
|
||||
appLogger.w(
|
||||
'refreshProfileSettings: No Plex token available, cannot fetch profile',
|
||||
);
|
||||
appLogger.w('refreshProfileSettings: No Plex token available, cannot fetch profile');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -171,9 +152,7 @@ class UserProfileProvider extends ChangeNotifier {
|
||||
|
||||
// Auto-initialize services if not ready
|
||||
if (_authService == null || _storageService == null) {
|
||||
appLogger.d(
|
||||
'loadHomeUsers: Services not initialized, initializing services...',
|
||||
);
|
||||
appLogger.d('loadHomeUsers: Services not initialized, initializing services...');
|
||||
_authService = await PlexAuthService.create();
|
||||
_storageService = await StorageService.getInstance();
|
||||
await _loadCachedData();
|
||||
@@ -188,9 +167,7 @@ class UserProfileProvider extends ChangeNotifier {
|
||||
|
||||
// Use cached data if available and not forcing refresh
|
||||
if (!forceRefresh && _home != null) {
|
||||
appLogger.d(
|
||||
'loadHomeUsers: Using cached data, users count: ${_home!.users.length}',
|
||||
);
|
||||
appLogger.d('loadHomeUsers: Using cached data, users count: ${_home!.users.length}');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -208,12 +185,8 @@ class UserProfileProvider extends ChangeNotifier {
|
||||
final home = await _authService!.getHomeUsers(currentToken);
|
||||
_home = home;
|
||||
|
||||
appLogger.i(
|
||||
'loadHomeUsers: Success! Home users count: ${home.users.length}',
|
||||
);
|
||||
appLogger.d(
|
||||
'loadHomeUsers: Users: ${home.users.map((u) => u.displayName).join(', ')}',
|
||||
);
|
||||
appLogger.i('loadHomeUsers: Success! Home users count: ${home.users.length}');
|
||||
appLogger.d('loadHomeUsers: Users: ${home.users.map((u) => u.displayName).join(', ')}');
|
||||
|
||||
// Cache the home data
|
||||
await _storageService!.saveHomeUsersCache(home.toJson());
|
||||
@@ -223,17 +196,13 @@ class UserProfileProvider extends ChangeNotifier {
|
||||
final currentUserUUID = _storageService!.getCurrentUserUUID();
|
||||
if (currentUserUUID != null) {
|
||||
_currentUser = home.getUserByUUID(currentUserUUID);
|
||||
appLogger.d(
|
||||
'loadHomeUsers: Set current user from UUID: ${_currentUser?.displayName}',
|
||||
);
|
||||
appLogger.d('loadHomeUsers: Set current user from UUID: ${_currentUser?.displayName}');
|
||||
} else {
|
||||
// Default to admin user if no current user set
|
||||
_currentUser = home.adminUser;
|
||||
if (_currentUser != null) {
|
||||
await _storageService!.saveCurrentUserUUID(_currentUser!.uuid);
|
||||
appLogger.d(
|
||||
'loadHomeUsers: Set current user to admin: ${_currentUser?.displayName}',
|
||||
);
|
||||
appLogger.d('loadHomeUsers: Set current user to admin: ${_currentUser?.displayName}');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -289,11 +258,7 @@ class UserProfileProvider extends ChangeNotifier {
|
||||
// Check if user requires PIN
|
||||
String? pin;
|
||||
if (user.requiresPassword && context != null && context.mounted) {
|
||||
pin = await showPinEntryDialog(
|
||||
context,
|
||||
user.displayName,
|
||||
errorMessage: errorMessage,
|
||||
);
|
||||
pin = await showPinEntryDialog(context, user.displayName, errorMessage: errorMessage);
|
||||
|
||||
// User cancelled the PIN dialog
|
||||
if (pin == null) {
|
||||
@@ -302,19 +267,13 @@ class UserProfileProvider extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
final switchResponse = await _authService!.switchToUser(
|
||||
user.uuid,
|
||||
currentToken,
|
||||
pin: pin,
|
||||
);
|
||||
final switchResponse = await _authService!.switchToUser(user.uuid, currentToken, pin: pin);
|
||||
|
||||
// switchResponse.authToken is the new user's Plex.tv token
|
||||
// Fetch servers with this token to get the proper server access tokens
|
||||
appLogger.d('Got new user Plex.tv token, fetching servers...');
|
||||
|
||||
final servers = await _authService!.fetchServers(
|
||||
switchResponse.authToken,
|
||||
);
|
||||
final servers = await _authService!.fetchServers(switchResponse.authToken);
|
||||
if (servers.isEmpty) {
|
||||
throw Exception('No servers available for this user');
|
||||
}
|
||||
@@ -335,10 +294,8 @@ class UserProfileProvider extends ChangeNotifier {
|
||||
appLogger.d(
|
||||
'Updated profile settings for user: ${user.displayName}',
|
||||
error: {
|
||||
'defaultAudioLanguage':
|
||||
_profileSettings?.defaultAudioLanguage ?? 'not set',
|
||||
'defaultSubtitleLanguage':
|
||||
_profileSettings?.defaultSubtitleLanguage ?? 'not set',
|
||||
'defaultAudioLanguage': _profileSettings?.defaultAudioLanguage ?? 'not set',
|
||||
'defaultSubtitleLanguage': _profileSettings?.defaultSubtitleLanguage ?? 'not set',
|
||||
},
|
||||
);
|
||||
|
||||
@@ -348,9 +305,7 @@ class UserProfileProvider extends ChangeNotifier {
|
||||
// The callback will handle server reconnection using the servers list
|
||||
await _invalidateAllData(servers);
|
||||
|
||||
appLogger.d(
|
||||
'Profile switch complete, all servers reconnected with new tokens',
|
||||
);
|
||||
appLogger.d('Profile switch complete, all servers reconnected with new tokens');
|
||||
|
||||
appLogger.i('Successfully switched to user: ${user.displayName}');
|
||||
return true;
|
||||
@@ -462,9 +417,7 @@ class UserProfileProvider extends ChangeNotifier {
|
||||
await _loadCachedData();
|
||||
|
||||
// Load from API since we cleared the cache
|
||||
appLogger.d(
|
||||
'UserProfileProvider: Loading fresh home users for new server',
|
||||
);
|
||||
appLogger.d('UserProfileProvider: Loading fresh home users for new server');
|
||||
|
||||
// Store context reference before async operations to avoid build context warnings
|
||||
final contextForSwitch = context;
|
||||
@@ -485,42 +438,27 @@ class UserProfileProvider extends ChangeNotifier {
|
||||
final success = await switchToUser(userToSwitchTo, contextForSwitch);
|
||||
|
||||
if (success) {
|
||||
appLogger.d(
|
||||
'UserProfileProvider: Successfully switched to admin user for new server',
|
||||
);
|
||||
appLogger.d('UserProfileProvider: Successfully switched to admin user for new server');
|
||||
} else {
|
||||
appLogger.w(
|
||||
'UserProfileProvider: Failed to complete profile switch for new server',
|
||||
);
|
||||
appLogger.w('UserProfileProvider: Failed to complete profile switch for new server');
|
||||
}
|
||||
} else if (_currentUser != null && contextForSwitch == null) {
|
||||
appLogger.w(
|
||||
'UserProfileProvider: Cannot perform complete profile switch - no context provided',
|
||||
);
|
||||
appLogger.w('UserProfileProvider: Cannot perform complete profile switch - no context provided');
|
||||
// Still try to fetch profile settings even without full switch
|
||||
try {
|
||||
await refreshProfileSettings();
|
||||
} catch (e) {
|
||||
appLogger.w(
|
||||
'UserProfileProvider: Failed to refresh profile settings for new server',
|
||||
error: e,
|
||||
);
|
||||
appLogger.w('UserProfileProvider: Failed to refresh profile settings for new server', error: e);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.w(
|
||||
'UserProfileProvider: Failed to load home users for new server',
|
||||
error: e,
|
||||
);
|
||||
appLogger.w('UserProfileProvider: Failed to load home users for new server', error: e);
|
||||
// Don't set error as it's not critical
|
||||
}
|
||||
|
||||
appLogger.d('UserProfileProvider: Refresh for new server complete');
|
||||
} catch (e) {
|
||||
appLogger.e(
|
||||
'UserProfileProvider: Failed to refresh for new server',
|
||||
error: e,
|
||||
);
|
||||
appLogger.e('UserProfileProvider: Failed to refresh for new server', error: e);
|
||||
_setError('Failed to refresh for new server');
|
||||
} finally {
|
||||
_setLoading(false);
|
||||
|
||||
@@ -61,10 +61,7 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
await storage.clearCredentials();
|
||||
setState(() {
|
||||
_isAuthenticating = false;
|
||||
_errorMessage = t.serverSelection.noServersFoundForAccount(
|
||||
username: username,
|
||||
email: email,
|
||||
);
|
||||
_errorMessage = t.serverSelection.noServersFoundForAccount(username: username, email: email);
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -80,8 +77,7 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
// Connect to all servers
|
||||
if (!mounted) return;
|
||||
final multiServerProvider = context.read<MultiServerProvider>();
|
||||
final connectedCount = await multiServerProvider.serverManager
|
||||
.connectToAllServers(servers);
|
||||
final connectedCount = await multiServerProvider.serverManager.connectToAllServers(servers);
|
||||
|
||||
if (connectedCount == 0) {
|
||||
setState(() {
|
||||
@@ -93,8 +89,7 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
|
||||
// Get the first connected client for backward compatibility
|
||||
if (!mounted) return;
|
||||
final firstClient =
|
||||
multiServerProvider.serverManager.onlineClients.values.first;
|
||||
final firstClient = multiServerProvider.serverManager.onlineClients.values.first;
|
||||
|
||||
// Set it as the legacy client
|
||||
final plexClientProvider = context.read<PlexClientProvider>();
|
||||
@@ -102,12 +97,7 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
|
||||
// Navigate to main screen
|
||||
if (!mounted) return;
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => MainScreen(client: firstClient),
|
||||
),
|
||||
);
|
||||
Navigator.pushReplacement(context, MaterialPageRoute(builder: (context) => MainScreen(client: firstClient)));
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to connect to servers', error: e);
|
||||
setState(() {
|
||||
@@ -153,10 +143,7 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
}
|
||||
|
||||
// Poll for authentication with cancellation support
|
||||
final token = await _authService.pollPinUntilClaimed(
|
||||
pinId,
|
||||
shouldCancel: () => _shouldCancelPolling,
|
||||
);
|
||||
final token = await _authService.pollPinUntilClaimed(pinId, shouldCancel: () => _shouldCancelPolling);
|
||||
|
||||
// If polling was cancelled, don't show error
|
||||
if (_shouldCancelPolling) {
|
||||
@@ -245,10 +232,7 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(t.auth.cancel),
|
||||
),
|
||||
TextButton(onPressed: () => Navigator.of(context).pop(), child: Text(t.auth.cancel)),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
final token = tokenController.text.trim();
|
||||
@@ -315,16 +299,11 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Image.asset(
|
||||
'assets/plezy.png',
|
||||
width: 120,
|
||||
height: 120,
|
||||
),
|
||||
Image.asset('assets/plezy.png', width: 120, height: 120),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
t.app.title,
|
||||
style: Theme.of(context).textTheme.headlineMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
@@ -346,11 +325,7 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
// Initial state buttons
|
||||
ElevatedButton(
|
||||
onPressed: _startAuthentication,
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 16,
|
||||
),
|
||||
),
|
||||
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
|
||||
child: Text(t.auth.signInWithPlex),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
@@ -361,11 +336,7 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
});
|
||||
_startAuthentication();
|
||||
},
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 16,
|
||||
),
|
||||
),
|
||||
style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
|
||||
child: Text(t.auth.showQRCode),
|
||||
),
|
||||
if (kDebugMode) ...[
|
||||
@@ -373,27 +344,17 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
OutlinedButton(
|
||||
onPressed: _handleDebugTap,
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 12,
|
||||
),
|
||||
side: BorderSide(
|
||||
color: Theme.of(context).colorScheme.outline
|
||||
.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
t.auth.debugEnterToken,
|
||||
style: TextStyle(fontSize: 12),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
side: BorderSide(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: Text(t.auth.debugEnterToken, style: TextStyle(fontSize: 12)),
|
||||
),
|
||||
],
|
||||
if (_errorMessage != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_errorMessage!,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
@@ -411,8 +372,7 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
t.app.title,
|
||||
style: Theme.of(context).textTheme.headlineMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
@@ -425,9 +385,7 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
// add QR button here
|
||||
ElevatedButton(
|
||||
onPressed: _startAuthentication,
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
),
|
||||
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
|
||||
child: Text(t.auth.signInWithPlex),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
@@ -438,9 +396,7 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
});
|
||||
_startAuthentication();
|
||||
},
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
),
|
||||
style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
|
||||
child: Text(t.auth.showQRCode),
|
||||
),
|
||||
if (kDebugMode) ...[
|
||||
@@ -449,25 +405,16 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
onPressed: _handleDebugTap,
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
side: BorderSide(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.outline.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
t.auth.debugEnterToken,
|
||||
style: TextStyle(fontSize: 12),
|
||||
side: BorderSide(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: Text(t.auth.debugEnterToken, style: TextStyle(fontSize: 12)),
|
||||
),
|
||||
],
|
||||
if (_errorMessage != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_errorMessage!,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
@@ -486,9 +433,7 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
const SizedBox(height: 24),
|
||||
OutlinedButton(
|
||||
onPressed: _retryAuthentication,
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 24),
|
||||
),
|
||||
style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 24)),
|
||||
child: Text(t.auth.retry),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -16,9 +16,7 @@ import 'libraries/state_messages.dart';
|
||||
|
||||
/// Abstract base class for screens displaying media lists (collections/playlists)
|
||||
/// Provides common state management and playback functionality
|
||||
abstract class BaseMediaListDetailScreen<T extends StatefulWidget>
|
||||
extends State<T>
|
||||
with Refreshable, ItemUpdatable {
|
||||
abstract class BaseMediaListDetailScreen<T extends StatefulWidget> extends State<T> with Refreshable, ItemUpdatable {
|
||||
// State properties - concrete implementations to avoid duplication
|
||||
List<PlexMetadata> items = [];
|
||||
bool isLoading = false;
|
||||
@@ -59,10 +57,7 @@ abstract class BaseMediaListDetailScreen<T extends StatefulWidget>
|
||||
|
||||
// If serverId is null, fall back to first available server
|
||||
if (serverId == null) {
|
||||
final multiServerProvider = Provider.of<MultiServerProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
final multiServerProvider = Provider.of<MultiServerProvider>(context, listen: false);
|
||||
if (!multiServerProvider.hasConnectedServers) {
|
||||
throw Exception(t.errors.noClientAvailable);
|
||||
}
|
||||
@@ -91,9 +86,7 @@ abstract class BaseMediaListDetailScreen<T extends StatefulWidget>
|
||||
Future<void> _playWithShuffle(bool shuffle) async {
|
||||
if (items.isEmpty) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(emptyMessage)));
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(emptyMessage)));
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -104,19 +97,11 @@ abstract class BaseMediaListDetailScreen<T extends StatefulWidget>
|
||||
final launcher = PlayQueueLauncher(
|
||||
context: context,
|
||||
client: client,
|
||||
serverId: item is PlexMetadata
|
||||
? item.serverId
|
||||
: (item as PlexPlaylist).serverId,
|
||||
serverName: item is PlexMetadata
|
||||
? item.serverName
|
||||
: (item as PlexPlaylist).serverName,
|
||||
serverId: item is PlexMetadata ? item.serverId : (item as PlexPlaylist).serverId,
|
||||
serverName: item is PlexMetadata ? item.serverName : (item as PlexPlaylist).serverName,
|
||||
);
|
||||
|
||||
await launcher.launchFromCollectionOrPlaylist(
|
||||
item: item,
|
||||
shuffle: shuffle,
|
||||
showLoadingIndicator: false,
|
||||
);
|
||||
await launcher.launchFromCollectionOrPlaylist(item: item, shuffle: shuffle, showLoadingIndicator: false);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -142,30 +127,19 @@ abstract class BaseMediaListDetailScreen<T extends StatefulWidget>
|
||||
if (errorMessage != null) {
|
||||
return [
|
||||
SliverFillRemaining(
|
||||
child: ErrorStateWidget(
|
||||
message: errorMessage!,
|
||||
icon: Symbols.error_outline_rounded,
|
||||
onRetry: loadItems,
|
||||
),
|
||||
child: ErrorStateWidget(message: errorMessage!, icon: Symbols.error_outline_rounded, onRetry: loadItems),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
if (items.isEmpty && isLoading) {
|
||||
return [
|
||||
const SliverFillRemaining(
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
];
|
||||
return [const SliverFillRemaining(child: Center(child: CircularProgressIndicator()))];
|
||||
}
|
||||
|
||||
if (items.isEmpty) {
|
||||
return [
|
||||
SliverFillRemaining(
|
||||
child: EmptyStateWidget(
|
||||
message: emptyMessage,
|
||||
icon: emptyIcon,
|
||||
),
|
||||
child: EmptyStateWidget(message: emptyMessage, icon: emptyIcon),
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -210,8 +184,7 @@ abstract class BaseMediaListDetailScreen<T extends StatefulWidget>
|
||||
|
||||
/// Mixin that provides standard loadItems implementation for media lists
|
||||
/// Handles the common pattern of fetching, tagging, and setting items
|
||||
mixin StandardItemLoader<T extends StatefulWidget>
|
||||
on BaseMediaListDetailScreen<T> {
|
||||
mixin StandardItemLoader<T extends StatefulWidget> on BaseMediaListDetailScreen<T> {
|
||||
/// Fetch items from the API (must be implemented by subclass)
|
||||
Future<List<PlexMetadata>> fetchItems();
|
||||
|
||||
|
||||
@@ -18,8 +18,7 @@ class CollectionDetailScreen extends StatefulWidget {
|
||||
State<CollectionDetailScreen> createState() => _CollectionDetailScreenState();
|
||||
}
|
||||
|
||||
class _CollectionDetailScreenState
|
||||
extends BaseMediaListDetailScreen<CollectionDetailScreen>
|
||||
class _CollectionDetailScreenState extends BaseMediaListDetailScreen<CollectionDetailScreen>
|
||||
with StandardItemLoader<CollectionDetailScreen> {
|
||||
@override
|
||||
PlexMetadata get mediaItem => widget.collection;
|
||||
@@ -72,20 +71,14 @@ class _CollectionDetailScreenState
|
||||
if (!mounted) return;
|
||||
|
||||
try {
|
||||
final success = await client.deleteCollection(
|
||||
sectionId.toString(),
|
||||
widget.collection.ratingKey,
|
||||
);
|
||||
final success = await client.deleteCollection(sectionId.toString(), widget.collection.ratingKey);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (mounted) {
|
||||
if (success) {
|
||||
showSuccessSnackBar(context, t.collections.deleted);
|
||||
Navigator.pop(
|
||||
context,
|
||||
true,
|
||||
); // Return true to indicate refresh needed
|
||||
Navigator.pop(context, true); // Return true to indicate refresh needed
|
||||
} else {
|
||||
showErrorSnackBar(context, t.collections.deleteFailed);
|
||||
}
|
||||
@@ -93,10 +86,7 @@ class _CollectionDetailScreenState
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to delete collection', error: e);
|
||||
if (mounted) {
|
||||
showErrorSnackBar(
|
||||
context,
|
||||
t.collections.deleteFailedWithError(error: e.toString()),
|
||||
);
|
||||
showErrorSnackBar(context, t.collections.deleteFailedWithError(error: e.toString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+124
-387
@@ -46,16 +46,11 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
|
||||
@override
|
||||
PlexClient get client {
|
||||
final multiServerProvider = Provider.of<MultiServerProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
final multiServerProvider = Provider.of<MultiServerProvider>(context, listen: false);
|
||||
if (!multiServerProvider.hasConnectedServers) {
|
||||
throw Exception('No servers available');
|
||||
}
|
||||
return context.getClientForServer(
|
||||
multiServerProvider.onlineServerIds.first,
|
||||
);
|
||||
return context.getClientForServer(multiServerProvider.onlineServerIds.first);
|
||||
}
|
||||
|
||||
List<PlexMetadata> _onDeck = [];
|
||||
@@ -86,16 +81,11 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
// Items should always have a serverId, but if not, fall back to first available server
|
||||
final serverId = item?.serverId;
|
||||
if (serverId == null) {
|
||||
final multiServerProvider = Provider.of<MultiServerProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
final multiServerProvider = Provider.of<MultiServerProvider>(context, listen: false);
|
||||
if (!multiServerProvider.hasConnectedServers) {
|
||||
throw Exception('No servers available');
|
||||
}
|
||||
return context.getClientForServer(
|
||||
multiServerProvider.onlineServerIds.first,
|
||||
);
|
||||
return context.getClientForServer(multiServerProvider.onlineServerIds.first);
|
||||
}
|
||||
return context.getClientForServer(serverId);
|
||||
}
|
||||
@@ -132,11 +122,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
if (isUp && hubIndex == 0) {
|
||||
_heroFocusNode.requestFocus();
|
||||
// Scroll to top to show hero fully
|
||||
_scrollController.animateTo(
|
||||
0,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
_scrollController.animateTo(0, duration: const Duration(milliseconds: 200), curve: Curves.easeOut);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -166,10 +152,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_indicatorAnimationController = AnimationController(
|
||||
vsync: this,
|
||||
duration: _heroAutoScrollDuration,
|
||||
);
|
||||
_indicatorAnimationController = AnimationController(vsync: this, duration: _heroAutoScrollDuration);
|
||||
_heroFocusNode = FocusNode(debugLabel: 'hero_section');
|
||||
_refreshButtonFocusNode = FocusNode(debugLabel: 'refresh_button');
|
||||
_userButtonFocusNode = FocusNode(debugLabel: 'user_button');
|
||||
@@ -221,10 +204,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
// LEFT: Navigate hero carousel to previous
|
||||
if (key.isLeftKey) {
|
||||
if (_currentHeroIndex > 0) {
|
||||
_heroController.previousPage(
|
||||
duration: tokens(context).slow,
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
_heroController.previousPage(duration: tokens(context).slow, curve: Curves.easeInOut);
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
@@ -232,10 +212,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
// RIGHT: Navigate hero carousel to next
|
||||
if (key.isRightKey) {
|
||||
if (_currentHeroIndex < _onDeck.length - 1) {
|
||||
_heroController.nextPage(
|
||||
duration: tokens(context).slow,
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
_heroController.nextPage(duration: tokens(context).slow, curve: Curves.easeInOut);
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
@@ -339,9 +316,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
|
||||
_indicatorAnimationController.forward(from: 0.0);
|
||||
_autoScrollTimer = Timer.periodic(_heroAutoScrollDuration, (timer) {
|
||||
if (_onDeck.isEmpty ||
|
||||
!_heroController.hasClients ||
|
||||
_isAutoScrollPaused) {
|
||||
if (_onDeck.isEmpty || !_heroController.hasClients || _isAutoScrollPaused) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -351,11 +326,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
}
|
||||
|
||||
final nextPage = (_currentHeroIndex + 1) % _onDeck.length;
|
||||
_heroController.animateToPage(
|
||||
nextPage,
|
||||
duration: const Duration(milliseconds: 500),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
_heroController.animateToPage(nextPage, duration: const Duration(milliseconds: 500), curve: Curves.easeInOut);
|
||||
// Wait for page transition to complete before resetting progress
|
||||
Future.delayed(const Duration(milliseconds: 500), () {
|
||||
if (!_isAutoScrollPaused) {
|
||||
@@ -430,20 +401,15 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
|
||||
try {
|
||||
appLogger.d('Fetching onDeck and hubs from all Plex servers');
|
||||
final multiServerProvider = Provider.of<MultiServerProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
final multiServerProvider = Provider.of<MultiServerProvider>(context, listen: false);
|
||||
|
||||
if (!multiServerProvider.hasConnectedServers) {
|
||||
throw Exception('No servers available');
|
||||
}
|
||||
|
||||
// Start OnDeck and libraries fetch in parallel
|
||||
final onDeckFuture = multiServerProvider.aggregationService
|
||||
.getOnDeckFromAllServers(limit: 20);
|
||||
final librariesFuture = multiServerProvider.aggregationService
|
||||
.getLibrariesFromAllServersGrouped();
|
||||
final onDeckFuture = multiServerProvider.aggregationService.getOnDeckFromAllServers(limit: 20);
|
||||
final librariesFuture = multiServerProvider.aggregationService.getLibrariesFromAllServersGrouped();
|
||||
|
||||
// Wait for OnDeck to complete and show it immediately
|
||||
final onDeck = await onDeckFuture;
|
||||
@@ -472,17 +438,13 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
if (!mounted) return;
|
||||
|
||||
// Get hidden libraries to filter from hubs
|
||||
final hiddenLibrariesProvider = Provider.of<HiddenLibrariesProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
final hiddenLibrariesProvider = Provider.of<HiddenLibrariesProvider>(context, listen: false);
|
||||
|
||||
// Fetch hubs using the pre-fetched libraries and hidden keys
|
||||
final allHubs = await multiServerProvider.aggregationService
|
||||
.getHubsFromAllServers(
|
||||
librariesByServer: librariesByServer,
|
||||
hiddenLibraryKeys: hiddenLibrariesProvider.hiddenLibraryKeys,
|
||||
);
|
||||
final allHubs = await multiServerProvider.aggregationService.getHubsFromAllServers(
|
||||
librariesByServer: librariesByServer,
|
||||
hiddenLibraryKeys: hiddenLibrariesProvider.hiddenLibraryKeys,
|
||||
);
|
||||
|
||||
// Filter out duplicate hubs that we already fetch separately
|
||||
final filteredHubs = allHubs.where((hub) {
|
||||
@@ -495,9 +457,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
!title.contains('on deck');
|
||||
}).toList();
|
||||
|
||||
appLogger.d(
|
||||
'Received ${onDeck.length} on deck items and ${filteredHubs.length} hubs from all servers',
|
||||
);
|
||||
appLogger.d('Received ${onDeck.length} on deck items and ${filteredHubs.length} hubs from all servers');
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_hubs = filteredHubs;
|
||||
@@ -528,8 +488,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
return;
|
||||
}
|
||||
|
||||
final onDeck = await multiServerProvider.aggregationService
|
||||
.getOnDeckFromAllServers(limit: 20);
|
||||
final onDeck = await multiServerProvider.aggregationService.getOnDeckFromAllServers(limit: 20);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
@@ -585,14 +544,12 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
if (lowerTitle.contains('newly') || lowerTitle.contains('new release')) {
|
||||
return Symbols.new_releases_rounded;
|
||||
}
|
||||
if (lowerTitle.contains('recently released') ||
|
||||
lowerTitle.contains('recent')) {
|
||||
if (lowerTitle.contains('recently released') || lowerTitle.contains('recent')) {
|
||||
return Symbols.schedule_rounded;
|
||||
}
|
||||
|
||||
// Top/Rated content
|
||||
if (lowerTitle.contains('top rated') ||
|
||||
lowerTitle.contains('highest rated')) {
|
||||
if (lowerTitle.contains('top rated') || lowerTitle.contains('highest rated')) {
|
||||
return Symbols.star_rounded;
|
||||
}
|
||||
if (lowerTitle.contains('top ')) {
|
||||
@@ -650,15 +607,12 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
}
|
||||
|
||||
// Year-based (80s, 90s, etc.)
|
||||
if (lowerTitle.contains('80') ||
|
||||
lowerTitle.contains('90') ||
|
||||
lowerTitle.contains('00')) {
|
||||
if (lowerTitle.contains('80') || lowerTitle.contains('90') || lowerTitle.contains('00')) {
|
||||
return Symbols.history_rounded;
|
||||
}
|
||||
|
||||
// Rediscover/Start Watching
|
||||
if (lowerTitle.contains('rediscover') ||
|
||||
lowerTitle.contains('start watching')) {
|
||||
if (lowerTitle.contains('rediscover') || lowerTitle.contains('start watching')) {
|
||||
return Symbols.play_arrow_rounded;
|
||||
}
|
||||
|
||||
@@ -669,18 +623,14 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
@override
|
||||
void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) {
|
||||
// Check and update in _onDeck list
|
||||
final onDeckIndex = _onDeck.indexWhere(
|
||||
(item) => item.ratingKey == ratingKey,
|
||||
);
|
||||
final onDeckIndex = _onDeck.indexWhere((item) => item.ratingKey == ratingKey);
|
||||
if (onDeckIndex != -1) {
|
||||
_onDeck[onDeckIndex] = updatedMetadata;
|
||||
}
|
||||
|
||||
// Check and update in hub items
|
||||
for (final hub in _hubs) {
|
||||
final itemIndex = hub.items.indexWhere(
|
||||
(item) => item.ratingKey == ratingKey,
|
||||
);
|
||||
final itemIndex = hub.items.indexWhere((item) => item.ratingKey == ratingKey);
|
||||
if (itemIndex != -1) {
|
||||
hub.items[itemIndex] = updatedMetadata;
|
||||
}
|
||||
@@ -694,24 +644,15 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
title: Text(t.common.logout),
|
||||
content: Text(t.messages.logoutConfirm),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: Text(t.common.cancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: Text(t.common.logout),
|
||||
),
|
||||
TextButton(onPressed: () => Navigator.pop(context, false), child: Text(t.common.cancel)),
|
||||
FilledButton(onPressed: () => Navigator.pop(context, true), child: Text(t.common.logout)),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirm == true && mounted) {
|
||||
// Use comprehensive logout through UserProfileProvider
|
||||
final userProfileProvider = Provider.of<UserProfileProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
final userProfileProvider = Provider.of<UserProfileProvider>(context, listen: false);
|
||||
final plexClientProvider = context.plexClient;
|
||||
final multiServerProvider = context.read<MultiServerProvider>();
|
||||
final serverStateProvider = context.read<ServerStateProvider>();
|
||||
@@ -727,36 +668,27 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
playbackStateProvider.clearShuffle();
|
||||
|
||||
if (mounted) {
|
||||
Navigator.of(context).pushAndRemoveUntil(
|
||||
MaterialPageRoute(builder: (context) => const AuthScreen()),
|
||||
(route) => false,
|
||||
);
|
||||
Navigator.of(
|
||||
context,
|
||||
).pushAndRemoveUntil(MaterialPageRoute(builder: (context) => const AuthScreen()), (route) => false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _handleSwitchProfile(BuildContext context) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const ProfileSwitchScreen()),
|
||||
);
|
||||
Navigator.push(context, MaterialPageRoute(builder: (context) => const ProfileSwitchScreen()));
|
||||
}
|
||||
|
||||
/// Show user menu programmatically (for D-pad select)
|
||||
void _showUserMenu(BuildContext context, UserProfileProvider userProvider) {
|
||||
final RenderBox? button =
|
||||
_userButtonFocusNode.context?.findRenderObject() as RenderBox?;
|
||||
final RenderBox? button = _userButtonFocusNode.context?.findRenderObject() as RenderBox?;
|
||||
if (button == null) return;
|
||||
|
||||
final RenderBox overlay =
|
||||
Navigator.of(context).overlay!.context.findRenderObject() as RenderBox;
|
||||
final RenderBox overlay = Navigator.of(context).overlay!.context.findRenderObject() as RenderBox;
|
||||
final position = RelativeRect.fromRect(
|
||||
Rect.fromPoints(
|
||||
button.localToGlobal(Offset.zero, ancestor: overlay),
|
||||
button.localToGlobal(
|
||||
button.size.bottomRight(Offset.zero),
|
||||
ancestor: overlay,
|
||||
),
|
||||
button.localToGlobal(button.size.bottomRight(Offset.zero), ancestor: overlay),
|
||||
),
|
||||
Offset.zero & overlay.size,
|
||||
);
|
||||
@@ -769,22 +701,12 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
PopupMenuItem(
|
||||
value: 'switch_profile',
|
||||
child: Row(
|
||||
children: [
|
||||
AppIcon(Symbols.people_rounded, fill: 1),
|
||||
SizedBox(width: 8),
|
||||
Text(t.discover.switchProfile),
|
||||
],
|
||||
children: [AppIcon(Symbols.people_rounded, fill: 1), SizedBox(width: 8), Text(t.discover.switchProfile)],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'logout',
|
||||
child: Row(
|
||||
children: [
|
||||
AppIcon(Symbols.logout_rounded, fill: 1),
|
||||
SizedBox(width: 8),
|
||||
Text(t.discover.logout),
|
||||
],
|
||||
),
|
||||
child: Row(children: [AppIcon(Symbols.logout_rounded, fill: 1), SizedBox(width: 8), Text(t.discover.logout)]),
|
||||
),
|
||||
],
|
||||
).then((value) {
|
||||
@@ -819,16 +741,11 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: _isRefreshFocused
|
||||
? Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.08)
|
||||
? Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.08)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: IconButton(
|
||||
icon: const AppIcon(Symbols.refresh_rounded, fill: 1),
|
||||
onPressed: _loadContent,
|
||||
),
|
||||
child: IconButton(icon: const AppIcon(Symbols.refresh_rounded, fill: 1), onPressed: _loadContent),
|
||||
),
|
||||
),
|
||||
Consumer<UserProfileProvider>(
|
||||
@@ -839,24 +756,14 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: _isUserFocused
|
||||
? Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.08)
|
||||
? Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.08)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: PopupMenuButton<String>(
|
||||
icon: userProvider.currentUser?.thumb != null
|
||||
? UserAvatarWidget(
|
||||
user: userProvider.currentUser!,
|
||||
size: 32,
|
||||
showIndicators: false,
|
||||
)
|
||||
: const AppIcon(
|
||||
Symbols.account_circle_rounded,
|
||||
fill: 1,
|
||||
size: 32,
|
||||
),
|
||||
? UserAvatarWidget(user: userProvider.currentUser!, size: 32, showIndicators: false)
|
||||
: const AppIcon(Symbols.account_circle_rounded, fill: 1, size: 32),
|
||||
onSelected: (value) {
|
||||
if (value == 'switch_profile') {
|
||||
_handleSwitchProfile(context);
|
||||
@@ -895,10 +802,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_isLoading)
|
||||
const SliverFillRemaining(
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
if (_isLoading) const SliverFillRemaining(child: Center(child: CircularProgressIndicator())),
|
||||
if (_errorMessage != null)
|
||||
SliverFillRemaining(
|
||||
child: ErrorStateWidget(
|
||||
@@ -936,8 +840,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
onRefresh: updateItem,
|
||||
onRemoveFromContinueWatching: _refreshContinueWatching,
|
||||
isInContinueWatching: true,
|
||||
onVerticalNavigation: (isUp) =>
|
||||
_handleVerticalNavigation(0, isUp),
|
||||
onVerticalNavigation: (isUp) => _handleVerticalNavigation(0, isUp),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -950,10 +853,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
icon: _getHubIcon(_hubs[i].title),
|
||||
onRefresh: updateItem,
|
||||
// Hub index is i + 1 if continue watching exists, otherwise i
|
||||
onVerticalNavigation: (isUp) => _handleVerticalNavigation(
|
||||
_onDeck.isNotEmpty ? i + 1 : i,
|
||||
isUp,
|
||||
),
|
||||
onVerticalNavigation: (isUp) => _handleVerticalNavigation(_onDeck.isNotEmpty ? i + 1 : i, isUp),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -971,9 +871,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
width: 200,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
@@ -989,12 +887,8 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
margin: const EdgeInsets.only(right: 12),
|
||||
width: 140,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(
|
||||
tokens(context).radiusSm,
|
||||
),
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -1011,19 +905,11 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
AppIcon(
|
||||
Symbols.movie_rounded,
|
||||
fill: 1,
|
||||
size: 64,
|
||||
color: Colors.grey,
|
||||
),
|
||||
AppIcon(Symbols.movie_rounded, fill: 1, size: 64, color: Colors.grey),
|
||||
SizedBox(height: 16),
|
||||
Text(t.discover.noContentAvailable),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
t.discover.addMediaToLibraries,
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
Text(t.discover.addMediaToLibraries, style: TextStyle(color: Colors.grey)),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -1081,14 +967,11 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
}
|
||||
},
|
||||
child: AppIcon(
|
||||
_isAutoScrollPaused
|
||||
? Symbols.play_arrow_rounded
|
||||
: Symbols.pause_rounded,
|
||||
_isAutoScrollPaused ? Symbols.play_arrow_rounded : Symbols.pause_rounded,
|
||||
fill: 1,
|
||||
color: Colors.white,
|
||||
size: 18,
|
||||
semanticLabel:
|
||||
'${_isAutoScrollPaused ? t.discover.play : t.discover.pause} auto-scroll',
|
||||
semanticLabel: '${_isAutoScrollPaused ? t.discover.play : t.discover.pause} auto-scroll',
|
||||
),
|
||||
),
|
||||
// Spacer to separate indicators from button
|
||||
@@ -1099,11 +982,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
return List.generate(range.end - range.start + 1, (i) {
|
||||
final index = range.start + i;
|
||||
final isActive = _currentHeroIndex == index;
|
||||
final dotSize = _getDotSize(
|
||||
index,
|
||||
range.start,
|
||||
range.end,
|
||||
);
|
||||
final dotSize = _getDotSize(index, range.start, range.end);
|
||||
|
||||
if (isActive) {
|
||||
// Animated progress indicator for active page
|
||||
@@ -1111,26 +990,17 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
animation: _indicatorAnimationController,
|
||||
builder: (context, child) {
|
||||
// Fill width animates based on dot size
|
||||
final maxWidth =
|
||||
dotSize *
|
||||
3; // 24px for normal, 15px for small
|
||||
final fillWidth =
|
||||
dotSize +
|
||||
((maxWidth - dotSize) *
|
||||
_indicatorAnimationController.value);
|
||||
final maxWidth = dotSize * 3; // 24px for normal, 15px for small
|
||||
final fillWidth = dotSize + ((maxWidth - dotSize) * _indicatorAnimationController.value);
|
||||
return AnimatedContainer(
|
||||
duration: tokens(context).slow,
|
||||
curve: Curves.easeInOut,
|
||||
margin: const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||
width: maxWidth,
|
||||
height: dotSize,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.4),
|
||||
borderRadius: BorderRadius.circular(
|
||||
dotSize / 2,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(dotSize / 2),
|
||||
),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
@@ -1139,9 +1009,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
height: dotSize,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(
|
||||
dotSize / 2,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(dotSize / 2),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -1181,14 +1049,10 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
final isLargeScreen = ScreenBreakpoints.isWideTabletOrLarger(screenWidth);
|
||||
|
||||
// Determine content type label for chip
|
||||
final contentTypeLabel = heroItem.isMovie
|
||||
? t.discover.movie
|
||||
: t.discover.tvShow;
|
||||
final contentTypeLabel = heroItem.isMovie ? t.discover.movie : t.discover.tvShow;
|
||||
|
||||
// Build semantic label for hero item
|
||||
final heroLabel = isEpisode
|
||||
? "${heroItem.grandparentTitle}, ${heroItem.title}"
|
||||
: heroItem.title;
|
||||
final heroLabel = isEpisode ? "${heroItem.grandparentTitle}, ${heroItem.title}" : heroItem.title;
|
||||
|
||||
return Semantics(
|
||||
label: heroLabel,
|
||||
@@ -1204,11 +1068,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.3),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 20, offset: const Offset(0, 10)),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
@@ -1221,13 +1081,8 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
AnimatedBuilder(
|
||||
animation: _scrollController,
|
||||
builder: (context, child) {
|
||||
final scrollOffset = _scrollController.hasClients
|
||||
? _scrollController.offset
|
||||
: 0.0;
|
||||
return Transform.translate(
|
||||
offset: Offset(0, scrollOffset * 0.3),
|
||||
child: child,
|
||||
);
|
||||
final scrollOffset = _scrollController.hasClients ? _scrollController.offset : 0.0;
|
||||
return Transform.translate(offset: Offset(0, scrollOffset * 0.3), child: child);
|
||||
},
|
||||
child: TweenAnimationBuilder<double>(
|
||||
tween: Tween(begin: 0.0, end: 1.0),
|
||||
@@ -1255,27 +1110,17 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
return CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) => Container(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
errorWidget: (context, url, error) => Container(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
placeholder: (context, url) =>
|
||||
Container(color: Theme.of(context).colorScheme.surfaceContainerHighest),
|
||||
errorWidget: (context, url, error) =>
|
||||
Container(color: Theme.of(context).colorScheme.surfaceContainerHighest),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Container(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
Container(color: Theme.of(context).colorScheme.surfaceContainerHighest),
|
||||
|
||||
// Gradient Overlay
|
||||
Container(
|
||||
@@ -1299,13 +1144,9 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
left: 0,
|
||||
right: isLargeScreen ? 200 : 0,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: isLargeScreen ? 40 : 16,
|
||||
),
|
||||
padding: EdgeInsets.symmetric(horizontal: isLargeScreen ? 40 : 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: isLargeScreen
|
||||
? CrossAxisAlignment.start
|
||||
: CrossAxisAlignment.center,
|
||||
crossAxisAlignment: isLargeScreen ? CrossAxisAlignment.start : CrossAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Show logo or name/title
|
||||
@@ -1316,86 +1157,50 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final client = _getClientForItem(heroItem);
|
||||
final dpr = MediaQuery.of(
|
||||
context,
|
||||
).devicePixelRatio;
|
||||
final logoUrl =
|
||||
PlexImageHelper.getOptimizedImageUrl(
|
||||
client: client,
|
||||
thumbPath: heroItem.clearLogo,
|
||||
maxWidth: 400,
|
||||
maxHeight: 120,
|
||||
devicePixelRatio: dpr,
|
||||
imageType: ImageType.logo,
|
||||
);
|
||||
final dpr = MediaQuery.of(context).devicePixelRatio;
|
||||
final logoUrl = PlexImageHelper.getOptimizedImageUrl(
|
||||
client: client,
|
||||
thumbPath: heroItem.clearLogo,
|
||||
maxWidth: 400,
|
||||
maxHeight: 120,
|
||||
devicePixelRatio: dpr,
|
||||
imageType: ImageType.logo,
|
||||
);
|
||||
|
||||
return CachedNetworkImage(
|
||||
imageUrl: logoUrl,
|
||||
filterQuality: FilterQuality.medium,
|
||||
fit: BoxFit.contain,
|
||||
memCacheWidth: (400 * dpr)
|
||||
.clamp(200, 800)
|
||||
.round(),
|
||||
alignment: isLargeScreen
|
||||
? Alignment.bottomLeft
|
||||
: Alignment.bottomCenter,
|
||||
memCacheWidth: (400 * dpr).clamp(200, 800).round(),
|
||||
alignment: isLargeScreen ? Alignment.bottomLeft : Alignment.bottomCenter,
|
||||
placeholder: (context, url) => Align(
|
||||
alignment: isLargeScreen
|
||||
? Alignment.centerLeft
|
||||
: Alignment.center,
|
||||
alignment: isLargeScreen ? Alignment.centerLeft : Alignment.center,
|
||||
child: Text(
|
||||
showName,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.displaySmall
|
||||
?.copyWith(
|
||||
color: Colors.white.withValues(
|
||||
alpha: 0.3,
|
||||
),
|
||||
fontWeight: FontWeight.bold,
|
||||
shadows: [
|
||||
Shadow(
|
||||
color: Colors.black.withValues(
|
||||
alpha: 0.5,
|
||||
),
|
||||
blurRadius: 8,
|
||||
),
|
||||
],
|
||||
),
|
||||
style: Theme.of(context).textTheme.displaySmall?.copyWith(
|
||||
color: Colors.white.withValues(alpha: 0.3),
|
||||
fontWeight: FontWeight.bold,
|
||||
shadows: [Shadow(color: Colors.black.withValues(alpha: 0.5), blurRadius: 8)],
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: isLargeScreen
|
||||
? TextAlign.left
|
||||
: TextAlign.center,
|
||||
textAlign: isLargeScreen ? TextAlign.left : TextAlign.center,
|
||||
),
|
||||
),
|
||||
errorWidget: (context, url, error) {
|
||||
// Fallback to text if logo fails to load
|
||||
return Align(
|
||||
alignment: isLargeScreen
|
||||
? Alignment.centerLeft
|
||||
: Alignment.center,
|
||||
alignment: isLargeScreen ? Alignment.centerLeft : Alignment.center,
|
||||
child: Text(
|
||||
showName,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.displaySmall
|
||||
?.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
shadows: [
|
||||
Shadow(
|
||||
color: Colors.black
|
||||
.withValues(alpha: 0.5),
|
||||
blurRadius: 8,
|
||||
),
|
||||
],
|
||||
),
|
||||
style: Theme.of(context).textTheme.displaySmall?.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
shadows: [Shadow(color: Colors.black.withValues(alpha: 0.5), blurRadius: 8)],
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: isLargeScreen
|
||||
? TextAlign.left
|
||||
: TextAlign.center,
|
||||
textAlign: isLargeScreen ? TextAlign.left : TextAlign.center,
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -1406,57 +1211,33 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
else
|
||||
Text(
|
||||
showName,
|
||||
style: Theme.of(context).textTheme.displaySmall
|
||||
?.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
shadows: [
|
||||
Shadow(
|
||||
color: Colors.black.withValues(
|
||||
alpha: 0.5,
|
||||
),
|
||||
blurRadius: 8,
|
||||
),
|
||||
],
|
||||
),
|
||||
style: Theme.of(context).textTheme.displaySmall?.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
shadows: [Shadow(color: Colors.black.withValues(alpha: 0.5), blurRadius: 8)],
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: isLargeScreen
|
||||
? TextAlign.left
|
||||
: TextAlign.center,
|
||||
textAlign: isLargeScreen ? TextAlign.left : TextAlign.center,
|
||||
),
|
||||
|
||||
// Metadata as dot-separated text with content type
|
||||
if (heroItem.year != null ||
|
||||
heroItem.contentRating != null ||
|
||||
heroItem.rating != null) ...[
|
||||
if (heroItem.year != null || heroItem.contentRating != null || heroItem.rating != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
[
|
||||
contentTypeLabel,
|
||||
if (heroItem.rating != null)
|
||||
'★ ${heroItem.rating!.toStringAsFixed(1)}',
|
||||
if (heroItem.contentRating != null)
|
||||
formatContentRating(heroItem.contentRating!),
|
||||
if (heroItem.year != null)
|
||||
heroItem.year.toString(),
|
||||
if (heroItem.rating != null) '★ ${heroItem.rating!.toStringAsFixed(1)}',
|
||||
if (heroItem.contentRating != null) formatContentRating(heroItem.contentRating!),
|
||||
if (heroItem.year != null) heroItem.year.toString(),
|
||||
].join(' • '),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
textAlign: isLargeScreen
|
||||
? TextAlign.left
|
||||
: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500),
|
||||
textAlign: isLargeScreen ? TextAlign.left : TextAlign.center,
|
||||
),
|
||||
],
|
||||
|
||||
// On small screens: show button before summary
|
||||
if (!isLargeScreen) ...[
|
||||
const SizedBox(height: 20),
|
||||
_buildSmartPlayButton(heroItem),
|
||||
],
|
||||
if (!isLargeScreen) ...[const SizedBox(height: 20), _buildSmartPlayButton(heroItem)],
|
||||
|
||||
// Summary with episode info (Apple TV style)
|
||||
if (heroItem.summary != null) ...[
|
||||
@@ -1464,26 +1245,14 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
RichText(
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: isLargeScreen
|
||||
? TextAlign.left
|
||||
: TextAlign.center,
|
||||
textAlign: isLargeScreen ? TextAlign.left : TextAlign.center,
|
||||
text: TextSpan(
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 14,
|
||||
height: 1.4,
|
||||
),
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 14, height: 1.4),
|
||||
children: [
|
||||
if (isEpisode &&
|
||||
heroItem.parentIndex != null &&
|
||||
heroItem.index != null)
|
||||
if (isEpisode && heroItem.parentIndex != null && heroItem.index != null)
|
||||
TextSpan(
|
||||
text:
|
||||
'S${heroItem.parentIndex}, E${heroItem.index}: ',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
text: 'S${heroItem.parentIndex}, E${heroItem.index}: ',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.white),
|
||||
),
|
||||
TextSpan(
|
||||
text: heroItem.summary?.isNotEmpty == true
|
||||
@@ -1496,10 +1265,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
],
|
||||
|
||||
// On large screens: show button after summary
|
||||
if (isLargeScreen) ...[
|
||||
const SizedBox(height: 20),
|
||||
_buildSmartPlayButton(heroItem),
|
||||
],
|
||||
if (isLargeScreen) ...[const SizedBox(height: 20), _buildSmartPlayButton(heroItem)],
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -1514,18 +1280,11 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
|
||||
Widget _buildSmartPlayButton(PlexMetadata heroItem) {
|
||||
final hasProgress =
|
||||
heroItem.viewOffset != null &&
|
||||
heroItem.duration != null &&
|
||||
heroItem.viewOffset! > 0 &&
|
||||
heroItem.duration! > 0;
|
||||
heroItem.viewOffset != null && heroItem.duration != null && heroItem.viewOffset! > 0 && heroItem.duration! > 0;
|
||||
|
||||
final minutesLeft = hasProgress
|
||||
? ((heroItem.duration! - heroItem.viewOffset!) / 60000).round()
|
||||
: 0;
|
||||
final minutesLeft = hasProgress ? ((heroItem.duration! - heroItem.viewOffset!) / 60000).round() : 0;
|
||||
|
||||
final progress = hasProgress
|
||||
? heroItem.viewOffset! / heroItem.duration!
|
||||
: 0.0;
|
||||
final progress = hasProgress ? heroItem.viewOffset! / heroItem.duration! : 0.0;
|
||||
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
@@ -1535,57 +1294,35 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(24)),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const AppIcon(
|
||||
Symbols.play_arrow_rounded,
|
||||
fill: 1,
|
||||
size: 20,
|
||||
color: Colors.black,
|
||||
),
|
||||
const AppIcon(Symbols.play_arrow_rounded, fill: 1, size: 20, color: Colors.black),
|
||||
const SizedBox(width: 8),
|
||||
if (hasProgress) ...[
|
||||
// Progress bar
|
||||
Container(
|
||||
width: 40,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black26,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
decoration: BoxDecoration(color: Colors.black26, borderRadius: BorderRadius.circular(3)),
|
||||
child: FractionallySizedBox(
|
||||
alignment: Alignment.centerLeft,
|
||||
widthFactor: progress,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
decoration: BoxDecoration(color: Colors.black, borderRadius: BorderRadius.circular(2)),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
t.discover.minutesLeft(minutes: minutesLeft),
|
||||
style: const TextStyle(
|
||||
color: Colors.black,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
style: const TextStyle(color: Colors.black, fontSize: 14, fontWeight: FontWeight.w600),
|
||||
),
|
||||
] else
|
||||
Text(
|
||||
t.discover.play,
|
||||
style: TextStyle(
|
||||
color: Colors.black,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
style: TextStyle(color: Colors.black, fontSize: 14, fontWeight: FontWeight.w600),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -23,8 +23,7 @@ class DownloadsScreen extends StatefulWidget {
|
||||
State<DownloadsScreen> createState() => DownloadsScreenState();
|
||||
}
|
||||
|
||||
class DownloadsScreenState extends State<DownloadsScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
class DownloadsScreenState extends State<DownloadsScreen> with SingleTickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
|
||||
// Focus nodes for tab chips
|
||||
@@ -212,10 +211,7 @@ class DownloadsScreenState extends State<DownloadsScreen>
|
||||
// Tab selector chips (only on mobile - desktop has them in app bar)
|
||||
if (!PlatformDetector.shouldUseSideNavigation(context))
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
alignment: Alignment.centerLeft,
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
@@ -240,9 +236,7 @@ class DownloadsScreenState extends State<DownloadsScreen>
|
||||
// Helper to get client from globalKey (serverId:ratingKey)
|
||||
getClient(String globalKey) {
|
||||
final serverId = globalKey.split(':').first;
|
||||
return serverProvider.serverManager.getClient(
|
||||
serverId,
|
||||
);
|
||||
return serverProvider.serverManager.getClient(serverId);
|
||||
}
|
||||
|
||||
return DownloadTreeView(
|
||||
@@ -252,19 +246,13 @@ class DownloadsScreenState extends State<DownloadsScreen>
|
||||
onResume: (globalKey) {
|
||||
final client = getClient(globalKey);
|
||||
if (client != null) {
|
||||
downloadProvider.resumeDownload(
|
||||
globalKey,
|
||||
client,
|
||||
);
|
||||
downloadProvider.resumeDownload(globalKey, client);
|
||||
}
|
||||
},
|
||||
onRetry: (globalKey) {
|
||||
final client = getClient(globalKey);
|
||||
if (client != null) {
|
||||
downloadProvider.retryDownload(
|
||||
globalKey,
|
||||
client,
|
||||
);
|
||||
downloadProvider.retryDownload(globalKey, client);
|
||||
}
|
||||
},
|
||||
onCancel: downloadProvider.cancelDownload,
|
||||
@@ -302,11 +290,7 @@ class _DownloadsGridContent extends StatelessWidget {
|
||||
final bool suppressAutoFocus;
|
||||
final VoidCallback? onBack;
|
||||
|
||||
const _DownloadsGridContent({
|
||||
required this.type,
|
||||
required this.suppressAutoFocus,
|
||||
this.onBack,
|
||||
});
|
||||
const _DownloadsGridContent({required this.type, required this.suppressAutoFocus, this.onBack});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -322,10 +306,7 @@ class _DownloadsGridContent extends StatelessWidget {
|
||||
|
||||
return GridView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
gridDelegate: MediaGridDelegate.createDelegate(
|
||||
context: context,
|
||||
density: settingsProvider.libraryDensity,
|
||||
),
|
||||
gridDelegate: MediaGridDelegate.createDelegate(context: context, density: settingsProvider.libraryDensity),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
|
||||
@@ -103,29 +103,10 @@ class _HubDetailScreenState extends State<HubDetailScreen> with Refreshable {
|
||||
|
||||
List<PlexSort> _getDefaultSortOptions() {
|
||||
return [
|
||||
PlexSort(
|
||||
key: 'titleSort',
|
||||
title: t.hubDetail.title,
|
||||
defaultDirection: 'asc',
|
||||
),
|
||||
PlexSort(
|
||||
key: 'year',
|
||||
descKey: 'year:desc',
|
||||
title: t.hubDetail.releaseYear,
|
||||
defaultDirection: 'desc',
|
||||
),
|
||||
PlexSort(
|
||||
key: 'addedAt',
|
||||
descKey: 'addedAt:desc',
|
||||
title: t.hubDetail.dateAdded,
|
||||
defaultDirection: 'desc',
|
||||
),
|
||||
PlexSort(
|
||||
key: 'rating',
|
||||
descKey: 'rating:desc',
|
||||
title: t.hubDetail.rating,
|
||||
defaultDirection: 'desc',
|
||||
),
|
||||
PlexSort(key: 'titleSort', title: t.hubDetail.title, defaultDirection: 'asc'),
|
||||
PlexSort(key: 'year', descKey: 'year:desc', title: t.hubDetail.releaseYear, defaultDirection: 'desc'),
|
||||
PlexSort(key: 'addedAt', descKey: 'addedAt:desc', title: t.hubDetail.dateAdded, defaultDirection: 'desc'),
|
||||
PlexSort(key: 'rating', descKey: 'rating:desc', title: t.hubDetail.rating, defaultDirection: 'desc'),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -246,11 +227,7 @@ class _HubDetailScreenState extends State<HubDetailScreen> with Refreshable {
|
||||
title: Text(widget.hub.title),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: AppIcon(
|
||||
Symbols.swap_vert_rounded,
|
||||
fill: 1,
|
||||
semanticLabel: t.libraries.sort,
|
||||
),
|
||||
icon: AppIcon(Symbols.swap_vert_rounded, fill: 1, semanticLabel: t.libraries.sort),
|
||||
onPressed: _showSortBottomSheet,
|
||||
),
|
||||
],
|
||||
@@ -264,13 +241,9 @@ class _HubDetailScreenState extends State<HubDetailScreen> with Refreshable {
|
||||
),
|
||||
)
|
||||
else if (_filteredItems.isEmpty && _isLoading)
|
||||
const SliverFillRemaining(
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
const SliverFillRemaining(child: Center(child: CircularProgressIndicator()))
|
||||
else if (_filteredItems.isEmpty)
|
||||
SliverFillRemaining(
|
||||
child: Center(child: Text(t.hubDetail.noItemsFound)),
|
||||
)
|
||||
SliverFillRemaining(child: Center(child: Text(t.hubDetail.noItemsFound)))
|
||||
else
|
||||
MediaGridSliver(
|
||||
items: _filteredItems,
|
||||
|
||||
@@ -47,47 +47,33 @@ class AdaptiveMediaGrid<T> extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<SettingsProvider>(
|
||||
builder: (context, settingsProvider, child) {
|
||||
return _buildItemsView(
|
||||
context,
|
||||
settingsProvider.viewMode,
|
||||
settingsProvider.libraryDensity,
|
||||
);
|
||||
return _buildItemsView(context, settingsProvider.viewMode, settingsProvider.libraryDensity);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds either a list or grid view based on the view mode
|
||||
Widget _buildItemsView(
|
||||
BuildContext context,
|
||||
ViewMode viewMode,
|
||||
LibraryDensity density,
|
||||
) {
|
||||
Widget _buildItemsView(BuildContext context, ViewMode viewMode, LibraryDensity density) {
|
||||
final effectivePadding = padding ?? GridLayoutConstants.gridPadding;
|
||||
final effectiveAspectRatio =
|
||||
childAspectRatio ?? GridLayoutConstants.posterAspectRatio;
|
||||
final effectiveAspectRatio = childAspectRatio ?? GridLayoutConstants.posterAspectRatio;
|
||||
|
||||
if (viewMode == ViewMode.list) {
|
||||
return ListView.builder(
|
||||
padding: effectivePadding,
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) =>
|
||||
itemBuilder(context, items[index], index),
|
||||
itemBuilder: (context, index) => itemBuilder(context, items[index], index),
|
||||
);
|
||||
} else {
|
||||
return GridView.builder(
|
||||
padding: effectivePadding,
|
||||
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent(
|
||||
context,
|
||||
density,
|
||||
),
|
||||
maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent(context, density),
|
||||
childAspectRatio: effectiveAspectRatio,
|
||||
crossAxisSpacing: GridLayoutConstants.crossAxisSpacing,
|
||||
mainAxisSpacing: GridLayoutConstants.mainAxisSpacing,
|
||||
),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) =>
|
||||
itemBuilder(context, items[index], index),
|
||||
itemBuilder: (context, index) => itemBuilder(context, items[index], index),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,12 +52,8 @@ class _FiltersBottomSheetState extends State<FiltersBottomSheet> {
|
||||
|
||||
void _sortFilters() {
|
||||
// Separate boolean filters (toggles) from regular filters
|
||||
final booleanFilters = widget.filters
|
||||
.where((f) => f.filterType == 'boolean')
|
||||
.toList();
|
||||
final regularFilters = widget.filters
|
||||
.where((f) => f.filterType != 'boolean')
|
||||
.toList();
|
||||
final booleanFilters = widget.filters.where((f) => f.filterType == 'boolean').toList();
|
||||
final regularFilters = widget.filters.where((f) => f.filterType != 'boolean').toList();
|
||||
|
||||
// Combine with boolean filters first
|
||||
_sortedFilters = [...booleanFilters, ...regularFilters];
|
||||
@@ -130,17 +126,12 @@ class _FiltersBottomSheetState extends State<FiltersBottomSheet> {
|
||||
// Header with back button
|
||||
BottomSheetHeader(
|
||||
title: _currentFilter!.title,
|
||||
leading: AppBarBackButton(
|
||||
style: BackButtonStyle.plain,
|
||||
onPressed: _goBack,
|
||||
),
|
||||
leading: AppBarBackButton(style: BackButtonStyle.plain, onPressed: _goBack),
|
||||
),
|
||||
|
||||
// Filter options list
|
||||
if (_isLoadingValues)
|
||||
const Expanded(
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
const Expanded(child: Center(child: CircularProgressIndicator()))
|
||||
else
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
@@ -149,18 +140,14 @@ class _FiltersBottomSheetState extends State<FiltersBottomSheet> {
|
||||
itemCount: _filterValues.length + 1,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == 0) {
|
||||
final isSelected = !_tempSelectedFilters.containsKey(
|
||||
_currentFilter!.filter,
|
||||
);
|
||||
final isSelected = !_tempSelectedFilters.containsKey(_currentFilter!.filter);
|
||||
return FocusableListTile(
|
||||
focusNode: _initialFocusNode,
|
||||
title: Text(t.libraries.all),
|
||||
selected: isSelected,
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_tempSelectedFilters.remove(
|
||||
_currentFilter!.filter,
|
||||
);
|
||||
_tempSelectedFilters.remove(_currentFilter!.filter);
|
||||
});
|
||||
_applyFilters();
|
||||
},
|
||||
@@ -168,24 +155,17 @@ class _FiltersBottomSheetState extends State<FiltersBottomSheet> {
|
||||
}
|
||||
|
||||
final value = _filterValues[index - 1];
|
||||
final filterValue = _extractFilterValue(
|
||||
value.key,
|
||||
_currentFilter!.filter,
|
||||
);
|
||||
final isSelected =
|
||||
_tempSelectedFilters[_currentFilter!.filter] ==
|
||||
filterValue;
|
||||
final filterValue = _extractFilterValue(value.key, _currentFilter!.filter);
|
||||
final isSelected = _tempSelectedFilters[_currentFilter!.filter] == filterValue;
|
||||
|
||||
return FocusableListTile(
|
||||
title: Text(value.title),
|
||||
selected: isSelected,
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_tempSelectedFilters[_currentFilter!.filter] =
|
||||
filterValue;
|
||||
_tempSelectedFilters[_currentFilter!.filter] = filterValue;
|
||||
// Cache the display name for this filter value
|
||||
_filterDisplayNames['${_currentFilter!.filter}:$filterValue'] =
|
||||
value.title;
|
||||
_filterDisplayNames['${_currentFilter!.filter}:$filterValue'] = value.title;
|
||||
});
|
||||
_applyFilters();
|
||||
},
|
||||
@@ -230,8 +210,7 @@ class _FiltersBottomSheetState extends State<FiltersBottomSheet> {
|
||||
// Handle boolean filters as switches (unwatched, inProgress, unmatched, hdr, etc.)
|
||||
if (_isBooleanFilter(filter)) {
|
||||
final isActive =
|
||||
_tempSelectedFilters.containsKey(filter.filter) &&
|
||||
_tempSelectedFilters[filter.filter] == '1';
|
||||
_tempSelectedFilters.containsKey(filter.filter) && _tempSelectedFilters[filter.filter] == '1';
|
||||
return FocusableSwitchListTile(
|
||||
focusNode: index == 0 ? _initialFocusNode : null,
|
||||
value: isActive,
|
||||
@@ -254,9 +233,7 @@ class _FiltersBottomSheetState extends State<FiltersBottomSheet> {
|
||||
String? displayValue;
|
||||
if (selectedValue != null) {
|
||||
// Try to get the cached display name, fall back to the value itself
|
||||
displayValue =
|
||||
_filterDisplayNames['${filter.filter}:$selectedValue'] ??
|
||||
selectedValue;
|
||||
displayValue = _filterDisplayNames['${filter.filter}:$selectedValue'] ?? selectedValue;
|
||||
}
|
||||
|
||||
return FocusableListTile(
|
||||
|
||||
@@ -63,12 +63,7 @@ class FolderTreeItem extends StatelessWidget {
|
||||
return InkWell(
|
||||
onTap: _handleTap,
|
||||
child: Container(
|
||||
padding: EdgeInsets.only(
|
||||
left: 16.0 + indentation,
|
||||
right: 16.0,
|
||||
top: 12.0,
|
||||
bottom: 12.0,
|
||||
),
|
||||
padding: EdgeInsets.only(left: 16.0 + indentation, right: 16.0, top: 12.0, bottom: 12.0),
|
||||
child: Row(
|
||||
children: [
|
||||
// Expand/collapse icon for folders
|
||||
@@ -76,15 +71,9 @@ class FolderTreeItem extends StatelessWidget {
|
||||
SizedBox(
|
||||
width: 24,
|
||||
child: isLoading
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: AppIcon(
|
||||
isExpanded
|
||||
? Symbols.keyboard_arrow_down_rounded
|
||||
: Symbols.keyboard_arrow_right_rounded,
|
||||
isExpanded ? Symbols.keyboard_arrow_down_rounded : Symbols.keyboard_arrow_right_rounded,
|
||||
fill: 1,
|
||||
size: 20,
|
||||
),
|
||||
@@ -101,9 +90,7 @@ class FolderTreeItem extends StatelessWidget {
|
||||
size: 20,
|
||||
color: isFolder
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.7),
|
||||
: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7),
|
||||
),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
@@ -112,10 +99,7 @@ class FolderTreeItem extends StatelessWidget {
|
||||
Expanded(
|
||||
child: Text(
|
||||
item.title,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: isFolder ? FontWeight.w500 : FontWeight.w400,
|
||||
),
|
||||
style: TextStyle(fontSize: 14, fontWeight: isFolder ? FontWeight.w500 : FontWeight.w400),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
@@ -125,12 +109,7 @@ class FolderTreeItem extends StatelessWidget {
|
||||
if (!isFolder && item.year != null)
|
||||
Text(
|
||||
item.year.toString(),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
style: TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6)),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -16,12 +16,7 @@ class FolderTreeView extends StatefulWidget {
|
||||
final String? serverId; // Server this library belongs to
|
||||
final void Function(String)? onRefresh;
|
||||
|
||||
const FolderTreeView({
|
||||
super.key,
|
||||
required this.libraryKey,
|
||||
this.serverId,
|
||||
this.onRefresh,
|
||||
});
|
||||
const FolderTreeView({super.key, required this.libraryKey, this.serverId, this.onRefresh});
|
||||
|
||||
@override
|
||||
State<FolderTreeView> createState() => _FolderTreeViewState();
|
||||
@@ -74,10 +69,7 @@ class _FolderTreeViewState extends State<FolderTreeView> {
|
||||
|
||||
appLogger.e('Failed to load root folders', error: e);
|
||||
setState(() {
|
||||
_errorMessage = t.errors.failedToLoad(
|
||||
context: t.libraries.folders,
|
||||
error: e.toString(),
|
||||
);
|
||||
_errorMessage = t.errors.failedToLoad(context: t.libraries.folders, error: e.toString());
|
||||
_isLoadingRoot = false;
|
||||
});
|
||||
}
|
||||
@@ -113,9 +105,7 @@ class _FolderTreeViewState extends State<FolderTreeView> {
|
||||
_loadingFolders.remove(folder.key);
|
||||
});
|
||||
|
||||
appLogger.d(
|
||||
'Loaded ${children.length} children for folder: ${folder.title}',
|
||||
);
|
||||
appLogger.d('Loaded ${children.length} children for folder: ${folder.title}');
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
|
||||
@@ -125,13 +115,7 @@ class _FolderTreeViewState extends State<FolderTreeView> {
|
||||
});
|
||||
|
||||
if (mounted) {
|
||||
showErrorSnackBar(
|
||||
context,
|
||||
t.errors.failedToLoad(
|
||||
context: t.libraries.folders,
|
||||
error: e.toString(),
|
||||
),
|
||||
);
|
||||
showErrorSnackBar(context, t.errors.failedToLoad(context: t.libraries.folders, error: e.toString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -153,16 +137,10 @@ class _FolderTreeViewState extends State<FolderTreeView> {
|
||||
bool _isFolder(PlexMetadata item) {
|
||||
// Folders typically don't have a specific type or might have special indicators
|
||||
// Check for common folder indicators
|
||||
return item.key.contains('/folder') ||
|
||||
item.type.isEmpty ||
|
||||
item.type.toLowerCase() == 'folder';
|
||||
return item.key.contains('/folder') || item.type.isEmpty || item.type.toLowerCase() == 'folder';
|
||||
}
|
||||
|
||||
List<Widget> _buildTreeItems(
|
||||
List<PlexMetadata> items,
|
||||
int depth, [
|
||||
String parentPath = '',
|
||||
]) {
|
||||
List<Widget> _buildTreeItems(List<PlexMetadata> items, int depth, [String parentPath = '']) {
|
||||
final List<Widget> widgets = [];
|
||||
|
||||
for (int i = 0; i < items.length; i++) {
|
||||
@@ -214,10 +192,7 @@ class _FolderTreeViewState extends State<FolderTreeView> {
|
||||
}
|
||||
|
||||
if (_rootFolders.isEmpty) {
|
||||
return EmptyStateWidget(
|
||||
message: t.libraries.noFoldersFound,
|
||||
icon: Symbols.folder_open_rounded,
|
||||
);
|
||||
return EmptyStateWidget(message: t.libraries.noFoldersFound, icon: Symbols.folder_open_rounded);
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
|
||||
@@ -66,16 +66,11 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
with Refreshable, FullRefreshable, FocusableTab, LibraryLoadable, ItemUpdatable, SingleTickerProviderStateMixin {
|
||||
@override
|
||||
PlexClient get client {
|
||||
final multiServerProvider = Provider.of<MultiServerProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
final multiServerProvider = Provider.of<MultiServerProvider>(context, listen: false);
|
||||
if (!multiServerProvider.hasConnectedServers) {
|
||||
throw Exception(t.errors.noClientAvailable);
|
||||
}
|
||||
return context.getClientForServer(
|
||||
multiServerProvider.onlineServerIds.first,
|
||||
);
|
||||
return context.getClientForServer(multiServerProvider.onlineServerIds.first);
|
||||
}
|
||||
|
||||
late TabController _tabController;
|
||||
@@ -115,16 +110,10 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
final _libraryDropdownKey = GlobalKey<PopupMenuButtonState<String>>();
|
||||
|
||||
// Focus nodes for tab chips
|
||||
final _recommendedTabChipFocusNode = FocusNode(
|
||||
debugLabel: 'tab_chip_recommended',
|
||||
);
|
||||
final _recommendedTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_recommended');
|
||||
final _browseTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_browse');
|
||||
final _collectionsTabChipFocusNode = FocusNode(
|
||||
debugLabel: 'tab_chip_collections',
|
||||
);
|
||||
final _playlistsTabChipFocusNode = FocusNode(
|
||||
debugLabel: 'tab_chip_playlists',
|
||||
);
|
||||
final _collectionsTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_collections');
|
||||
final _playlistsTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_playlists');
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -164,10 +153,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
// Only save if this was a user-initiated tab change, not a restore
|
||||
if (!_isRestoringTab) {
|
||||
StorageService.getInstance().then((storage) {
|
||||
storage.saveLibraryTab(
|
||||
_selectedLibraryGlobalKey!,
|
||||
_tabController.index,
|
||||
);
|
||||
storage.saveLibraryTab(_selectedLibraryGlobalKey!, _tabController.index);
|
||||
});
|
||||
|
||||
// Focus first item in the current tab (only for user-initiated changes)
|
||||
@@ -254,9 +240,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
if (_tabController.index == tabIndex && mounted) {
|
||||
// Use post-frame callback to ensure the widget tree is fully built
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted &&
|
||||
_tabController.index == tabIndex &&
|
||||
!_suppressAutoFocus) {
|
||||
if (mounted && _tabController.index == tabIndex && !_suppressAutoFocus) {
|
||||
_focusCurrentTab();
|
||||
}
|
||||
});
|
||||
@@ -336,23 +320,14 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
|
||||
/// Check if libraries come from multiple servers
|
||||
bool get _hasMultipleServers {
|
||||
final uniqueServerIds = _allLibraries
|
||||
.where((lib) => lib.serverId != null)
|
||||
.map((lib) => lib.serverId)
|
||||
.toSet();
|
||||
final uniqueServerIds = _allLibraries.where((lib) => lib.serverId != null).map((lib) => lib.serverId).toSet();
|
||||
return uniqueServerIds.length > 1;
|
||||
}
|
||||
|
||||
Future<void> _loadLibraries() async {
|
||||
// Extract context dependencies before async gap
|
||||
final multiServerProvider = Provider.of<MultiServerProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
final hiddenLibrariesProvider = Provider.of<HiddenLibrariesProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
final multiServerProvider = Provider.of<MultiServerProvider>(context, listen: false);
|
||||
final hiddenLibrariesProvider = Provider.of<HiddenLibrariesProvider>(context, listen: false);
|
||||
|
||||
setState(() {
|
||||
_isLoadingLibraries = true;
|
||||
@@ -368,34 +343,25 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
final storage = await StorageService.getInstance();
|
||||
|
||||
// Fetch libraries from all servers
|
||||
final allLibraries = await multiServerProvider.aggregationService
|
||||
.getLibrariesFromAllServers();
|
||||
final allLibraries = await multiServerProvider.aggregationService.getLibrariesFromAllServers();
|
||||
|
||||
// Filter out music libraries (type: 'artist') since music playback is not yet supported
|
||||
// Only show movie and TV show libraries
|
||||
final filteredLibraries = allLibraries
|
||||
.where((lib) => !ContentTypeHelper.isMusicLibrary(lib))
|
||||
.toList();
|
||||
final filteredLibraries = allLibraries.where((lib) => !ContentTypeHelper.isMusicLibrary(lib)).toList();
|
||||
|
||||
// Load saved library order and apply it
|
||||
final savedOrder = storage.getLibraryOrder();
|
||||
final orderedLibraries = _applyLibraryOrder(
|
||||
filteredLibraries,
|
||||
savedOrder,
|
||||
);
|
||||
final orderedLibraries = _applyLibraryOrder(filteredLibraries, savedOrder);
|
||||
|
||||
_updateState(() {
|
||||
_allLibraries =
|
||||
orderedLibraries; // Store all libraries with ordering applied
|
||||
_allLibraries = orderedLibraries; // Store all libraries with ordering applied
|
||||
_isLoadingLibraries = false;
|
||||
});
|
||||
|
||||
if (allLibraries.isNotEmpty) {
|
||||
// Compute visible libraries for initial load
|
||||
final hiddenKeys = hiddenLibrariesProvider.hiddenLibraryKeys;
|
||||
final visibleLibraries = allLibraries
|
||||
.where((lib) => !hiddenKeys.contains(lib.globalKey))
|
||||
.toList();
|
||||
final visibleLibraries = allLibraries.where((lib) => !hiddenKeys.contains(lib.globalKey)).toList();
|
||||
|
||||
// Load saved preferences
|
||||
final savedLibraryKey = storage.getSelectedLibraryKey();
|
||||
@@ -404,9 +370,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
String? libraryGlobalKeyToLoad;
|
||||
if (savedLibraryKey != null) {
|
||||
// Check if saved library exists and is visible
|
||||
final libraryExists = visibleLibraries.any(
|
||||
(lib) => lib.globalKey == savedLibraryKey,
|
||||
);
|
||||
final libraryExists = visibleLibraries.any((lib) => lib.globalKey == savedLibraryKey);
|
||||
if (libraryExists) {
|
||||
libraryGlobalKeyToLoad = savedLibraryKey;
|
||||
}
|
||||
@@ -418,9 +382,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
}
|
||||
|
||||
if (libraryGlobalKeyToLoad != null && mounted) {
|
||||
final savedFilters = storage.getLibraryFilters(
|
||||
sectionId: libraryGlobalKeyToLoad,
|
||||
);
|
||||
final savedFilters = storage.getLibraryFilters(sectionId: libraryGlobalKeyToLoad);
|
||||
if (savedFilters.isNotEmpty) {
|
||||
_selectedFilters = Map.from(savedFilters);
|
||||
}
|
||||
@@ -435,10 +397,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
}
|
||||
}
|
||||
|
||||
List<PlexLibrary> _applyLibraryOrder(
|
||||
List<PlexLibrary> libraries,
|
||||
List<String>? savedOrder,
|
||||
) {
|
||||
List<PlexLibrary> _applyLibraryOrder(List<PlexLibrary> libraries, List<String>? savedOrder) {
|
||||
if (savedOrder == null || savedOrder.isEmpty) {
|
||||
return libraries;
|
||||
}
|
||||
@@ -483,25 +442,17 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
|
||||
Future<void> _loadLibraryContent(String libraryGlobalKey) async {
|
||||
// Compute visible libraries based on current provider state
|
||||
final hiddenLibrariesProvider = Provider.of<HiddenLibrariesProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
final hiddenLibrariesProvider = Provider.of<HiddenLibrariesProvider>(context, listen: false);
|
||||
final hiddenKeys = hiddenLibrariesProvider.hiddenLibraryKeys;
|
||||
final visibleLibraries = _allLibraries
|
||||
.where((lib) => !hiddenKeys.contains(lib.globalKey))
|
||||
.toList();
|
||||
final visibleLibraries = _allLibraries.where((lib) => !hiddenKeys.contains(lib.globalKey)).toList();
|
||||
|
||||
// Find the library by key
|
||||
final libraryIndex = visibleLibraries.indexWhere(
|
||||
(lib) => lib.globalKey == libraryGlobalKey,
|
||||
);
|
||||
final libraryIndex = visibleLibraries.indexWhere((lib) => lib.globalKey == libraryGlobalKey);
|
||||
if (libraryIndex == -1) return; // Library not found or hidden
|
||||
|
||||
final library = visibleLibraries[libraryIndex];
|
||||
|
||||
final isChangingLibrary =
|
||||
!_isInitialLoad && _selectedLibraryGlobalKey != libraryGlobalKey;
|
||||
final isChangingLibrary = !_isInitialLoad && _selectedLibraryGlobalKey != libraryGlobalKey;
|
||||
|
||||
// Get the correct client for this library's server
|
||||
final client = context.getClientForLibrary(library);
|
||||
@@ -544,9 +495,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
// However, on first load the tab might finish loading before the tab index
|
||||
// is restored. Check if the current tab has already loaded and focus if so.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted &&
|
||||
_selectedLibraryGlobalKey == libraryGlobalKey &&
|
||||
_loadedTabs.contains(_tabController.index)) {
|
||||
if (mounted && _selectedLibraryGlobalKey == libraryGlobalKey && _loadedTabs.contains(_tabController.index)) {
|
||||
_focusCurrentTab();
|
||||
}
|
||||
});
|
||||
@@ -575,12 +524,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
final filtersWithSort = _buildFiltersWithSort();
|
||||
|
||||
// Load pages sequentially
|
||||
await _loadAllPagesSequentially(
|
||||
library,
|
||||
filtersWithSort,
|
||||
currentRequestId,
|
||||
client,
|
||||
);
|
||||
await _loadAllPagesSequentially(library, filtersWithSort, currentRequestId, client);
|
||||
} catch (e) {
|
||||
// Ignore cancellation errors
|
||||
if (e is DioException && e.type == DioExceptionType.cancel) {
|
||||
@@ -612,12 +556,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
|
||||
// Tag items with server info for multi-server support
|
||||
final taggedItems = items
|
||||
.map(
|
||||
(item) => item.copyWith(
|
||||
serverId: library.serverId,
|
||||
serverName: library.serverName,
|
||||
),
|
||||
)
|
||||
.map((item) => item.copyWith(serverId: library.serverId, serverName: library.serverName))
|
||||
.toList();
|
||||
|
||||
// Check if request is still valid
|
||||
@@ -662,10 +601,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
if (savedSortData != null) {
|
||||
final sortKey = savedSortData['key'] as String?;
|
||||
if (sortKey != null) {
|
||||
savedSort = sortOptions.firstWhere(
|
||||
(s) => s.key == sortKey,
|
||||
orElse: () => sortOptions.first,
|
||||
);
|
||||
savedSort = sortOptions.firstWhere((s) => s.key == sortKey, orElse: () => sortOptions.first);
|
||||
descending = (savedSortData['descending'] as bool?) ?? false;
|
||||
} else {
|
||||
savedSort = sortOptions.first;
|
||||
@@ -689,9 +625,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
Map<String, String> _buildFiltersWithSort() {
|
||||
final filtersWithSort = Map<String, String>.from(_selectedFilters);
|
||||
if (_selectedSort != null) {
|
||||
filtersWithSort['sort'] = _selectedSort!.getSortKey(
|
||||
descending: _isSortDescending,
|
||||
);
|
||||
filtersWithSort['sort'] = _selectedSort!.getSortKey(descending: _isSortDescending);
|
||||
}
|
||||
return filtersWithSort;
|
||||
}
|
||||
@@ -752,20 +686,14 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
}
|
||||
|
||||
Future<void> _toggleLibraryVisibility(PlexLibrary library) async {
|
||||
final hiddenLibrariesProvider = Provider.of<HiddenLibrariesProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
final isHidden = hiddenLibrariesProvider.hiddenLibraryKeys.contains(
|
||||
library.globalKey,
|
||||
);
|
||||
final hiddenLibrariesProvider = Provider.of<HiddenLibrariesProvider>(context, listen: false);
|
||||
final isHidden = hiddenLibrariesProvider.hiddenLibraryKeys.contains(library.globalKey);
|
||||
|
||||
if (isHidden) {
|
||||
await hiddenLibrariesProvider.unhideLibrary(library.globalKey);
|
||||
} else {
|
||||
// Check if we're hiding the currently selected library
|
||||
final isCurrentlySelected =
|
||||
_selectedLibraryGlobalKey == library.globalKey;
|
||||
final isCurrentlySelected = _selectedLibraryGlobalKey == library.globalKey;
|
||||
|
||||
await hiddenLibrariesProvider.hideLibrary(library.globalKey);
|
||||
|
||||
@@ -773,11 +701,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
if (isCurrentlySelected) {
|
||||
// Compute visible libraries after hiding
|
||||
final visibleLibraries = _allLibraries
|
||||
.where(
|
||||
(lib) => !hiddenLibrariesProvider.hiddenLibraryKeys.contains(
|
||||
lib.globalKey,
|
||||
),
|
||||
)
|
||||
.where((lib) => !hiddenLibrariesProvider.hiddenLibraryKeys.contains(lib.globalKey))
|
||||
.toList();
|
||||
|
||||
if (visibleLibraries.isNotEmpty) {
|
||||
@@ -795,9 +719,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
label: t.libraries.scanLibraryFiles,
|
||||
requiresConfirmation: true,
|
||||
confirmationTitle: t.libraries.scanLibrary,
|
||||
confirmationMessage: t.libraries.scanLibraryConfirm(
|
||||
title: library.title,
|
||||
),
|
||||
confirmationMessage: t.libraries.scanLibraryConfirm(title: library.title),
|
||||
),
|
||||
ContextMenuItem(
|
||||
value: 'analyze',
|
||||
@@ -805,9 +727,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
label: t.libraries.analyze,
|
||||
requiresConfirmation: true,
|
||||
confirmationTitle: t.libraries.analyzeLibrary,
|
||||
confirmationMessage: t.libraries.analyzeLibraryConfirm(
|
||||
title: library.title,
|
||||
),
|
||||
confirmationMessage: t.libraries.analyzeLibraryConfirm(title: library.title),
|
||||
),
|
||||
ContextMenuItem(
|
||||
value: 'refresh',
|
||||
@@ -815,9 +735,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
label: t.libraries.refreshMetadata,
|
||||
requiresConfirmation: true,
|
||||
confirmationTitle: t.libraries.refreshMetadata,
|
||||
confirmationMessage: t.libraries.refreshMetadataConfirm(
|
||||
title: library.title,
|
||||
),
|
||||
confirmationMessage: t.libraries.refreshMetadataConfirm(title: library.title),
|
||||
isDestructive: true,
|
||||
),
|
||||
ContextMenuItem(
|
||||
@@ -826,9 +744,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
label: t.libraries.emptyTrash,
|
||||
requiresConfirmation: true,
|
||||
confirmationTitle: t.libraries.emptyTrash,
|
||||
confirmationMessage: t.libraries.emptyTrashConfirm(
|
||||
title: library.title,
|
||||
),
|
||||
confirmationMessage: t.libraries.emptyTrashConfirm(title: library.title),
|
||||
isDestructive: true,
|
||||
),
|
||||
];
|
||||
@@ -852,10 +768,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
}
|
||||
|
||||
void _showLibraryManagementSheet() {
|
||||
final hiddenLibrariesProvider = Provider.of<HiddenLibrariesProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
final hiddenLibrariesProvider = Provider.of<HiddenLibrariesProvider>(context, listen: false);
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
@@ -887,11 +800,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
final client = context.getClientForLibrary(library);
|
||||
|
||||
if (mounted) {
|
||||
showAppSnackBar(
|
||||
context,
|
||||
progressMessage,
|
||||
duration: const Duration(seconds: 2),
|
||||
);
|
||||
showAppSnackBar(context, progressMessage, duration: const Duration(seconds: 2));
|
||||
}
|
||||
|
||||
await action(client);
|
||||
@@ -913,8 +822,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
action: (client) => client.scanLibrary(library.key),
|
||||
progressMessage: t.messages.libraryScanning(title: library.title),
|
||||
successMessage: t.messages.libraryScanStarted(title: library.title),
|
||||
failureMessage: (error) =>
|
||||
t.messages.libraryScanFailed(error: error.toString()),
|
||||
failureMessage: (error) => t.messages.libraryScanFailed(error: error.toString()),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -924,8 +832,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
action: (client) => client.refreshLibraryMetadata(library.key),
|
||||
progressMessage: t.messages.metadataRefreshing(title: library.title),
|
||||
successMessage: t.messages.metadataRefreshStarted(title: library.title),
|
||||
failureMessage: (error) =>
|
||||
t.messages.metadataRefreshFailed(error: error.toString()),
|
||||
failureMessage: (error) => t.messages.metadataRefreshFailed(error: error.toString()),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -955,23 +862,17 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
for (final lib in libraries) {
|
||||
nameCounts[lib.title] = (nameCounts[lib.title] ?? 0) + 1;
|
||||
}
|
||||
return nameCounts.entries
|
||||
.where((e) => e.value > 1)
|
||||
.map((e) => e.key)
|
||||
.toSet();
|
||||
return nameCounts.entries.where((e) => e.value > 1).map((e) => e.key).toSet();
|
||||
}
|
||||
|
||||
/// Build dropdown menu items with server subtitle for non-unique names
|
||||
List<PopupMenuEntry<String>> _buildGroupedLibraryMenuItems(
|
||||
List<PlexLibrary> visibleLibraries,
|
||||
) {
|
||||
List<PopupMenuEntry<String>> _buildGroupedLibraryMenuItems(List<PlexLibrary> visibleLibraries) {
|
||||
// Find which library names are not unique
|
||||
final nonUniqueNames = _getNonUniqueLibraryNames(visibleLibraries);
|
||||
|
||||
return visibleLibraries.map((library) {
|
||||
final isSelected = library.globalKey == _selectedLibraryGlobalKey;
|
||||
final showServerName =
|
||||
nonUniqueNames.contains(library.title) && library.serverName != null;
|
||||
final showServerName = nonUniqueNames.contains(library.title) && library.serverName != null;
|
||||
|
||||
return PopupMenuItem<String>(
|
||||
value: library.globalKey,
|
||||
@@ -992,12 +893,8 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
Text(
|
||||
library.title,
|
||||
style: TextStyle(
|
||||
fontWeight: isSelected
|
||||
? FontWeight.w600
|
||||
: FontWeight.w400,
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: null,
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
|
||||
color: isSelected ? Theme.of(context).colorScheme.primary : null,
|
||||
),
|
||||
),
|
||||
if (showServerName)
|
||||
@@ -1005,9 +902,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
library.serverName!,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.color?.withValues(alpha: 0.6),
|
||||
color: Theme.of(context).textTheme.bodySmall?.color?.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -1116,25 +1011,17 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
selectedLibrary.title,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
Text(selectedLibrary.title, style: Theme.of(context).textTheme.titleMedium),
|
||||
Text(
|
||||
selectedLibrary.serverName!,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.color?.withValues(alpha: 0.6),
|
||||
color: Theme.of(context).textTheme.bodySmall?.color?.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
else
|
||||
Text(
|
||||
selectedLibrary.title,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
Text(selectedLibrary.title, style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(width: 4),
|
||||
const AppIcon(Symbols.arrow_drop_down_rounded, fill: 1, size: 24),
|
||||
],
|
||||
@@ -1150,9 +1037,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
final hiddenKeys = hiddenLibrariesProvider.hiddenLibraryKeys;
|
||||
|
||||
// Compute visible libraries (filtered from all libraries)
|
||||
final visibleLibraries = _allLibraries
|
||||
.where((lib) => !hiddenKeys.contains(lib.globalKey))
|
||||
.toList();
|
||||
final visibleLibraries = _allLibraries.where((lib) => !hiddenKeys.contains(lib.globalKey)).toList();
|
||||
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
@@ -1180,9 +1065,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
],
|
||||
),
|
||||
if (_isLoadingLibraries)
|
||||
const SliverFillRemaining(
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
const SliverFillRemaining(child: Center(child: CircularProgressIndicator()))
|
||||
else if (_errorMessage != null && visibleLibraries.isEmpty)
|
||||
SliverFillRemaining(
|
||||
child: ErrorStateWidget(
|
||||
@@ -1193,21 +1076,14 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
)
|
||||
else if (visibleLibraries.isEmpty)
|
||||
SliverFillRemaining(
|
||||
child: EmptyStateWidget(
|
||||
message: t.libraries.noLibrariesFound,
|
||||
icon: Symbols.video_library_rounded,
|
||||
),
|
||||
child: EmptyStateWidget(message: t.libraries.noLibrariesFound, icon: Symbols.video_library_rounded),
|
||||
)
|
||||
else ...[
|
||||
// Tab selector chips (only on mobile - desktop has them in app bar)
|
||||
if (_selectedLibraryGlobalKey != null &&
|
||||
!PlatformDetector.shouldUseSideNavigation(context))
|
||||
if (_selectedLibraryGlobalKey != null && !PlatformDetector.shouldUseSideNavigation(context))
|
||||
SliverToBoxAdapter(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
@@ -1234,9 +1110,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
children: [
|
||||
LibraryRecommendedTab(
|
||||
key: _recommendedTabKey,
|
||||
library: _allLibraries.firstWhere(
|
||||
(lib) => lib.globalKey == _selectedLibraryGlobalKey,
|
||||
),
|
||||
library: _allLibraries.firstWhere((lib) => lib.globalKey == _selectedLibraryGlobalKey),
|
||||
isActive: _tabController.index == 0,
|
||||
suppressAutoFocus: _suppressAutoFocus,
|
||||
onDataLoaded: () => _handleTabDataLoaded(0),
|
||||
@@ -1244,9 +1118,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
),
|
||||
LibraryBrowseTab(
|
||||
key: _browseTabKey,
|
||||
library: _allLibraries.firstWhere(
|
||||
(lib) => lib.globalKey == _selectedLibraryGlobalKey,
|
||||
),
|
||||
library: _allLibraries.firstWhere((lib) => lib.globalKey == _selectedLibraryGlobalKey),
|
||||
isActive: _tabController.index == 1,
|
||||
suppressAutoFocus: _suppressAutoFocus,
|
||||
onDataLoaded: () => _handleTabDataLoaded(1),
|
||||
@@ -1254,9 +1126,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
),
|
||||
LibraryCollectionsTab(
|
||||
key: _collectionsTabKey,
|
||||
library: _allLibraries.firstWhere(
|
||||
(lib) => lib.globalKey == _selectedLibraryGlobalKey,
|
||||
),
|
||||
library: _allLibraries.firstWhere((lib) => lib.globalKey == _selectedLibraryGlobalKey),
|
||||
isActive: _tabController.index == 2,
|
||||
suppressAutoFocus: _suppressAutoFocus,
|
||||
onDataLoaded: () => _handleTabDataLoaded(2),
|
||||
@@ -1264,9 +1134,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
),
|
||||
LibraryPlaylistsTab(
|
||||
key: _playlistsTabKey,
|
||||
library: _allLibraries.firstWhere(
|
||||
(lib) => lib.globalKey == _selectedLibraryGlobalKey,
|
||||
),
|
||||
library: _allLibraries.firstWhere((lib) => lib.globalKey == _selectedLibraryGlobalKey),
|
||||
isActive: _tabController.index == 3,
|
||||
suppressAutoFocus: _suppressAutoFocus,
|
||||
onDataLoaded: () => _handleTabDataLoaded(3),
|
||||
@@ -1280,7 +1148,6 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class _LibraryManagementSheet extends StatefulWidget {
|
||||
@@ -1301,8 +1168,7 @@ class _LibraryManagementSheet extends StatefulWidget {
|
||||
});
|
||||
|
||||
@override
|
||||
State<_LibraryManagementSheet> createState() =>
|
||||
_LibraryManagementSheetState();
|
||||
State<_LibraryManagementSheet> createState() => _LibraryManagementSheetState();
|
||||
}
|
||||
|
||||
class _LibraryManagementSheetState extends State<_LibraryManagementSheet> {
|
||||
@@ -1440,10 +1306,7 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> {
|
||||
widget.onReorder(_tempLibraries);
|
||||
}
|
||||
|
||||
Future<void> _showLibraryMenuBottomSheet(
|
||||
BuildContext outerContext,
|
||||
PlexLibrary library,
|
||||
) async {
|
||||
Future<void> _showLibraryMenuBottomSheet(BuildContext outerContext, PlexLibrary library) async {
|
||||
final menuItems = widget.getLibraryMenuItems(library);
|
||||
final selected = await showModalBottomSheet<String>(
|
||||
context: outerContext,
|
||||
@@ -1453,13 +1316,7 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> {
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
library.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
child: Text(library.title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||
),
|
||||
...menuItems.indexed.map(
|
||||
(entry) => ListTile(
|
||||
@@ -1475,32 +1332,20 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> {
|
||||
|
||||
if (selected != null && mounted) {
|
||||
// Find the selected item to check if confirmation is needed
|
||||
final selectedItem = menuItems.firstWhere(
|
||||
(item) => item.value == selected,
|
||||
);
|
||||
final selectedItem = menuItems.firstWhere((item) => item.value == selected);
|
||||
|
||||
if (selectedItem.requiresConfirmation) {
|
||||
if (!mounted || !context.mounted) return;
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(
|
||||
selectedItem.confirmationTitle ?? t.dialog.confirmAction,
|
||||
),
|
||||
content: Text(
|
||||
selectedItem.confirmationMessage ??
|
||||
t.libraries.confirmActionMessage,
|
||||
),
|
||||
title: Text(selectedItem.confirmationTitle ?? t.dialog.confirmAction),
|
||||
content: Text(selectedItem.confirmationMessage ?? t.libraries.confirmActionMessage),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: Text(t.common.cancel),
|
||||
),
|
||||
TextButton(onPressed: () => Navigator.pop(context, false), child: Text(t.common.cancel)),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
style: selectedItem.isDestructive
|
||||
? TextButton.styleFrom(foregroundColor: Colors.red)
|
||||
: null,
|
||||
style: selectedItem.isDestructive ? TextButton.styleFrom(foregroundColor: Colors.red) : null,
|
||||
child: Text(t.common.confirm),
|
||||
),
|
||||
],
|
||||
@@ -1520,10 +1365,7 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> {
|
||||
for (final lib in _tempLibraries) {
|
||||
nameCounts[lib.title] = (nameCounts[lib.title] ?? 0) + 1;
|
||||
}
|
||||
return nameCounts.entries
|
||||
.where((e) => e.value > 1)
|
||||
.map((e) => e.key)
|
||||
.toSet();
|
||||
return nameCounts.entries.where((e) => e.value > 1).map((e) => e.key).toSet();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -1544,9 +1386,7 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> {
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(color: Theme.of(context).dividerColor),
|
||||
),
|
||||
border: Border(bottom: BorderSide(color: Theme.of(context).dividerColor)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
@@ -1555,10 +1395,7 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> {
|
||||
Expanded(
|
||||
child: Text(
|
||||
t.libraries.manageLibraries,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
@@ -1575,10 +1412,7 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> {
|
||||
focusNode: _listFocusNode,
|
||||
autofocus: InputModeTracker.isKeyboardMode(context),
|
||||
onKeyEvent: _handleKeyEvent,
|
||||
child: _buildFlatLibraryList(
|
||||
scrollController,
|
||||
hiddenLibraryKeys,
|
||||
),
|
||||
child: _buildFlatLibraryList(scrollController, hiddenLibraryKeys),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -1588,10 +1422,7 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> {
|
||||
}
|
||||
|
||||
/// Build flat library list with server subtitle for non-unique names
|
||||
Widget _buildFlatLibraryList(
|
||||
ScrollController scrollController,
|
||||
Set<String> hiddenLibraryKeys,
|
||||
) {
|
||||
Widget _buildFlatLibraryList(ScrollController scrollController, Set<String> hiddenLibraryKeys) {
|
||||
final nonUniqueNames = _getNonUniqueLibraryNames();
|
||||
final isKeyboardMode = InputModeTracker.isKeyboardMode(context);
|
||||
|
||||
@@ -1603,9 +1434,7 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> {
|
||||
buildDefaultDragHandles: false,
|
||||
itemBuilder: (context, index) {
|
||||
final library = _tempLibraries[index];
|
||||
final showServerName =
|
||||
nonUniqueNames.contains(library.title) &&
|
||||
library.serverName != null;
|
||||
final showServerName = nonUniqueNames.contains(library.title) && library.serverName != null;
|
||||
final isFocused = isKeyboardMode && index == _focusedIndex;
|
||||
final isMoving = index == _movingIndex;
|
||||
return _buildLibraryTile(
|
||||
@@ -1659,13 +1488,9 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> {
|
||||
ReorderableDragStartListener(
|
||||
index: index,
|
||||
child: AppIcon(
|
||||
isMoving
|
||||
? Symbols.swap_vert_rounded
|
||||
: Symbols.drag_indicator_rounded,
|
||||
isMoving ? Symbols.swap_vert_rounded : Symbols.drag_indicator_rounded,
|
||||
fill: 1,
|
||||
color: isMoving
|
||||
? colorScheme.primary
|
||||
: IconTheme.of(context).color?.withValues(alpha: 0.5),
|
||||
color: isMoving ? colorScheme.primary : IconTheme.of(context).color?.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
@@ -1678,9 +1503,7 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> {
|
||||
library.serverName!,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.color?.withValues(alpha: 0.6),
|
||||
color: Theme.of(context).textTheme.bodySmall?.color?.withValues(alpha: 0.6),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
@@ -1689,36 +1512,22 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> {
|
||||
children: [
|
||||
Container(
|
||||
decoration: isVisibilityButtonFocused
|
||||
? BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
)
|
||||
? BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(20))
|
||||
: null,
|
||||
child: IconButton(
|
||||
icon: AppIcon(
|
||||
isHidden
|
||||
? Symbols.visibility_off_rounded
|
||||
: Symbols.visibility_rounded,
|
||||
fill: 1,
|
||||
),
|
||||
tooltip: isHidden
|
||||
? t.libraries.showLibrary
|
||||
: t.libraries.hideLibrary,
|
||||
icon: AppIcon(isHidden ? Symbols.visibility_off_rounded : Symbols.visibility_rounded, fill: 1),
|
||||
tooltip: isHidden ? t.libraries.showLibrary : t.libraries.hideLibrary,
|
||||
onPressed: () => widget.onToggleVisibility(library),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
decoration: isOptionsButtonFocused
|
||||
? BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
)
|
||||
? BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(20))
|
||||
: null,
|
||||
child: IconButton(
|
||||
icon: const AppIcon(Symbols.more_vert_rounded, fill: 1),
|
||||
tooltip: t.libraries.libraryOptions,
|
||||
onPressed: () =>
|
||||
_showLibraryMenuBottomSheet(context, library),
|
||||
onPressed: () => _showLibraryMenuBottomSheet(context, library),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -79,10 +79,7 @@ class _SortBottomSheetState extends State<SortBottomSheet> {
|
||||
BottomSheetHeader(
|
||||
title: t.libraries.sortBy,
|
||||
action: widget.onClear != null
|
||||
? TextButton(
|
||||
onPressed: _handleClear,
|
||||
child: Text(t.common.clear),
|
||||
)
|
||||
? TextButton(onPressed: _handleClear, child: Text(t.common.clear))
|
||||
: null,
|
||||
),
|
||||
Expanded(
|
||||
@@ -111,19 +108,11 @@ class _SortBottomSheetState extends State<SortBottomSheet> {
|
||||
segments: const [
|
||||
ButtonSegment(
|
||||
value: false,
|
||||
icon: AppIcon(
|
||||
Symbols.arrow_upward_rounded,
|
||||
fill: 1,
|
||||
size: 16,
|
||||
),
|
||||
icon: AppIcon(Symbols.arrow_upward_rounded, fill: 1, size: 16),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: true,
|
||||
icon: AppIcon(
|
||||
Symbols.arrow_downward_rounded,
|
||||
fill: 1,
|
||||
size: 16,
|
||||
),
|
||||
icon: AppIcon(Symbols.arrow_downward_rounded, fill: 1, size: 16),
|
||||
),
|
||||
],
|
||||
selected: {_currentDescending},
|
||||
|
||||
@@ -63,9 +63,7 @@ class StateMessageWidget extends StatelessWidget {
|
||||
icon,
|
||||
fill: 1,
|
||||
size: iconSize,
|
||||
color:
|
||||
iconColor ??
|
||||
theme.colorScheme.onSurface.withValues(alpha: 0.4),
|
||||
color: iconColor ?? theme.colorScheme.onSurface.withValues(alpha: 0.4),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
@@ -73,9 +71,7 @@ class StateMessageWidget extends StatelessWidget {
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
color:
|
||||
textColor ??
|
||||
theme.colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
color: textColor ?? theme.colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
if (subtitle != null) ...[
|
||||
@@ -83,11 +79,7 @@ class StateMessageWidget extends StatelessWidget {
|
||||
Text(
|
||||
subtitle!,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color:
|
||||
subtitleColor ??
|
||||
theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
style: theme.textTheme.bodyMedium?.copyWith(color: subtitleColor ?? theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
if (onAction != null && actionLabel != null) ...[
|
||||
@@ -163,13 +155,7 @@ class ErrorStateWidget extends StatelessWidget {
|
||||
/// Optional label for the retry button
|
||||
final String? retryLabel;
|
||||
|
||||
const ErrorStateWidget({
|
||||
super.key,
|
||||
required this.message,
|
||||
this.icon,
|
||||
this.onRetry,
|
||||
this.retryLabel,
|
||||
});
|
||||
const ErrorStateWidget({super.key, required this.message, this.icon, this.onRetry, this.retryLabel});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
@@ -55,8 +55,7 @@ abstract class BaseLibraryTab<T> extends StatefulWidget {
|
||||
|
||||
/// State mixin that provides the common implementation for library tabs
|
||||
/// This preserves AutomaticKeepAliveClientMixin functionality
|
||||
abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>>
|
||||
extends State<W>
|
||||
abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>> extends State<W>
|
||||
with AutomaticKeepAliveClientMixin, Refreshable, LibraryTabStateMixin {
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
@@ -167,10 +166,7 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>>
|
||||
// Don't auto-focus if suppressed (e.g., when navigating via tab bar)
|
||||
if (widget.suppressAutoFocus) return;
|
||||
|
||||
if (widget.isActive &&
|
||||
_hasLoadedData &&
|
||||
!_hasFocused &&
|
||||
_items.isNotEmpty) {
|
||||
if (widget.isActive && _hasLoadedData && !_hasFocused && _items.isNotEmpty) {
|
||||
_hasFocused = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
@@ -235,8 +231,7 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>>
|
||||
emptyIcon: emptyIcon,
|
||||
emptyMessage: emptyMessage,
|
||||
onRetry: loadItems,
|
||||
builder: (items) =>
|
||||
RefreshIndicator(onRefresh: loadItems, child: buildContent(items)),
|
||||
builder: (items) => RefreshIndicator(onRefresh: loadItems, child: buildContent(items)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,8 +41,7 @@ class LibraryBrowseTab extends BaseLibraryTab<PlexMetadata> {
|
||||
State<LibraryBrowseTab> createState() => _LibraryBrowseTabState();
|
||||
}
|
||||
|
||||
class _LibraryBrowseTabState
|
||||
extends BaseLibraryTabState<PlexMetadata, LibraryBrowseTab>
|
||||
class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBrowseTab>
|
||||
with ItemUpdatable, LibraryTabFocusMixin {
|
||||
@override
|
||||
PlexClient get client => getClientForLibrary();
|
||||
@@ -79,9 +78,7 @@ class _LibraryBrowseTabState
|
||||
static const int _pageSize = 500;
|
||||
|
||||
// Focus nodes for filter chips
|
||||
final FocusNode _groupingChipFocusNode = FocusNode(
|
||||
debugLabel: 'grouping_chip',
|
||||
);
|
||||
final FocusNode _groupingChipFocusNode = FocusNode(debugLabel: 'grouping_chip');
|
||||
final FocusNode _filtersChipFocusNode = FocusNode(debugLabel: 'filters_chip');
|
||||
final FocusNode _sortChipFocusNode = FocusNode(debugLabel: 'sort_chip');
|
||||
|
||||
@@ -171,13 +168,9 @@ class _LibraryBrowseTabState
|
||||
final sorts = await client.getLibrarySorts(widget.library.key);
|
||||
|
||||
// Load saved preferences
|
||||
final savedFilters = storage.getLibraryFilters(
|
||||
sectionId: widget.library.globalKey,
|
||||
);
|
||||
final savedFilters = storage.getLibraryFilters(sectionId: widget.library.globalKey);
|
||||
final savedSort = storage.getLibrarySort(widget.library.globalKey);
|
||||
final savedGrouping = storage.getLibraryGrouping(
|
||||
widget.library.globalKey,
|
||||
);
|
||||
final savedGrouping = storage.getLibraryGrouping(widget.library.globalKey);
|
||||
|
||||
// Check if request was cancelled
|
||||
if (currentRequestId != _requestId) return;
|
||||
@@ -246,9 +239,7 @@ class _LibraryBrowseTabState
|
||||
|
||||
// Add sort
|
||||
if (_selectedSort != null) {
|
||||
filterParams['sort'] = _selectedSort!.getSortKey(
|
||||
descending: _isSortDescending,
|
||||
);
|
||||
filterParams['sort'] = _selectedSort!.getSortKey(descending: _isSortDescending);
|
||||
}
|
||||
|
||||
// Items are automatically tagged with server info by PlexClient
|
||||
@@ -373,10 +364,7 @@ class _LibraryBrowseTabState
|
||||
});
|
||||
|
||||
final storage = await StorageService.getInstance();
|
||||
await storage.saveLibraryGrouping(
|
||||
widget.library.globalKey,
|
||||
value,
|
||||
);
|
||||
await storage.saveLibraryGrouping(widget.library.globalKey, value);
|
||||
|
||||
if (!sheetContext.mounted || !mounted) return;
|
||||
|
||||
@@ -388,10 +376,7 @@ class _LibraryBrowseTabState
|
||||
itemCount: options.length,
|
||||
itemBuilder: (context, index) {
|
||||
final grouping = options[index];
|
||||
return RadioListTile<String>(
|
||||
title: Text(_getGroupingLabel(grouping)),
|
||||
value: grouping,
|
||||
);
|
||||
return RadioListTile<String>(title: Text(_getGroupingLabel(grouping)), value: grouping);
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -415,10 +400,7 @@ class _LibraryBrowseTabState
|
||||
|
||||
// Save filters to storage
|
||||
final storage = await StorageService.getInstance();
|
||||
await storage.saveLibraryFilters(
|
||||
filters,
|
||||
sectionId: widget.library.globalKey,
|
||||
);
|
||||
await storage.saveLibraryFilters(filters, sectionId: widget.library.globalKey);
|
||||
|
||||
_loadItems();
|
||||
},
|
||||
@@ -441,11 +423,7 @@ class _LibraryBrowseTabState
|
||||
});
|
||||
|
||||
StorageService.getInstance().then((storage) {
|
||||
storage.saveLibrarySort(
|
||||
widget.library.globalKey,
|
||||
sort.key,
|
||||
descending: descending,
|
||||
);
|
||||
storage.saveLibrarySort(widget.library.globalKey, sort.key, descending: descending);
|
||||
});
|
||||
|
||||
_loadItems();
|
||||
@@ -467,16 +445,9 @@ class _LibraryBrowseTabState
|
||||
}
|
||||
|
||||
/// Calculate the number of columns in the current grid based on screen width
|
||||
int _getGridColumnCount(
|
||||
BuildContext context,
|
||||
SettingsProvider settingsProvider,
|
||||
) {
|
||||
final screenWidth =
|
||||
MediaQuery.of(context).size.width - 16; // Subtract padding
|
||||
final maxCrossAxisExtent = GridSizeCalculator.getMaxCrossAxisExtent(
|
||||
context,
|
||||
settingsProvider.libraryDensity,
|
||||
);
|
||||
int _getGridColumnCount(BuildContext context, SettingsProvider settingsProvider) {
|
||||
final screenWidth = MediaQuery.of(context).size.width - 16; // Subtract padding
|
||||
final maxCrossAxisExtent = GridSizeCalculator.getMaxCrossAxisExtent(context, settingsProvider.libraryDensity);
|
||||
return (screenWidth / maxCrossAxisExtent).floor().clamp(1, 100);
|
||||
}
|
||||
|
||||
@@ -518,16 +489,13 @@ class _LibraryBrowseTabState
|
||||
icon: Symbols.filter_alt_rounded,
|
||||
label: _selectedFilters.isEmpty
|
||||
? t.libraries.filters
|
||||
: t.libraries.filtersWithCount(
|
||||
count: _selectedFilters.length,
|
||||
),
|
||||
: t.libraries.filtersWithCount(count: _selectedFilters.length),
|
||||
onPressed: _showFiltersBottomSheet,
|
||||
onNavigateDown: _navigateToGrid,
|
||||
onNavigateUp: widget.onBack,
|
||||
onBack: widget.onBack,
|
||||
),
|
||||
if (_filters.isNotEmpty && _selectedGrouping != 'folders')
|
||||
const SizedBox(width: 8),
|
||||
if (_filters.isNotEmpty && _selectedGrouping != 'folders') const SizedBox(width: 8),
|
||||
// Sort chip
|
||||
if (_sortOptions.isNotEmpty && _selectedGrouping != 'folders')
|
||||
FocusableFilterChip(
|
||||
@@ -553,11 +521,7 @@ class _LibraryBrowseTabState
|
||||
Widget _buildContent() {
|
||||
// Show folder tree view when in folders mode
|
||||
if (_selectedGrouping == 'folders') {
|
||||
return FolderTreeView(
|
||||
libraryKey: widget.library.key,
|
||||
serverId: widget.library.serverId,
|
||||
onRefresh: updateItem,
|
||||
);
|
||||
return FolderTreeView(libraryKey: widget.library.key, serverId: widget.library.serverId, onRefresh: updateItem);
|
||||
}
|
||||
|
||||
if (isLoading && items.isEmpty) {
|
||||
@@ -574,18 +538,12 @@ class _LibraryBrowseTabState
|
||||
}
|
||||
|
||||
if (items.isEmpty) {
|
||||
return EmptyStateWidget(
|
||||
message: t.libraries.thisLibraryIsEmpty,
|
||||
icon: Symbols.folder_open_rounded,
|
||||
);
|
||||
return EmptyStateWidget(message: t.libraries.thisLibraryIsEmpty, icon: Symbols.folder_open_rounded);
|
||||
}
|
||||
|
||||
return NotificationListener<ScrollNotification>(
|
||||
onNotification: (notification) {
|
||||
if (notification.metrics.pixels >=
|
||||
notification.metrics.maxScrollExtent - 300 &&
|
||||
_hasMoreItems &&
|
||||
!isLoading) {
|
||||
if (notification.metrics.pixels >= notification.metrics.maxScrollExtent - 300 && _hasMoreItems && !isLoading) {
|
||||
_loadItems(loadMore: true);
|
||||
}
|
||||
return false;
|
||||
@@ -599,10 +557,7 @@ class _LibraryBrowseTabState
|
||||
}
|
||||
|
||||
/// Builds either a list or grid view based on the view mode
|
||||
Widget _buildItemsView(
|
||||
BuildContext context,
|
||||
SettingsProvider settingsProvider,
|
||||
) {
|
||||
Widget _buildItemsView(BuildContext context, SettingsProvider settingsProvider) {
|
||||
final itemCount = items.length + (_hasMoreItems && isLoading ? 1 : 0);
|
||||
|
||||
if (settingsProvider.viewMode == ViewMode.list) {
|
||||
@@ -610,23 +565,16 @@ class _LibraryBrowseTabState
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.all(8),
|
||||
itemCount: itemCount,
|
||||
itemBuilder: (context, index) =>
|
||||
_buildMediaCardItem(index, isFirstRow: index == 0),
|
||||
itemBuilder: (context, index) => _buildMediaCardItem(index, isFirstRow: index == 0),
|
||||
);
|
||||
} else {
|
||||
// In grid view, calculate columns and pass to item builder
|
||||
final columnCount = _getGridColumnCount(context, settingsProvider);
|
||||
return GridView.builder(
|
||||
padding: const EdgeInsets.all(8),
|
||||
gridDelegate: MediaGridDelegate.createDelegate(
|
||||
context: context,
|
||||
density: settingsProvider.libraryDensity,
|
||||
),
|
||||
gridDelegate: MediaGridDelegate.createDelegate(context: context, density: settingsProvider.libraryDensity),
|
||||
itemCount: itemCount,
|
||||
itemBuilder: (context, index) => _buildMediaCardItem(
|
||||
index,
|
||||
isFirstRow: _isFirstRow(index, columnCount),
|
||||
),
|
||||
itemBuilder: (context, index) => _buildMediaCardItem(index, isFirstRow: _isFirstRow(index, columnCount)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,7 @@ class LibraryCollectionsTab extends BaseLibraryTab<PlexMetadata> {
|
||||
State<LibraryCollectionsTab> createState() => _LibraryCollectionsTabState();
|
||||
}
|
||||
|
||||
class _LibraryCollectionsTabState
|
||||
extends LibraryGridTabState<PlexMetadata, LibraryCollectionsTab> {
|
||||
class _LibraryCollectionsTabState extends LibraryGridTabState<PlexMetadata, LibraryCollectionsTab> {
|
||||
@override
|
||||
String get focusNodeDebugLabel => 'collections_first_item';
|
||||
|
||||
@@ -40,8 +39,7 @@ class _LibraryCollectionsTabState
|
||||
String get errorContext => t.collections.title;
|
||||
|
||||
@override
|
||||
Stream<void>? getRefreshStream() =>
|
||||
LibraryRefreshNotifier().collectionsStream;
|
||||
Stream<void>? getRefreshStream() => LibraryRefreshNotifier().collectionsStream;
|
||||
|
||||
@override
|
||||
Future<List<PlexMetadata>> loadData() async {
|
||||
|
||||
@@ -8,8 +8,7 @@ import 'base_library_tab.dart';
|
||||
///
|
||||
/// Handles focus, item counting, and grid wiring so individual tabs only
|
||||
/// implement data loading and per-item rendering.
|
||||
abstract class LibraryGridTabState<T, W extends BaseLibraryTab<T>>
|
||||
extends BaseLibraryTabState<T, W>
|
||||
abstract class LibraryGridTabState<T, W extends BaseLibraryTab<T>> extends BaseLibraryTabState<T, W>
|
||||
with LibraryTabFocusMixin {
|
||||
/// Build a single grid item.
|
||||
Widget buildGridItem(BuildContext context, T item, int index);
|
||||
@@ -21,8 +20,7 @@ abstract class LibraryGridTabState<T, W extends BaseLibraryTab<T>>
|
||||
Widget buildContent(List<T> items) {
|
||||
return AdaptiveMediaGrid<T>(
|
||||
items: items,
|
||||
itemBuilder: (context, item, index) =>
|
||||
buildGridItem(context, item, index),
|
||||
itemBuilder: (context, item, index) => buildGridItem(context, item, index),
|
||||
onRefresh: loadItems,
|
||||
firstItemFocusNode: firstItemFocusNode,
|
||||
onBack: widget.onBack,
|
||||
|
||||
@@ -25,8 +25,7 @@ class LibraryPlaylistsTab extends BaseLibraryTab<PlexPlaylist> {
|
||||
State<LibraryPlaylistsTab> createState() => _LibraryPlaylistsTabState();
|
||||
}
|
||||
|
||||
class _LibraryPlaylistsTabState
|
||||
extends LibraryGridTabState<PlexPlaylist, LibraryPlaylistsTab> {
|
||||
class _LibraryPlaylistsTabState extends LibraryGridTabState<PlexPlaylist, LibraryPlaylistsTab> {
|
||||
@override
|
||||
String get focusNodeDebugLabel => 'playlists_first_item';
|
||||
|
||||
@@ -48,10 +47,7 @@ class _LibraryPlaylistsTabState
|
||||
final client = getClientForLibrary();
|
||||
|
||||
// Playlists are automatically tagged with server info by PlexClient
|
||||
return await client.getLibraryPlaylists(
|
||||
sectionId: widget.library.key,
|
||||
playlistType: 'video',
|
||||
);
|
||||
return await client.getLibraryPlaylists(sectionId: widget.library.key, playlistType: 'video');
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -25,9 +25,7 @@ class LibraryRecommendedTab extends BaseLibraryTab<PlexHub> {
|
||||
State<LibraryRecommendedTab> createState() => _LibraryRecommendedTabState();
|
||||
}
|
||||
|
||||
class _LibraryRecommendedTabState
|
||||
extends BaseLibraryTabState<PlexHub, LibraryRecommendedTab>
|
||||
with ItemUpdatable {
|
||||
class _LibraryRecommendedTabState extends BaseLibraryTabState<PlexHub, LibraryRecommendedTab> with ItemUpdatable {
|
||||
/// GlobalKeys for each hub section to enable vertical navigation
|
||||
final List<GlobalKey<HubSectionState>> _hubKeys = [];
|
||||
|
||||
@@ -38,9 +36,7 @@ class _LibraryRecommendedTabState
|
||||
void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) {
|
||||
// Update the item in any hub that contains it
|
||||
for (final hub in items) {
|
||||
final itemIndex = hub.items.indexWhere(
|
||||
(item) => item.ratingKey == ratingKey,
|
||||
);
|
||||
final itemIndex = hub.items.indexWhere((item) => item.ratingKey == ratingKey);
|
||||
if (itemIndex != -1) {
|
||||
hub.items[itemIndex] = updatedMetadata;
|
||||
}
|
||||
@@ -151,8 +147,7 @@ class _LibraryRecommendedTabState
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final hub = items[index];
|
||||
final isContinueWatching =
|
||||
hub.hubIdentifier == '_library_continue_watching_';
|
||||
final isContinueWatching = hub.hubIdentifier == '_library_continue_watching_';
|
||||
|
||||
return HubSection(
|
||||
key: index < _hubKeys.length ? _hubKeys[index] : null,
|
||||
@@ -160,11 +155,8 @@ class _LibraryRecommendedTabState
|
||||
icon: _getHubIcon(hub),
|
||||
isInContinueWatching: isContinueWatching,
|
||||
onRefresh: updateItem,
|
||||
onRemoveFromContinueWatching: isContinueWatching
|
||||
? _refreshContinueWatching
|
||||
: null,
|
||||
onVerticalNavigation: (isUp) =>
|
||||
_handleVerticalNavigation(index, isUp),
|
||||
onRemoveFromContinueWatching: isContinueWatching ? _refreshContinueWatching : null,
|
||||
onVerticalNavigation: (isUp) => _handleVerticalNavigation(index, isUp),
|
||||
onBack: widget.onBack,
|
||||
);
|
||||
},
|
||||
|
||||
@@ -82,12 +82,8 @@ class _MainScreenState extends State<MainScreen> with RouteAware {
|
||||
final GlobalKey<SideNavigationRailState> _sideNavKey = GlobalKey();
|
||||
|
||||
// Focus management for sidebar/content switching
|
||||
final FocusScopeNode _sidebarFocusScope = FocusScopeNode(
|
||||
debugLabel: 'Sidebar',
|
||||
);
|
||||
final FocusScopeNode _contentFocusScope = FocusScopeNode(
|
||||
debugLabel: 'Content',
|
||||
);
|
||||
final FocusScopeNode _sidebarFocusScope = FocusScopeNode(debugLabel: 'Sidebar');
|
||||
final FocusScopeNode _contentFocusScope = FocusScopeNode(debugLabel: 'Content');
|
||||
bool _isSidebarFocused = false;
|
||||
|
||||
@override
|
||||
@@ -154,21 +150,12 @@ class _MainScreenState extends State<MainScreen> with RouteAware {
|
||||
// In offline mode, only show Downloads and Settings
|
||||
// In online mode, show all 5 screens
|
||||
if (offline) {
|
||||
return [
|
||||
DownloadsScreen(key: _downloadsKey),
|
||||
SettingsScreen(key: _settingsKey),
|
||||
];
|
||||
return [DownloadsScreen(key: _downloadsKey), SettingsScreen(key: _settingsKey)];
|
||||
}
|
||||
|
||||
return [
|
||||
DiscoverScreen(
|
||||
key: _discoverKey,
|
||||
onBecameVisible: _onDiscoverBecameVisible,
|
||||
),
|
||||
LibrariesScreen(
|
||||
key: _librariesKey,
|
||||
onLibraryOrderChanged: _onLibraryOrderChanged,
|
||||
),
|
||||
DiscoverScreen(key: _discoverKey, onBecameVisible: _onDiscoverBecameVisible),
|
||||
LibrariesScreen(key: _librariesKey, onLibraryOrderChanged: _onLibraryOrderChanged),
|
||||
SearchScreen(key: _searchKey),
|
||||
DownloadsScreen(key: _downloadsKey),
|
||||
SettingsScreen(key: _settingsKey),
|
||||
@@ -211,8 +198,7 @@ class _MainScreenState extends State<MainScreen> with RouteAware {
|
||||
_lastOnlineTabId = previousTabId;
|
||||
}
|
||||
|
||||
_currentIndex =
|
||||
_normalizeIndexForMode(_currentIndex, wasOffline, _isOffline);
|
||||
_currentIndex = _normalizeIndexForMode(_currentIndex, wasOffline, _isOffline);
|
||||
|
||||
// Track if we auto-switched to Downloads because the previous tab was unavailable.
|
||||
_autoSwitchedToDownloads =
|
||||
@@ -222,14 +208,10 @@ class _MainScreenState extends State<MainScreen> with RouteAware {
|
||||
// Coming back online: restore the last online tab if we forced a switch to Downloads.
|
||||
if (_autoSwitchedToDownloads) {
|
||||
final restoredTab = _lastOnlineTabId ?? NavigationTabId.discover;
|
||||
final restoredIndex = NavigationTab.indexFor(
|
||||
restoredTab,
|
||||
isOffline: _isOffline,
|
||||
);
|
||||
final restoredIndex = NavigationTab.indexFor(restoredTab, isOffline: _isOffline);
|
||||
_currentIndex = restoredIndex >= 0 ? restoredIndex : 0;
|
||||
} else {
|
||||
_currentIndex =
|
||||
_normalizeIndexForMode(_currentIndex, wasOffline, _isOffline);
|
||||
_currentIndex = _normalizeIndexForMode(_currentIndex, wasOffline, _isOffline);
|
||||
}
|
||||
_autoSwitchedToDownloads = false;
|
||||
}
|
||||
@@ -326,9 +308,7 @@ class _MainScreenState extends State<MainScreen> with RouteAware {
|
||||
/// Invalidate all cached data across all screens when profile is switched
|
||||
/// Receives the list of servers with new profile tokens for reconnection
|
||||
Future<void> _invalidateAllScreens(List<PlexServer> servers) async {
|
||||
appLogger.d(
|
||||
'Invalidating all screen data due to profile switch with ${servers.length} servers',
|
||||
);
|
||||
appLogger.d('Invalidating all screen data due to profile switch with ${servers.length} servers');
|
||||
|
||||
// Get all providers
|
||||
final multiServerProvider = context.read<MultiServerProvider>();
|
||||
@@ -341,13 +321,8 @@ class _MainScreenState extends State<MainScreen> with RouteAware {
|
||||
final storage = await StorageService.getInstance();
|
||||
final clientId = storage.getClientIdentifier();
|
||||
|
||||
final connectedCount = await multiServerProvider.reconnectWithServers(
|
||||
servers,
|
||||
clientIdentifier: clientId,
|
||||
);
|
||||
appLogger.d(
|
||||
'Reconnected to $connectedCount/${servers.length} servers after profile switch',
|
||||
);
|
||||
final connectedCount = await multiServerProvider.reconnectWithServers(servers, clientIdentifier: clientId);
|
||||
appLogger.d('Reconnected to $connectedCount/${servers.length} servers after profile switch');
|
||||
|
||||
// Trigger watch state sync now that servers are connected
|
||||
if (connectedCount > 0) {
|
||||
@@ -491,10 +466,7 @@ class _MainScreenState extends State<MainScreen> with RouteAware {
|
||||
node: _contentFocusScope,
|
||||
// No autofocus - we control focus programmatically to prevent
|
||||
// autofocus from stealing focus back after setState() rebuilds
|
||||
child: IndexedStack(
|
||||
index: _currentIndex,
|
||||
children: _screens,
|
||||
),
|
||||
child: IndexedStack(index: _currentIndex, children: _screens),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -24,8 +24,7 @@ class PlaylistDetailScreen extends StatefulWidget {
|
||||
State<PlaylistDetailScreen> createState() => _PlaylistDetailScreenState();
|
||||
}
|
||||
|
||||
class _PlaylistDetailScreenState
|
||||
extends BaseMediaListDetailScreen<PlaylistDetailScreen>
|
||||
class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetailScreen>
|
||||
with StandardItemLoader<PlaylistDetailScreen> {
|
||||
@override
|
||||
dynamic get mediaItem => widget.playlist;
|
||||
@@ -66,14 +65,10 @@ class _PlaylistDetailScreenState
|
||||
|
||||
if (mounted) {
|
||||
if (success) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(t.playlists.deleted)));
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t.playlists.deleted)));
|
||||
Navigator.pop(context); // Return to playlists screen
|
||||
} else {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(t.playlists.errorDeleting)));
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t.playlists.errorDeleting)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -94,9 +89,7 @@ class _PlaylistDetailScreenState
|
||||
if (movedItem.playlistItemID == null) {
|
||||
appLogger.e('Cannot reorder: item missing playlistItemID');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(t.playlists.errorReordering)));
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t.playlists.errorReordering)));
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -112,18 +105,14 @@ class _PlaylistDetailScreenState
|
||||
if (afterItem.playlistItemID == null) {
|
||||
appLogger.e('Cannot reorder: after item missing playlistItemID');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(t.playlists.errorReordering)));
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t.playlists.errorReordering)));
|
||||
}
|
||||
return;
|
||||
}
|
||||
afterPlaylistItemId = afterItem.playlistItemID!;
|
||||
}
|
||||
|
||||
appLogger.d(
|
||||
'Reordering item from $oldIndex to $newIndex (after ID: $afterPlaylistItemId)',
|
||||
);
|
||||
appLogger.d('Reordering item from $oldIndex to $newIndex (after ID: $afterPlaylistItemId)');
|
||||
|
||||
// Optimistically update UI
|
||||
setState(() {
|
||||
@@ -147,9 +136,7 @@ class _PlaylistDetailScreenState
|
||||
items.insert(oldIndex, item);
|
||||
});
|
||||
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(t.playlists.errorReordering)));
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t.playlists.errorReordering)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -161,16 +148,12 @@ class _PlaylistDetailScreenState
|
||||
if (item.playlistItemID == null) {
|
||||
appLogger.e('Cannot remove: item missing playlistItemID');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(t.playlists.errorRemoving)));
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t.playlists.errorRemoving)));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
appLogger.d(
|
||||
'Removing item ${item.title} (playlistItemID: ${item.playlistItemID}) from playlist',
|
||||
);
|
||||
appLogger.d('Removing item ${item.title} (playlistItemID: ${item.playlistItemID}) from playlist');
|
||||
|
||||
// Optimistically update UI
|
||||
setState(() {
|
||||
@@ -185,9 +168,7 @@ class _PlaylistDetailScreenState
|
||||
|
||||
if (mounted) {
|
||||
if (success) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(t.playlists.itemRemoved)));
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t.playlists.itemRemoved)));
|
||||
} else {
|
||||
// Revert on failure
|
||||
appLogger.e('Failed to remove playlist item, reverting UI');
|
||||
@@ -195,9 +176,7 @@ class _PlaylistDetailScreenState
|
||||
items.insert(index, item);
|
||||
});
|
||||
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(t.playlists.errorRemoving)));
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t.playlists.errorRemoving)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -233,20 +212,11 @@ class _PlaylistDetailScreenState
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AppIcon(
|
||||
Symbols.auto_awesome_rounded,
|
||||
fill: 1,
|
||||
size: 12,
|
||||
color: Colors.blue[300],
|
||||
),
|
||||
AppIcon(Symbols.auto_awesome_rounded, fill: 1, size: 12, color: Colors.blue[300]),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
t.playlists.smartPlaylist,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.blue[300],
|
||||
fontWeight: FontWeight.normal,
|
||||
),
|
||||
style: TextStyle(fontSize: 11, color: Colors.blue[300], fontWeight: FontWeight.normal),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -59,11 +59,7 @@ class _PlaylistItemCardState extends State<PlaylistItemCard> {
|
||||
index: widget.index,
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.only(right: 12),
|
||||
child: AppIcon(
|
||||
Symbols.drag_indicator_rounded,
|
||||
fill: 1,
|
||||
color: Colors.grey,
|
||||
),
|
||||
child: AppIcon(Symbols.drag_indicator_rounded, fill: 1, color: Colors.grey),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -81,10 +77,7 @@ class _PlaylistItemCardState extends State<PlaylistItemCard> {
|
||||
// Title
|
||||
Text(
|
||||
item.displayTitle,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
@@ -103,11 +96,7 @@ class _PlaylistItemCardState extends State<PlaylistItemCard> {
|
||||
if (item.viewOffset != null && item.duration != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: MediaProgressBar(
|
||||
viewOffset: item.viewOffset!,
|
||||
duration: item.duration!,
|
||||
minHeight: 3,
|
||||
),
|
||||
child: MediaProgressBar(viewOffset: item.viewOffset!, duration: item.duration!, minHeight: 3),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -117,10 +106,7 @@ class _PlaylistItemCardState extends State<PlaylistItemCard> {
|
||||
|
||||
// Duration
|
||||
if (item.duration != null)
|
||||
Text(
|
||||
formatDurationTextual(item.duration!),
|
||||
style: TextStyle(fontSize: 13, color: Colors.grey[400]),
|
||||
),
|
||||
Text(formatDurationTextual(item.duration!), style: TextStyle(fontSize: 13, color: Colors.grey[400])),
|
||||
|
||||
const SizedBox(width: 8),
|
||||
|
||||
@@ -164,16 +150,8 @@ class _PlaylistItemCardState extends State<PlaylistItemCard> {
|
||||
return Container(
|
||||
width: 60,
|
||||
height: 90,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[850],
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: const AppIcon(
|
||||
Symbols.movie_rounded,
|
||||
fill: 1,
|
||||
color: Colors.grey,
|
||||
size: 24,
|
||||
),
|
||||
decoration: BoxDecoration(color: Colors.grey[850], borderRadius: BorderRadius.circular(6)),
|
||||
child: const AppIcon(Symbols.movie_rounded, fill: 1, color: Colors.grey, size: 24),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,8 +15,7 @@ class PinEntryDialog extends StatefulWidget {
|
||||
State<PinEntryDialog> createState() => _PinEntryDialogState();
|
||||
}
|
||||
|
||||
class _PinEntryDialogState extends State<PinEntryDialog>
|
||||
with SingleTickerProviderStateMixin {
|
||||
class _PinEntryDialogState extends State<PinEntryDialog> with SingleTickerProviderStateMixin {
|
||||
final _pinController = TextEditingController();
|
||||
final _focusNode = FocusNode();
|
||||
bool _obscureText = true;
|
||||
@@ -28,22 +27,16 @@ class _PinEntryDialogState extends State<PinEntryDialog>
|
||||
super.initState();
|
||||
|
||||
// Setup shake animation
|
||||
_shakeController = AnimationController(
|
||||
duration: const Duration(milliseconds: 600),
|
||||
vsync: this,
|
||||
);
|
||||
_shakeController = AnimationController(duration: const Duration(milliseconds: 600), vsync: this);
|
||||
|
||||
// Create a shake effect that oscillates
|
||||
_shakeAnimation =
|
||||
TweenSequence<double>([
|
||||
TweenSequenceItem(tween: Tween(begin: 0.0, end: 10.0), weight: 1),
|
||||
TweenSequenceItem(tween: Tween(begin: 10.0, end: -10.0), weight: 1),
|
||||
TweenSequenceItem(tween: Tween(begin: -10.0, end: 10.0), weight: 1),
|
||||
TweenSequenceItem(tween: Tween(begin: 10.0, end: -10.0), weight: 1),
|
||||
TweenSequenceItem(tween: Tween(begin: -10.0, end: 0.0), weight: 1),
|
||||
]).animate(
|
||||
CurvedAnimation(parent: _shakeController, curve: Curves.easeInOut),
|
||||
);
|
||||
_shakeAnimation = TweenSequence<double>([
|
||||
TweenSequenceItem(tween: Tween(begin: 0.0, end: 10.0), weight: 1),
|
||||
TweenSequenceItem(tween: Tween(begin: 10.0, end: -10.0), weight: 1),
|
||||
TweenSequenceItem(tween: Tween(begin: -10.0, end: 10.0), weight: 1),
|
||||
TweenSequenceItem(tween: Tween(begin: 10.0, end: -10.0), weight: 1),
|
||||
TweenSequenceItem(tween: Tween(begin: -10.0, end: 0.0), weight: 1),
|
||||
]).animate(CurvedAnimation(parent: _shakeController, curve: Curves.easeInOut));
|
||||
|
||||
// Auto-focus the PIN field when dialog opens
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
@@ -80,24 +73,14 @@ class _PinEntryDialogState extends State<PinEntryDialog>
|
||||
return AnimatedBuilder(
|
||||
animation: _shakeAnimation,
|
||||
builder: (context, child) {
|
||||
return Transform.translate(
|
||||
offset: Offset(_shakeAnimation.value, 0),
|
||||
child: child,
|
||||
);
|
||||
return Transform.translate(offset: Offset(_shakeAnimation.value, 0), child: child);
|
||||
},
|
||||
child: AlertDialog(
|
||||
title: Row(
|
||||
children: [
|
||||
AppIcon(
|
||||
Symbols.lock_outline_rounded,
|
||||
fill: 1,
|
||||
size: 24,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
AppIcon(Symbols.lock_outline_rounded, fill: 1, size: 24, color: theme.colorScheme.primary),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(widget.userName, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
Expanded(child: Text(widget.userName, overflow: TextOverflow.ellipsis)),
|
||||
],
|
||||
),
|
||||
content: Column(
|
||||
@@ -109,10 +92,7 @@ class _PinEntryDialogState extends State<PinEntryDialog>
|
||||
focusNode: _focusNode,
|
||||
obscureText: _obscureText,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
LengthLimitingTextInputFormatter(10),
|
||||
],
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly, LengthLimitingTextInputFormatter(10)],
|
||||
decoration: InputDecoration(
|
||||
hintText: t.pinEntry.enterPin,
|
||||
border: const OutlineInputBorder(),
|
||||
@@ -120,9 +100,7 @@ class _PinEntryDialogState extends State<PinEntryDialog>
|
||||
errorMaxLines: 2,
|
||||
suffixIcon: IconButton(
|
||||
icon: AppIcon(
|
||||
_obscureText
|
||||
? Symbols.visibility_off_rounded
|
||||
: Symbols.visibility_rounded,
|
||||
_obscureText ? Symbols.visibility_off_rounded : Symbols.visibility_rounded,
|
||||
fill: 1,
|
||||
size: 20,
|
||||
),
|
||||
@@ -131,9 +109,7 @@ class _PinEntryDialogState extends State<PinEntryDialog>
|
||||
_obscureText = !_obscureText;
|
||||
});
|
||||
},
|
||||
tooltip: _obscureText
|
||||
? t.pinEntry.showPin
|
||||
: t.pinEntry.hidePin,
|
||||
tooltip: _obscureText ? t.pinEntry.showPin : t.pinEntry.hidePin,
|
||||
),
|
||||
),
|
||||
onSubmitted: (_) => _submit(),
|
||||
@@ -141,10 +117,7 @@ class _PinEntryDialogState extends State<PinEntryDialog>
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(null),
|
||||
child: Text(t.common.cancel),
|
||||
),
|
||||
TextButton(onPressed: () => Navigator.of(context).pop(null), child: Text(t.common.cancel)),
|
||||
FilledButton(onPressed: _submit, child: Text(t.common.submit)),
|
||||
],
|
||||
),
|
||||
@@ -153,15 +126,10 @@ class _PinEntryDialogState extends State<PinEntryDialog>
|
||||
}
|
||||
|
||||
/// Shows the PIN entry dialog and returns the entered PIN, or null if cancelled
|
||||
Future<String?> showPinEntryDialog(
|
||||
BuildContext context,
|
||||
String userName, {
|
||||
String? errorMessage,
|
||||
}) {
|
||||
Future<String?> showPinEntryDialog(BuildContext context, String userName, {String? errorMessage}) {
|
||||
return showDialog<String>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) =>
|
||||
PinEntryDialog(userName: userName, errorMessage: errorMessage),
|
||||
builder: (context) => PinEntryDialog(userName: userName, errorMessage: errorMessage),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,9 +29,7 @@ class ProfileListTile extends StatelessWidget {
|
||||
return ListTile(
|
||||
leading: UserAvatarWidget(user: user, size: 40, showIndicators: false),
|
||||
title: Text(user.displayName),
|
||||
subtitle: _hasUserAttributes()
|
||||
? Row(children: _buildUserAttributes(theme))
|
||||
: null,
|
||||
subtitle: _hasUserAttributes() ? Row(children: _buildUserAttributes(theme)) : null,
|
||||
trailing: isCurrentUser
|
||||
? Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
@@ -49,9 +47,7 @@ class ProfileListTile extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
)
|
||||
: (showTrailingIcon
|
||||
? const AppIcon(Symbols.chevron_right_rounded, fill: 1)
|
||||
: null),
|
||||
: (showTrailingIcon ? const AppIcon(Symbols.chevron_right_rounded, fill: 1) : null),
|
||||
onTap: isCurrentUser ? null : onTap,
|
||||
enabled: !isCurrentUser,
|
||||
);
|
||||
@@ -81,13 +77,7 @@ class ProfileListTile extends StatelessWidget {
|
||||
if (i > 0) {
|
||||
attributes.addAll([
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'•',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
Text('•', style: TextStyle(fontSize: 12, color: theme.colorScheme.onSurface.withValues(alpha: 0.5))),
|
||||
const SizedBox(width: 8),
|
||||
]);
|
||||
}
|
||||
@@ -97,11 +87,7 @@ class ProfileListTile extends StatelessWidget {
|
||||
attributes.add(
|
||||
Text(
|
||||
_getAttributeLabel(attribute),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: _getAttributeColor(attribute, theme),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
style: TextStyle(fontSize: 12, color: _getAttributeColor(attribute, theme), fontWeight: FontWeight.w500),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -65,8 +65,7 @@ class ProfileSwitchScreen extends StatelessWidget {
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemBuilder: (context, index) {
|
||||
final user = users[index];
|
||||
final isCurrentUser =
|
||||
user.uuid == userProvider.currentUser?.uuid;
|
||||
final isCurrentUser = user.uuid == userProvider.currentUser?.uuid;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
@@ -95,10 +94,7 @@ class ProfileSwitchScreen extends StatelessWidget {
|
||||
if (success && context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
} else if (!success && context.mounted) {
|
||||
showErrorSnackBar(
|
||||
context,
|
||||
t.errors.failedToSwitchProfile(displayName: user.displayName),
|
||||
);
|
||||
showErrorSnackBar(context, t.errors.failedToSwitchProfile(displayName: user.displayName));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,16 +26,8 @@ class UserAvatarWidget extends StatelessWidget {
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: AppIcon(
|
||||
Symbols.person_rounded,
|
||||
fill: 1,
|
||||
size: size * 0.6,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
decoration: BoxDecoration(color: theme.colorScheme.surfaceContainerHighest, shape: BoxShape.circle),
|
||||
child: AppIcon(Symbols.person_rounded, fill: 1, size: size * 0.6, color: theme.colorScheme.onSurfaceVariant),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -55,8 +47,7 @@ class UserAvatarWidget extends StatelessWidget {
|
||||
double sizeRatio = 0.3,
|
||||
}) {
|
||||
final badgeSize = size * sizeRatio;
|
||||
final iconSize =
|
||||
size * (sizeRatio * 0.67); // Approximately 2/3 of badge size
|
||||
final iconSize = size * (sizeRatio * 0.67); // Approximately 2/3 of badge size
|
||||
|
||||
return Positioned(
|
||||
top: position == 'topRight' ? 0 : null,
|
||||
@@ -68,10 +59,7 @@ class UserAvatarWidget extends StatelessWidget {
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
width: 1,
|
||||
),
|
||||
border: Border.all(color: Theme.of(context).colorScheme.surface, width: 1),
|
||||
),
|
||||
child: AppIcon(icon, fill: 1, size: iconSize, color: iconColor),
|
||||
),
|
||||
@@ -91,16 +79,10 @@ class UserAvatarWidget extends StatelessWidget {
|
||||
}) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
|
||||
),
|
||||
decoration: BoxDecoration(color: backgroundColor, borderRadius: BorderRadius.circular(tokens(context).radiusSm)),
|
||||
child: Text(
|
||||
text,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: textColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(color: textColor, fontWeight: FontWeight.bold),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -147,12 +129,7 @@ class UserAvatarWidget extends StatelessWidget {
|
||||
|
||||
return [
|
||||
const SizedBox(height: 4),
|
||||
Wrap(
|
||||
spacing: 4,
|
||||
runSpacing: 2,
|
||||
alignment: WrapAlignment.center,
|
||||
children: labels,
|
||||
),
|
||||
Wrap(spacing: 4, runSpacing: 2, alignment: WrapAlignment.center, children: labels),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -222,10 +199,7 @@ class UserAvatarWidget extends StatelessWidget {
|
||||
onTap: onTap,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildAvatar(context, theme),
|
||||
..._buildTextLabels(context, theme),
|
||||
],
|
||||
children: [_buildAvatar(context, theme), ..._buildTextLabels(context, theme)],
|
||||
),
|
||||
);
|
||||
} else {
|
||||
@@ -237,7 +211,5 @@ class UserAvatarWidget extends StatelessWidget {
|
||||
|
||||
// Extension to add warning color to ColorScheme if not available
|
||||
extension ColorSchemeExtension on ColorScheme {
|
||||
Color? get warning => brightness == Brightness.light
|
||||
? Colors.orange.shade600
|
||||
: Colors.orange.shade400;
|
||||
Color? get warning => brightness == Brightness.light ? Colors.orange.shade600 : Colors.orange.shade400;
|
||||
}
|
||||
|
||||
@@ -24,8 +24,7 @@ class SearchScreen extends StatefulWidget {
|
||||
State<SearchScreen> createState() => _SearchScreenState();
|
||||
}
|
||||
|
||||
class _SearchScreenState extends State<SearchScreen>
|
||||
with Refreshable, FullRefreshable, SearchInputFocusable {
|
||||
class _SearchScreenState extends State<SearchScreen> with Refreshable, FullRefreshable, SearchInputFocusable {
|
||||
final _searchController = TextEditingController();
|
||||
final _searchFocusNode = FocusNode(debugLabel: 'SearchInput');
|
||||
List<PlexMetadata> _searchResults = [];
|
||||
@@ -37,10 +36,7 @@ class _SearchScreenState extends State<SearchScreen>
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_searchDebounce = debounce(
|
||||
_performSearch,
|
||||
const Duration(milliseconds: 500),
|
||||
);
|
||||
_searchDebounce = debounce(_performSearch, const Duration(milliseconds: 500));
|
||||
_searchController.addListener(_onSearchChanged);
|
||||
// Focus the search input when the screen is shown
|
||||
FocusUtils.requestFocusAfterBuild(this, _searchFocusNode);
|
||||
@@ -92,18 +88,14 @@ class _SearchScreenState extends State<SearchScreen>
|
||||
});
|
||||
|
||||
try {
|
||||
final multiServerProvider = Provider.of<MultiServerProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
final multiServerProvider = Provider.of<MultiServerProvider>(context, listen: false);
|
||||
|
||||
if (!multiServerProvider.hasConnectedServers) {
|
||||
throw Exception('No servers available');
|
||||
}
|
||||
|
||||
// Search across all connected servers
|
||||
final results = await multiServerProvider.aggregationService
|
||||
.searchAcrossServers(query);
|
||||
final results = await multiServerProvider.aggregationService.searchAcrossServers(query);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_searchResults = results;
|
||||
@@ -138,9 +130,7 @@ class _SearchScreenState extends State<SearchScreen>
|
||||
// Public method to fully reload all content (for profile switches)
|
||||
@override
|
||||
void fullRefresh() {
|
||||
appLogger.d(
|
||||
'SearchScreen.fullRefresh() called - clearing search and reloading',
|
||||
);
|
||||
appLogger.d('SearchScreen.fullRefresh() called - clearing search and reloading');
|
||||
// Clear search results and search text for new profile
|
||||
_searchController.clear();
|
||||
setState(() {
|
||||
@@ -184,13 +174,8 @@ class _SearchScreenState extends State<SearchScreen>
|
||||
)
|
||||
: null,
|
||||
filled: true,
|
||||
fillColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(100),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
fillColor: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(100), borderSide: BorderSide.none),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(100),
|
||||
borderSide: BorderSide.none,
|
||||
@@ -199,18 +184,13 @@ class _SearchScreenState extends State<SearchScreen>
|
||||
borderRadius: BorderRadius.circular(100),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_isSearching)
|
||||
const SliverFillRemaining(
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
const SliverFillRemaining(child: Center(child: CircularProgressIndicator()))
|
||||
else if (!_hasSearched)
|
||||
SliverFillRemaining(
|
||||
child: StateMessageWidget(
|
||||
@@ -236,11 +216,7 @@ class _SearchScreenState extends State<SearchScreen>
|
||||
context: context,
|
||||
items: _searchResults,
|
||||
itemBuilder: (context, item, index) {
|
||||
return MediaCard(
|
||||
key: Key(item.ratingKey),
|
||||
item: item,
|
||||
onRefresh: updateItem,
|
||||
);
|
||||
return MediaCard(key: Key(item.ratingKey), item: item, onRefresh: updateItem);
|
||||
},
|
||||
viewMode: settingsProvider.viewMode,
|
||||
density: settingsProvider.libraryDensity,
|
||||
|
||||
@@ -26,18 +26,13 @@ class SeasonDetailScreen extends StatefulWidget {
|
||||
final PlexMetadata season;
|
||||
final bool isOffline;
|
||||
|
||||
const SeasonDetailScreen({
|
||||
super.key,
|
||||
required this.season,
|
||||
this.isOffline = false,
|
||||
});
|
||||
const SeasonDetailScreen({super.key, required this.season, this.isOffline = false});
|
||||
|
||||
@override
|
||||
State<SeasonDetailScreen> createState() => _SeasonDetailScreenState();
|
||||
}
|
||||
|
||||
class _SeasonDetailScreenState extends State<SeasonDetailScreen>
|
||||
with ItemUpdatable {
|
||||
class _SeasonDetailScreenState extends State<SeasonDetailScreen> with ItemUpdatable {
|
||||
PlexClient? _client;
|
||||
|
||||
@override
|
||||
@@ -96,16 +91,11 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen>
|
||||
final downloadProvider = context.read<DownloadProvider>();
|
||||
|
||||
// Get all downloaded episodes for the show (grandparentRatingKey)
|
||||
final allEpisodes = downloadProvider.getDownloadedEpisodesForShow(
|
||||
widget.season.parentRatingKey ?? '',
|
||||
);
|
||||
final allEpisodes = downloadProvider.getDownloadedEpisodesForShow(widget.season.parentRatingKey ?? '');
|
||||
|
||||
// Filter to only this season's episodes
|
||||
final seasonEpisodes =
|
||||
allEpisodes
|
||||
.where((ep) => ep.parentIndex == widget.season.index)
|
||||
.toList()
|
||||
..sort((a, b) => (a.index ?? 0).compareTo(b.index ?? 0));
|
||||
final seasonEpisodes = allEpisodes.where((ep) => ep.parentIndex == widget.season.index).toList()
|
||||
..sort((a, b) => (a.index ?? 0).compareTo(b.index ?? 0));
|
||||
|
||||
setState(() {
|
||||
_episodes = seasonEpisodes;
|
||||
@@ -130,8 +120,7 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen>
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Focus(
|
||||
onKeyEvent: (_, event) =>
|
||||
handleBackKeyNavigation(context, event, result: _watchStateChanged),
|
||||
onKeyEvent: (_, event) => handleBackKeyNavigation(context, event, result: _watchStateChanged),
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
@@ -141,27 +130,18 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen>
|
||||
onBackPressed: () => Navigator.pop(context, _watchStateChanged),
|
||||
),
|
||||
if (_isLoadingEpisodes)
|
||||
const SliverFillRemaining(
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
const SliverFillRemaining(child: Center(child: CircularProgressIndicator()))
|
||||
else if (_episodes.isEmpty)
|
||||
SliverFillRemaining(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
AppIcon(
|
||||
Symbols.movie_rounded,
|
||||
fill: 1,
|
||||
size: 64,
|
||||
color: tokens(context).textMuted,
|
||||
),
|
||||
AppIcon(Symbols.movie_rounded, fill: 1, size: 64, color: tokens(context).textMuted),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
t.messages.noEpisodesFoundGeneral,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
color: tokens(context).textMuted,
|
||||
),
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(color: tokens(context).textMuted),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -175,24 +155,17 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen>
|
||||
String? localPosterPath;
|
||||
if (widget.isOffline && episode.serverId != null) {
|
||||
final downloadProvider = context.read<DownloadProvider>();
|
||||
final globalKey =
|
||||
'${episode.serverId}:${episode.ratingKey}';
|
||||
final globalKey = '${episode.serverId}:${episode.ratingKey}';
|
||||
// Get the artwork reference and convert to local file path
|
||||
final artworkRef = downloadProvider.getArtworkPaths(
|
||||
globalKey,
|
||||
);
|
||||
localPosterPath = artworkRef?.getLocalPath(
|
||||
DownloadStorageService.instance,
|
||||
episode.serverId!,
|
||||
);
|
||||
final artworkRef = downloadProvider.getArtworkPaths(globalKey);
|
||||
localPosterPath = artworkRef?.getLocalPath(DownloadStorageService.instance, episode.serverId!);
|
||||
}
|
||||
return _EpisodeCard(
|
||||
episode: episode,
|
||||
client: _client,
|
||||
isOffline: widget.isOffline,
|
||||
localPosterPath: localPosterPath,
|
||||
autofocus:
|
||||
index == 0 && InputModeTracker.isKeyboardMode(context),
|
||||
autofocus: index == 0 && InputModeTracker.isKeyboardMode(context),
|
||||
onTap: () async {
|
||||
await navigateToVideoPlayerWithRefresh(
|
||||
context,
|
||||
@@ -238,10 +211,7 @@ class _EpisodeCard extends StatelessWidget {
|
||||
if (episode.duration != null)
|
||||
Text(
|
||||
formatDurationTimestamp(Duration(milliseconds: episode.duration!)),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: tokens(context).textMuted,
|
||||
fontSize: 12,
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: tokens(context).textMuted, fontSize: 12),
|
||||
),
|
||||
// Hide watch status when offline (not tracked)
|
||||
if (!isOffline && episode.duration != null && episode.isWatched) ...[
|
||||
@@ -249,18 +219,12 @@ class _EpisodeCard extends StatelessWidget {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||
child: Text(
|
||||
'•',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: tokens(context).textMuted,
|
||||
fontSize: 12,
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: tokens(context).textMuted, fontSize: 12),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${t.discover.watched} ✓',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: tokens(context).textMuted,
|
||||
fontSize: 12,
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: tokens(context).textMuted, fontSize: 12),
|
||||
),
|
||||
],
|
||||
],
|
||||
@@ -270,14 +234,8 @@ class _EpisodeCard extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Hide progress when offline (not tracked)
|
||||
final hasProgress =
|
||||
!isOffline &&
|
||||
episode.viewOffset != null &&
|
||||
episode.duration != null &&
|
||||
episode.viewOffset! > 0;
|
||||
final progress = hasProgress
|
||||
? episode.viewOffset! / episode.duration!
|
||||
: 0.0;
|
||||
final hasProgress = !isOffline && episode.viewOffset != null && episode.duration != null && episode.viewOffset! > 0;
|
||||
final progress = hasProgress ? episode.viewOffset! / episode.duration! : 0.0;
|
||||
|
||||
return MediaContextMenu(
|
||||
item: episode,
|
||||
@@ -287,14 +245,10 @@ class _EpisodeCard extends StatelessWidget {
|
||||
key: Key(episode.ratingKey),
|
||||
autofocus: autofocus,
|
||||
onTap: onTap,
|
||||
hoverColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.surface.withValues(alpha: 0.05),
|
||||
hoverColor: Theme.of(context).colorScheme.surface.withValues(alpha: 0.05),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(color: tokens(context).outline, width: 0.5),
|
||||
),
|
||||
border: Border(bottom: BorderSide(color: tokens(context).outline, width: 0.5)),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
child: Row(
|
||||
@@ -313,14 +267,9 @@ class _EpisodeCard extends StatelessWidget {
|
||||
? Image.file(
|
||||
File(localPosterPath!),
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) =>
|
||||
const PlaceholderContainer(
|
||||
child: AppIcon(
|
||||
Symbols.movie_rounded,
|
||||
fill: 1,
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
errorBuilder: (context, error, stackTrace) => const PlaceholderContainer(
|
||||
child: AppIcon(Symbols.movie_rounded, fill: 1, size: 32),
|
||||
),
|
||||
)
|
||||
: episode.thumb != null
|
||||
? PlexOptimizedImage.thumb(
|
||||
@@ -328,24 +277,12 @@ class _EpisodeCard extends StatelessWidget {
|
||||
imagePath: episode.thumb,
|
||||
filterQuality: FilterQuality.medium,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) =>
|
||||
const PlaceholderContainer(),
|
||||
errorWidget: (context, url, error) =>
|
||||
const PlaceholderContainer(
|
||||
child: AppIcon(
|
||||
Symbols.movie_rounded,
|
||||
fill: 1,
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
)
|
||||
: const PlaceholderContainer(
|
||||
child: AppIcon(
|
||||
Symbols.movie_rounded,
|
||||
fill: 1,
|
||||
size: 32,
|
||||
placeholder: (context, url) => const PlaceholderContainer(),
|
||||
errorWidget: (context, url, error) => const PlaceholderContainer(
|
||||
child: AppIcon(Symbols.movie_rounded, fill: 1, size: 32),
|
||||
),
|
||||
),
|
||||
)
|
||||
: const PlaceholderContainer(child: AppIcon(Symbols.movie_rounded, fill: 1, size: 32)),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -357,10 +294,7 @@ class _EpisodeCard extends StatelessWidget {
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.transparent,
|
||||
Colors.black.withValues(alpha: 0.2),
|
||||
],
|
||||
colors: [Colors.transparent, Colors.black.withValues(alpha: 0.2)],
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
@@ -370,12 +304,7 @@ class _EpisodeCard extends StatelessWidget {
|
||||
color: Colors.black.withValues(alpha: 0.6),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const AppIcon(
|
||||
Symbols.play_arrow_rounded,
|
||||
fill: 1,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
child: const AppIcon(Symbols.play_arrow_rounded, fill: 1, color: Colors.white, size: 20),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -418,14 +347,9 @@ class _EpisodeCard extends StatelessWidget {
|
||||
|
||||
// Only show download status in online mode
|
||||
if (!isOffline && episode.serverId != null) {
|
||||
final globalKey =
|
||||
'${episode.serverId}:${episode.ratingKey}';
|
||||
final progress = downloadProvider.getProgress(
|
||||
globalKey,
|
||||
);
|
||||
final isQueueing = downloadProvider.isQueueing(
|
||||
globalKey,
|
||||
);
|
||||
final globalKey = '${episode.serverId}:${episode.ratingKey}';
|
||||
final progress = downloadProvider.getProgress(globalKey);
|
||||
final isQueueing = downloadProvider.isQueueing(globalKey);
|
||||
|
||||
// Helper to get status-specific muted color
|
||||
Color getMutedColor(Color baseColor) {
|
||||
@@ -441,13 +365,9 @@ class _EpisodeCard extends StatelessWidget {
|
||||
downloadStatusIcon = SizedBox(
|
||||
width: 12,
|
||||
height: 12,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 1.5,
|
||||
color: tokens(context).textMuted,
|
||||
),
|
||||
child: CircularProgressIndicator(strokeWidth: 1.5, color: tokens(context).textMuted),
|
||||
);
|
||||
} else if (progress?.status ==
|
||||
DownloadStatus.queued) {
|
||||
} else if (progress?.status == DownloadStatus.queued) {
|
||||
// Queued state - waiting to download
|
||||
downloadStatusIcon = AppIcon(
|
||||
Symbols.schedule_rounded,
|
||||
@@ -455,8 +375,7 @@ class _EpisodeCard extends StatelessWidget {
|
||||
size: 12,
|
||||
color: getMutedColor(Colors.orange),
|
||||
);
|
||||
} else if (progress?.status ==
|
||||
DownloadStatus.downloading) {
|
||||
} else if (progress?.status == DownloadStatus.downloading) {
|
||||
// Downloading state - active download with radial progress
|
||||
downloadStatusIcon = SizedBox(
|
||||
width: 14,
|
||||
@@ -466,27 +385,22 @@ class _EpisodeCard extends StatelessWidget {
|
||||
children: [
|
||||
// Background circle
|
||||
CircularProgressIndicator(
|
||||
value: 1.0,
|
||||
strokeWidth: 1.5,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
getMutedColor(
|
||||
Colors.blue,
|
||||
).withValues(alpha: 0.3),
|
||||
value: 1.0,
|
||||
strokeWidth: 1.5,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
getMutedColor(Colors.blue).withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Progress circle
|
||||
CircularProgressIndicator(
|
||||
value: progress?.progressPercent,
|
||||
strokeWidth: 1.5,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
getMutedColor(Colors.blue),
|
||||
),
|
||||
valueColor: AlwaysStoppedAnimation<Color>(getMutedColor(Colors.blue)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} else if (progress?.status ==
|
||||
DownloadStatus.paused) {
|
||||
} else if (progress?.status == DownloadStatus.paused) {
|
||||
// Paused state - download paused
|
||||
downloadStatusIcon = AppIcon(
|
||||
Symbols.pause_circle_outline_rounded,
|
||||
@@ -494,8 +408,7 @@ class _EpisodeCard extends StatelessWidget {
|
||||
size: 12,
|
||||
color: getMutedColor(Colors.amber),
|
||||
);
|
||||
} else if (progress?.status ==
|
||||
DownloadStatus.failed) {
|
||||
} else if (progress?.status == DownloadStatus.failed) {
|
||||
// Failed state - download failed
|
||||
downloadStatusIcon = AppIcon(
|
||||
Symbols.error_outline_rounded,
|
||||
@@ -503,8 +416,7 @@ class _EpisodeCard extends StatelessWidget {
|
||||
size: 12,
|
||||
color: getMutedColor(Colors.red),
|
||||
);
|
||||
} else if (progress?.status ==
|
||||
DownloadStatus.cancelled) {
|
||||
} else if (progress?.status == DownloadStatus.cancelled) {
|
||||
// Cancelled state - download cancelled
|
||||
downloadStatusIcon = AppIcon(
|
||||
Symbols.cancel_rounded,
|
||||
@@ -512,8 +424,7 @@ class _EpisodeCard extends StatelessWidget {
|
||||
size: 12,
|
||||
color: getMutedColor(Colors.grey),
|
||||
);
|
||||
} else if (progress?.status ==
|
||||
DownloadStatus.completed) {
|
||||
} else if (progress?.status == DownloadStatus.completed) {
|
||||
// Completed state - download complete
|
||||
downloadStatusIcon = AppIcon(
|
||||
Symbols.file_download_done_rounded,
|
||||
@@ -530,39 +441,28 @@ class _EpisodeCard extends StatelessWidget {
|
||||
// Episode number badge
|
||||
if (episode.index != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 3,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primaryContainer,
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(
|
||||
'E${episode.index}',
|
||||
style: TextStyle(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onPrimaryContainer,
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Download status icon (if present)
|
||||
if (downloadStatusIcon != null) ...[
|
||||
const SizedBox(width: 6),
|
||||
downloadStatusIcon,
|
||||
],
|
||||
if (downloadStatusIcon != null) ...[const SizedBox(width: 6), downloadStatusIcon],
|
||||
const SizedBox(width: 8),
|
||||
// Episode title
|
||||
Expanded(
|
||||
child: Text(
|
||||
episode.title,
|
||||
style: Theme.of(context).textTheme.titleSmall
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
@@ -573,15 +473,13 @@ class _EpisodeCard extends StatelessWidget {
|
||||
),
|
||||
|
||||
// Summary
|
||||
if (episode.summary != null &&
|
||||
episode.summary!.isNotEmpty) ...[
|
||||
if (episode.summary != null && episode.summary!.isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
episode.summary!,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: tokens(context).textMuted,
|
||||
height: 1.3,
|
||||
),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: tokens(context).textMuted, height: 1.3),
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
|
||||
@@ -52,15 +52,12 @@ class _AboutScreenState extends State<AboutScreen> {
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
appName,
|
||||
style: Theme.of(context).textTheme.headlineMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
t.about.versionLabel(version: appVersion),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.copyWith(color: Colors.grey),
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
@@ -80,17 +77,9 @@ class _AboutScreenState extends State<AboutScreen> {
|
||||
leading: const AppIcon(Symbols.description_rounded, fill: 1),
|
||||
title: Text(t.about.openSourceLicenses),
|
||||
subtitle: Text(t.about.viewLicensesDescription),
|
||||
trailing: const AppIcon(
|
||||
Symbols.chevron_right_rounded,
|
||||
fill: 1,
|
||||
),
|
||||
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const LicensesScreen(),
|
||||
),
|
||||
);
|
||||
Navigator.push(context, MaterialPageRoute(builder: (context) => const LicensesScreen()));
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
@@ -44,17 +44,12 @@ class _HotKeyRecorderWidgetState extends State<HotKeyRecorderWidget> {
|
||||
children: [
|
||||
Text(
|
||||
'Current shortcut:',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Theme.of(context).dividerColor),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
@@ -73,21 +68,14 @@ class _HotKeyRecorderWidgetState extends State<HotKeyRecorderWidget> {
|
||||
),
|
||||
if (_recordedHotKey != null)
|
||||
IconButton(
|
||||
icon: const AppIcon(
|
||||
Symbols.backspace_rounded,
|
||||
fill: 1,
|
||||
size: 18,
|
||||
),
|
||||
icon: const AppIcon(Symbols.backspace_rounded, fill: 1, size: 18),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_recordedHotKey = null;
|
||||
});
|
||||
},
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 24,
|
||||
minHeight: 24,
|
||||
),
|
||||
constraints: const BoxConstraints(minWidth: 24, minHeight: 24),
|
||||
tooltip: t.hotkeys.clearShortcut,
|
||||
),
|
||||
],
|
||||
@@ -96,11 +84,9 @@ class _HotKeyRecorderWidgetState extends State<HotKeyRecorderWidget> {
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Press any key combination to set a new shortcut',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.7),
|
||||
),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7)),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
@@ -110,9 +96,7 @@ class _HotKeyRecorderWidgetState extends State<HotKeyRecorderWidget> {
|
||||
actions: [
|
||||
TextButton(onPressed: widget.onCancel, child: Text(t.common.cancel)),
|
||||
TextButton(
|
||||
onPressed: _recordedHotKey != null
|
||||
? () => widget.onHotKeyRecorded(_recordedHotKey!)
|
||||
: null,
|
||||
onPressed: _recordedHotKey != null ? () => widget.onHotKeyRecorded(_recordedHotKey!) : null,
|
||||
child: Text(t.common.save),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -10,11 +10,7 @@ class MergedLicenseEntry {
|
||||
final List<LicenseEntry> licenseEntries;
|
||||
final Set<String> allPackageNames;
|
||||
|
||||
MergedLicenseEntry({
|
||||
required this.packageName,
|
||||
required this.licenseEntries,
|
||||
required this.allPackageNames,
|
||||
});
|
||||
MergedLicenseEntry({required this.packageName, required this.licenseEntries, required this.allPackageNames});
|
||||
}
|
||||
|
||||
class LicensesScreen extends StatefulWidget {
|
||||
@@ -72,11 +68,7 @@ class _LicensesScreenState extends State<LicensesScreen> {
|
||||
if (_isLoading) {
|
||||
return FocusedScrollScaffold(
|
||||
title: Text(t.screens.licenses),
|
||||
slivers: const [
|
||||
SliverFillRemaining(
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
],
|
||||
slivers: const [SliverFillRemaining(child: Center(child: CircularProgressIndicator()))],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -95,21 +87,12 @@ class _LicensesScreenState extends State<LicensesScreen> {
|
||||
child: ListTile(
|
||||
title: Text(
|
||||
packageName,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: mergedLicense.licenseEntries.length > 1
|
||||
? Text(
|
||||
t.licenses.licensesCount(
|
||||
count: mergedLicense.licenseEntries.length,
|
||||
),
|
||||
)
|
||||
? Text(t.licenses.licensesCount(count: mergedLicense.licenseEntries.length))
|
||||
: null,
|
||||
trailing: const AppIcon(
|
||||
Symbols.chevron_right_rounded,
|
||||
fill: 1,
|
||||
),
|
||||
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
|
||||
onTap: () => _showLicenseDetail(mergedLicense),
|
||||
),
|
||||
);
|
||||
@@ -123,10 +106,7 @@ class _LicensesScreenState extends State<LicensesScreen> {
|
||||
void _showLicenseDetail(MergedLicenseEntry mergedLicense) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) =>
|
||||
_LicenseDetailScreen(mergedLicense: mergedLicense),
|
||||
),
|
||||
MaterialPageRoute(builder: (context) => _LicenseDetailScreen(mergedLicense: mergedLicense)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -158,20 +138,15 @@ class _LicenseDetailScreen extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
t.licenses.relatedPackages,
|
||||
style: Theme.of(context).textTheme.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
mergedLicense.allPackageNames.join(', '),
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
Text(mergedLicense.allPackageNames.join(', '), style: Theme.of(context).textTheme.bodyMedium),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (mergedLicense.allPackageNames.length > 1)
|
||||
const SizedBox(height: 16),
|
||||
if (mergedLicense.allPackageNames.length > 1) const SizedBox(height: 16),
|
||||
|
||||
// License cards
|
||||
...licenseEntries.asMap().entries.map((entry) {
|
||||
@@ -188,11 +163,8 @@ class _LicenseDetailScreen extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
isMultipleLicenses
|
||||
? t.licenses.licenseNumber(number: index + 1)
|
||||
: t.licenses.license,
|
||||
style: Theme.of(context).textTheme.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
isMultipleLicenses ? t.licenses.licenseNumber(number: index + 1) : t.licenses.license,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
...license.paragraphs.map((paragraph) {
|
||||
@@ -200,12 +172,7 @@ class _LicenseDetailScreen extends StatelessWidget {
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: SelectableText(
|
||||
paragraph.text,
|
||||
style: TextStyle(
|
||||
fontFamily: paragraph.indent > 0
|
||||
? 'monospace'
|
||||
: null,
|
||||
fontSize: 14,
|
||||
),
|
||||
style: TextStyle(fontFamily: paragraph.indent > 0 ? 'monospace' : null, fontSize: 14),
|
||||
),
|
||||
);
|
||||
}),
|
||||
@@ -213,8 +180,7 @@ class _LicenseDetailScreen extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (index < licenseEntries.length - 1)
|
||||
const SizedBox(height: 16),
|
||||
if (index < licenseEntries.length - 1) const SizedBox(height: 16),
|
||||
],
|
||||
);
|
||||
}),
|
||||
|
||||
@@ -42,9 +42,7 @@ class _LogsScreenState extends State<LogsScreen> {
|
||||
MemoryLogOutput.clearLogs();
|
||||
_logs = [];
|
||||
});
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(t.messages.logsCleared)));
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t.messages.logsCleared)));
|
||||
}
|
||||
|
||||
void _copyAllLogs() {
|
||||
@@ -56,9 +54,7 @@ class _LogsScreenState extends State<LogsScreen> {
|
||||
}
|
||||
isFirst = false;
|
||||
|
||||
buffer.write(
|
||||
'[${_formatTime(log.timestamp)}] [${log.level.name.toUpperCase()}] ${log.message}',
|
||||
);
|
||||
buffer.write('[${_formatTime(log.timestamp)}] [${log.level.name.toUpperCase()}] ${log.message}');
|
||||
if (log.error != null) {
|
||||
buffer.write('\nError: ${log.error}');
|
||||
}
|
||||
@@ -67,9 +63,7 @@ class _LogsScreenState extends State<LogsScreen> {
|
||||
}
|
||||
}
|
||||
Clipboard.setData(ClipboardData(text: buffer.toString()));
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(t.messages.logsCopied)));
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t.messages.logsCopied)));
|
||||
}
|
||||
|
||||
Color _getLevelColor(Level level) {
|
||||
@@ -133,9 +127,7 @@ class _LogsScreenState extends State<LogsScreen> {
|
||||
],
|
||||
),
|
||||
if (_logs.isEmpty)
|
||||
SliverFillRemaining(
|
||||
child: Center(child: Text(t.messages.noLogsAvailable)),
|
||||
)
|
||||
SliverFillRemaining(child: Center(child: Text(t.messages.noLogsAvailable)))
|
||||
else
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
@@ -163,12 +155,7 @@ class _LogEntryCard extends StatefulWidget {
|
||||
final Color levelColor;
|
||||
final IconData levelIcon;
|
||||
|
||||
const _LogEntryCard({
|
||||
required this.log,
|
||||
required this.formatTime,
|
||||
required this.levelColor,
|
||||
required this.levelIcon,
|
||||
});
|
||||
const _LogEntryCard({required this.log, required this.formatTime, required this.levelColor, required this.levelIcon});
|
||||
|
||||
@override
|
||||
State<_LogEntryCard> createState() => _LogEntryCardState();
|
||||
@@ -179,15 +166,12 @@ class _LogEntryCardState extends State<_LogEntryCard> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasErrorOrStackTrace =
|
||||
widget.log.error != null || widget.log.stackTrace != null;
|
||||
final hasErrorOrStackTrace = widget.log.error != null || widget.log.stackTrace != null;
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 0),
|
||||
child: InkWell(
|
||||
onTap: hasErrorOrStackTrace
|
||||
? () => setState(() => _isExpanded = !_isExpanded)
|
||||
: null,
|
||||
onTap: hasErrorOrStackTrace ? () => setState(() => _isExpanded = !_isExpanded) : null,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
@@ -196,12 +180,7 @@ class _LogEntryCardState extends State<_LogEntryCard> {
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AppIcon(
|
||||
widget.levelIcon,
|
||||
fill: 1,
|
||||
color: widget.levelColor,
|
||||
size: 20,
|
||||
),
|
||||
AppIcon(widget.levelIcon, fill: 1, color: widget.levelColor, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
@@ -211,43 +190,27 @@ class _LogEntryCardState extends State<_LogEntryCard> {
|
||||
children: [
|
||||
Text(
|
||||
widget.log.level.name.toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: widget.levelColor,
|
||||
fontSize: 12,
|
||||
),
|
||||
style: TextStyle(fontWeight: FontWeight.bold, color: widget.levelColor, fontSize: 12),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
widget.formatTime(widget.log.timestamp),
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.color
|
||||
?.withValues(alpha: 0.6),
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).textTheme.bodySmall?.color?.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
widget.log.message,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
Text(widget.log.message, style: Theme.of(context).textTheme.bodyMedium),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (hasErrorOrStackTrace)
|
||||
AppIcon(
|
||||
_isExpanded
|
||||
? Symbols.expand_less_rounded
|
||||
: Symbols.expand_more_rounded,
|
||||
_isExpanded ? Symbols.expand_less_rounded : Symbols.expand_more_rounded,
|
||||
fill: 1,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).iconTheme.color?.withValues(alpha: 0.6),
|
||||
color: Theme.of(context).iconTheme.color?.withValues(alpha: 0.6),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -256,16 +219,10 @@ class _LogEntryCardState extends State<_LogEntryCard> {
|
||||
const Divider(),
|
||||
const SizedBox(height: 8),
|
||||
if (widget.log.error != null)
|
||||
_buildDetailSection(
|
||||
title: t.logs.error,
|
||||
content: widget.log.error.toString(),
|
||||
),
|
||||
_buildDetailSection(title: t.logs.error, content: widget.log.error.toString()),
|
||||
if (widget.log.stackTrace != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
_buildDetailSection(
|
||||
title: t.logs.stackTrace,
|
||||
content: widget.log.stackTrace.toString(),
|
||||
),
|
||||
_buildDetailSection(title: t.logs.stackTrace, content: widget.log.stackTrace.toString()),
|
||||
],
|
||||
],
|
||||
],
|
||||
@@ -281,25 +238,20 @@ class _LogEntryCardState extends State<_LogEntryCard> {
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: widget.levelColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleSmall?.copyWith(color: widget.levelColor, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).brightness == Brightness.dark
|
||||
? Colors.grey[900]
|
||||
: Colors.grey[200],
|
||||
color: Theme.of(context).brightness == Brightness.dark ? Colors.grey[900] : Colors.grey[200],
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: SelectableText(
|
||||
content,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(fontFamily: 'monospace'),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(fontFamily: 'monospace'),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -29,11 +29,7 @@ class _DialogOption<T> {
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
|
||||
const _DialogOption({
|
||||
required this.value,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
});
|
||||
const _DialogOption({required this.value, required this.title, this.subtitle});
|
||||
}
|
||||
|
||||
class SettingsScreen extends StatefulWidget {
|
||||
@@ -46,8 +42,7 @@ class SettingsScreen extends StatefulWidget {
|
||||
class _SettingsScreenState extends State<SettingsScreen> {
|
||||
late settings.SettingsService _settingsService;
|
||||
KeyboardShortcutsService? _keyboardService;
|
||||
late final bool _keyboardShortcutsSupported =
|
||||
KeyboardShortcutsService.isPlatformSupported();
|
||||
late final bool _keyboardShortcutsSupported = KeyboardShortcutsService.isPlatformSupported();
|
||||
bool _isLoading = true;
|
||||
|
||||
bool _enableDebugLogging = false;
|
||||
@@ -91,8 +86,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
_autoSkipCredits = _settingsService.getAutoSkipCredits();
|
||||
_autoSkipDelay = _settingsService.getAutoSkipDelay();
|
||||
_downloadOnWifiOnly = _settingsService.getDownloadOnWifiOnly();
|
||||
_videoPlayerNavigationEnabled = _settingsService
|
||||
.getVideoPlayerNavigationEnabled();
|
||||
_videoPlayerNavigationEnabled = _settingsService.getVideoPlayerNavigationEnabled();
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
@@ -117,16 +111,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
const SizedBox(height: 24),
|
||||
_buildDownloadsSection(),
|
||||
const SizedBox(height: 24),
|
||||
if (_keyboardShortcutsSupported) ...[
|
||||
_buildKeyboardShortcutsSection(),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
if (_keyboardShortcutsSupported) ...[_buildKeyboardShortcutsSection(), const SizedBox(height: 24)],
|
||||
_buildAdvancedSection(),
|
||||
const SizedBox(height: 24),
|
||||
if (UpdateService.isUpdateCheckEnabled) ...[
|
||||
_buildUpdateSection(),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
if (UpdateService.isUpdateCheckEnabled) ...[_buildUpdateSection(), const SizedBox(height: 24)],
|
||||
_buildAboutSection(),
|
||||
const SizedBox(height: 24),
|
||||
]),
|
||||
@@ -146,9 +134,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
t.settings.appearance,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
Consumer<ThemeProvider>(
|
||||
@@ -165,9 +151,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
ListTile(
|
||||
leading: const AppIcon(Symbols.language_rounded, fill: 1),
|
||||
title: Text(t.settings.language),
|
||||
subtitle: Text(
|
||||
_getLanguageDisplayName(LocaleSettings.currentLocale),
|
||||
),
|
||||
subtitle: Text(_getLanguageDisplayName(LocaleSettings.currentLocale)),
|
||||
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
|
||||
onTap: () => _showLanguageDialog(),
|
||||
),
|
||||
@@ -188,9 +172,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
leading: const AppIcon(Symbols.view_list_rounded, fill: 1),
|
||||
title: Text(t.settings.viewMode),
|
||||
subtitle: Text(
|
||||
settingsProvider.viewMode == settings.ViewMode.grid
|
||||
? t.settings.gridView
|
||||
: t.settings.listView,
|
||||
settingsProvider.viewMode == settings.ViewMode.grid ? t.settings.gridView : t.settings.listView,
|
||||
),
|
||||
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
|
||||
onTap: () => _showViewModeDialog(),
|
||||
@@ -213,10 +195,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
Consumer<SettingsProvider>(
|
||||
builder: (context, settingsProvider, child) {
|
||||
return SwitchListTile(
|
||||
secondary: const AppIcon(
|
||||
Symbols.featured_play_list_rounded,
|
||||
fill: 1,
|
||||
),
|
||||
secondary: const AppIcon(Symbols.featured_play_list_rounded, fill: 1),
|
||||
title: Text(t.settings.showHeroSection),
|
||||
subtitle: Text(t.settings.showHeroSectionDescription),
|
||||
value: settingsProvider.showHeroSection,
|
||||
@@ -240,9 +219,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
t.settings.videoPlayback,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
SwitchListTile(
|
||||
@@ -260,9 +237,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
ListTile(
|
||||
leading: const AppIcon(Symbols.memory_rounded, fill: 1),
|
||||
title: Text(t.settings.bufferSize),
|
||||
subtitle: Text(
|
||||
t.settings.bufferSizeMB(size: _bufferSize.toString()),
|
||||
),
|
||||
subtitle: Text(t.settings.bufferSizeMB(size: _bufferSize.toString())),
|
||||
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
|
||||
onTap: () => _showBufferSizeDialog(),
|
||||
),
|
||||
@@ -272,38 +247,27 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
subtitle: Text(t.settings.subtitleStylingDescription),
|
||||
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const SubtitleStylingScreen(),
|
||||
),
|
||||
);
|
||||
Navigator.push(context, MaterialPageRoute(builder: (context) => const SubtitleStylingScreen()));
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const AppIcon(Symbols.replay_10_rounded, fill: 1),
|
||||
title: Text(t.settings.smallSkipDuration),
|
||||
subtitle: Text(
|
||||
t.settings.secondsUnit(seconds: _seekTimeSmall.toString()),
|
||||
),
|
||||
subtitle: Text(t.settings.secondsUnit(seconds: _seekTimeSmall.toString())),
|
||||
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
|
||||
onTap: () => _showSeekTimeSmallDialog(),
|
||||
),
|
||||
ListTile(
|
||||
leading: const AppIcon(Symbols.replay_30_rounded, fill: 1),
|
||||
title: Text(t.settings.largeSkipDuration),
|
||||
subtitle: Text(
|
||||
t.settings.secondsUnit(seconds: _seekTimeLarge.toString()),
|
||||
),
|
||||
subtitle: Text(t.settings.secondsUnit(seconds: _seekTimeLarge.toString())),
|
||||
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
|
||||
onTap: () => _showSeekTimeLargeDialog(),
|
||||
),
|
||||
ListTile(
|
||||
leading: const AppIcon(Symbols.bedtime_rounded, fill: 1),
|
||||
title: Text(t.settings.defaultSleepTimer),
|
||||
subtitle: Text(
|
||||
t.settings.minutesUnit(minutes: _sleepTimerDuration.toString()),
|
||||
),
|
||||
subtitle: Text(t.settings.minutesUnit(minutes: _sleepTimerDuration.toString())),
|
||||
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
|
||||
onTap: () => _showSleepTimerDurationDialog(),
|
||||
),
|
||||
@@ -357,11 +321,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
ListTile(
|
||||
leading: const AppIcon(Symbols.timer_rounded, fill: 1),
|
||||
title: Text(t.settings.autoSkipDelay),
|
||||
subtitle: Text(
|
||||
t.settings.autoSkipDelayDescription(
|
||||
seconds: _autoSkipDelay.toString(),
|
||||
),
|
||||
),
|
||||
subtitle: Text(t.settings.autoSkipDelayDescription(seconds: _autoSkipDelay.toString())),
|
||||
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
|
||||
onTap: () => _showAutoSkipDelayDialog(),
|
||||
),
|
||||
@@ -382,9 +342,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
t.settings.downloads,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
// Download location picker - not available on iOS
|
||||
@@ -396,20 +354,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
|
||||
return ListTile(
|
||||
leading: const AppIcon(Symbols.folder_rounded, fill: 1),
|
||||
title: Text(
|
||||
isCustom
|
||||
? t.settings.downloadLocationCustom
|
||||
: t.settings.downloadLocationDefault,
|
||||
),
|
||||
subtitle: Text(
|
||||
currentPath,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: const AppIcon(
|
||||
Symbols.chevron_right_rounded,
|
||||
fill: 1,
|
||||
),
|
||||
title: Text(isCustom ? t.settings.downloadLocationCustom : t.settings.downloadLocationDefault),
|
||||
subtitle: Text(currentPath, maxLines: 2, overflow: TextOverflow.ellipsis),
|
||||
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
|
||||
onTap: () => _showDownloadLocationDialog(),
|
||||
);
|
||||
},
|
||||
@@ -463,10 +410,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
},
|
||||
child: Text(t.settings.resetToDefault),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
child: Text(t.common.cancel),
|
||||
),
|
||||
TextButton(onPressed: () => Navigator.pop(dialogContext), child: Text(t.common.cancel)),
|
||||
FilledButton(
|
||||
onPressed: () async {
|
||||
Navigator.pop(dialogContext);
|
||||
@@ -493,9 +437,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
}
|
||||
} else {
|
||||
// Use file_picker on desktop
|
||||
final result = await FilePicker.platform.getDirectoryPath(
|
||||
dialogTitle: t.settings.selectFolder,
|
||||
);
|
||||
final result = await FilePicker.platform.getDirectoryPath(dialogTitle: t.settings.selectFolder);
|
||||
selectedPath = result;
|
||||
}
|
||||
|
||||
@@ -503,8 +445,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
// Validate the path is writable (for non-SAF paths)
|
||||
if (pathType == 'file') {
|
||||
final dir = Directory(selectedPath);
|
||||
final isWritable = await DownloadStorageService.instance
|
||||
.isDirectoryWritable(dir);
|
||||
final isWritable = await DownloadStorageService.instance.isDirectoryWritable(dir);
|
||||
if (!isWritable) {
|
||||
if (mounted) {
|
||||
showErrorSnackBar(context, t.settings.downloadLocationInvalid);
|
||||
@@ -514,10 +455,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
}
|
||||
|
||||
// Save the setting
|
||||
await _settingsService.setCustomDownloadPath(
|
||||
selectedPath,
|
||||
type: pathType,
|
||||
);
|
||||
await _settingsService.setCustomDownloadPath(selectedPath, type: pathType);
|
||||
await DownloadStorageService.instance.refreshCustomPath();
|
||||
|
||||
if (mounted) {
|
||||
@@ -553,9 +491,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
t.settings.keyboardShortcuts,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
@@ -591,9 +527,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
t.settings.advanced,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
SwitchListTile(
|
||||
@@ -614,10 +548,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
subtitle: Text(t.settings.viewLogsDescription),
|
||||
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const LogsScreen()),
|
||||
);
|
||||
Navigator.push(context, MaterialPageRoute(builder: (context) => const LogsScreen()));
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
@@ -650,37 +581,21 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
t.settings.updates,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: AppIcon(
|
||||
hasUpdate
|
||||
? Symbols.system_update_rounded
|
||||
: Symbols.check_circle_rounded,
|
||||
hasUpdate ? Symbols.system_update_rounded : Symbols.check_circle_rounded,
|
||||
fill: 1,
|
||||
color: hasUpdate ? Colors.orange : null,
|
||||
),
|
||||
title: Text(
|
||||
hasUpdate
|
||||
? t.settings.updateAvailable
|
||||
: t.settings.checkForUpdates,
|
||||
),
|
||||
title: Text(hasUpdate ? t.settings.updateAvailable : t.settings.checkForUpdates),
|
||||
subtitle: hasUpdate
|
||||
? Text(
|
||||
t.update.versionAvailable(
|
||||
version: _updateInfo!['latestVersion'],
|
||||
),
|
||||
)
|
||||
? Text(t.update.versionAvailable(version: _updateInfo!['latestVersion']))
|
||||
: Text(t.update.checkFailed),
|
||||
trailing: _isCheckingForUpdate
|
||||
? const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
|
||||
onTap: _isCheckingForUpdate
|
||||
? null
|
||||
@@ -705,10 +620,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
subtitle: Text(t.settings.aboutDescription),
|
||||
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const AboutScreen()),
|
||||
);
|
||||
Navigator.push(context, MaterialPageRoute(builder: (context) => const AboutScreen()));
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -765,12 +677,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(t.common.cancel),
|
||||
),
|
||||
],
|
||||
actions: [TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel))],
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -789,9 +696,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
children: options.map((size) {
|
||||
return ListTile(
|
||||
leading: AppIcon(
|
||||
_bufferSize == size
|
||||
? Symbols.radio_button_checked_rounded
|
||||
: Symbols.radio_button_unchecked_rounded,
|
||||
_bufferSize == size ? Symbols.radio_button_checked_rounded : Symbols.radio_button_unchecked_rounded,
|
||||
fill: 1,
|
||||
),
|
||||
title: Text('${size}MB'),
|
||||
@@ -805,12 +710,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(t.common.cancel),
|
||||
),
|
||||
],
|
||||
actions: [TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel))],
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -852,11 +752,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
if (parsed == null) {
|
||||
errorText = t.settings.validationErrorEnterNumber;
|
||||
} else if (parsed < min || parsed > max) {
|
||||
errorText = t.settings.validationErrorDuration(
|
||||
min: min,
|
||||
max: max,
|
||||
unit: labelText.toLowerCase(),
|
||||
);
|
||||
errorText = t.settings.validationErrorDuration(min: min, max: max, unit: labelText.toLowerCase());
|
||||
} else {
|
||||
errorText = null;
|
||||
}
|
||||
@@ -864,10 +760,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
},
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
child: Text(t.common.cancel),
|
||||
),
|
||||
TextButton(onPressed: () => Navigator.pop(dialogContext), child: Text(t.common.cancel)),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
final parsed = int.tryParse(controller.text);
|
||||
@@ -959,10 +852,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) =>
|
||||
_KeyboardShortcutsScreen(keyboardService: _keyboardService!),
|
||||
),
|
||||
MaterialPageRoute(builder: (context) => _KeyboardShortcutsScreen(keyboardService: _keyboardService!)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -974,10 +864,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
title: Text(t.settings.clearCache),
|
||||
content: Text(t.settings.clearCacheDescription),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(t.common.cancel),
|
||||
),
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel)),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
final navigator = Navigator.of(context);
|
||||
@@ -985,9 +872,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
await _settingsService.clearCache();
|
||||
if (mounted) {
|
||||
navigator.pop();
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(t.settings.clearCacheSuccess)),
|
||||
);
|
||||
messenger.showSnackBar(SnackBar(content: Text(t.settings.clearCacheSuccess)));
|
||||
}
|
||||
},
|
||||
child: Text(t.common.clear),
|
||||
@@ -1006,10 +891,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
title: Text(t.settings.resetSettings),
|
||||
content: Text(t.settings.resetSettingsDescription),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(t.common.cancel),
|
||||
),
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel)),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
final navigator = Navigator.of(context);
|
||||
@@ -1018,9 +900,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
await _keyboardService?.resetToDefaults();
|
||||
if (mounted) {
|
||||
navigator.pop();
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(t.settings.resetSettingsSuccess)),
|
||||
);
|
||||
messenger.showSnackBar(SnackBar(content: Text(t.settings.resetSettingsSuccess)));
|
||||
// Reload settings
|
||||
_loadSettings();
|
||||
}
|
||||
@@ -1063,19 +943,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
return ListTile(
|
||||
title: Text(_getLanguageDisplayName(locale)),
|
||||
leading: AppIcon(
|
||||
isSelected
|
||||
? Symbols.radio_button_checked_rounded
|
||||
: Symbols.radio_button_unchecked_rounded,
|
||||
isSelected ? Symbols.radio_button_checked_rounded : Symbols.radio_button_unchecked_rounded,
|
||||
fill: 1,
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: null,
|
||||
color: isSelected ? Theme.of(context).colorScheme.primary : null,
|
||||
),
|
||||
tileColor: isSelected
|
||||
? Theme.of(
|
||||
context,
|
||||
).colorScheme.primaryContainer.withValues(alpha: 0.3)
|
||||
: null,
|
||||
tileColor: isSelected ? Theme.of(context).colorScheme.primaryContainer.withValues(alpha: 0.3) : null,
|
||||
onTap: () async {
|
||||
// Save the locale to settings
|
||||
await _settingsService.setAppLocale(locale);
|
||||
@@ -1096,12 +968,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(t.common.cancel),
|
||||
),
|
||||
],
|
||||
actions: [TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel))],
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -1155,25 +1022,18 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
t.update.versionAvailable(
|
||||
version: _updateInfo!['latestVersion'],
|
||||
),
|
||||
t.update.versionAvailable(version: _updateInfo!['latestVersion']),
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
t.update.currentVersion(
|
||||
version: _updateInfo!['currentVersion'],
|
||||
),
|
||||
t.update.currentVersion(version: _updateInfo!['currentVersion']),
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(t.common.close),
|
||||
),
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.close)),
|
||||
FilledButton(
|
||||
onPressed: () async {
|
||||
final url = Uri.parse(_updateInfo!['releaseUrl']);
|
||||
@@ -1217,8 +1077,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
fill: 1,
|
||||
),
|
||||
title: Text(option.title),
|
||||
subtitle:
|
||||
option.subtitle != null ? Text(option.subtitle!) : null,
|
||||
subtitle: option.subtitle != null ? Text(option.subtitle!) : null,
|
||||
onTap: () async {
|
||||
await onSelect(option.value, settingsProvider);
|
||||
if (context.mounted) Navigator.pop(context);
|
||||
@@ -1226,12 +1085,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(t.common.cancel),
|
||||
),
|
||||
],
|
||||
actions: [TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel))],
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -1291,8 +1145,7 @@ class _KeyboardShortcutsScreen extends StatefulWidget {
|
||||
const _KeyboardShortcutsScreen({required this.keyboardService});
|
||||
|
||||
@override
|
||||
State<_KeyboardShortcutsScreen> createState() =>
|
||||
_KeyboardShortcutsScreenState();
|
||||
State<_KeyboardShortcutsScreen> createState() => _KeyboardShortcutsScreenState();
|
||||
}
|
||||
|
||||
class _KeyboardShortcutsScreenState extends State<_KeyboardShortcutsScreen> {
|
||||
@@ -1332,9 +1185,7 @@ class _KeyboardShortcutsScreenState extends State<_KeyboardShortcutsScreen> {
|
||||
await widget.keyboardService.resetToDefaults();
|
||||
await _loadHotkeys();
|
||||
if (mounted) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(t.settings.shortcutsReset)),
|
||||
);
|
||||
messenger.showSnackBar(SnackBar(content: Text(t.settings.shortcutsReset)));
|
||||
}
|
||||
},
|
||||
child: Text(t.common.reset),
|
||||
@@ -1352,19 +1203,12 @@ class _KeyboardShortcutsScreenState extends State<_KeyboardShortcutsScreen> {
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: ListTile(
|
||||
title: Text(
|
||||
widget.keyboardService.getActionDisplayName(action),
|
||||
),
|
||||
title: Text(widget.keyboardService.getActionDisplayName(action)),
|
||||
subtitle: Text(action),
|
||||
trailing: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Theme.of(context).dividerColor,
|
||||
),
|
||||
border: Border.all(color: Theme.of(context).dividerColor),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
@@ -1395,18 +1239,14 @@ class _KeyboardShortcutsScreenState extends State<_KeyboardShortcutsScreen> {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
|
||||
// Check for conflicts
|
||||
final existingAction = widget.keyboardService.getActionForHotkey(
|
||||
newHotkey,
|
||||
);
|
||||
final existingAction = widget.keyboardService.getActionForHotkey(newHotkey);
|
||||
if (existingAction != null && existingAction != action) {
|
||||
navigator.pop();
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
t.settings.shortcutAlreadyAssigned(
|
||||
action: widget.keyboardService.getActionDisplayName(
|
||||
existingAction,
|
||||
),
|
||||
action: widget.keyboardService.getActionDisplayName(existingAction),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -1428,11 +1268,7 @@ class _KeyboardShortcutsScreenState extends State<_KeyboardShortcutsScreen> {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
t.settings.shortcutUpdated(
|
||||
action: widget.keyboardService.getActionDisplayName(
|
||||
action,
|
||||
),
|
||||
),
|
||||
t.settings.shortcutUpdated(action: widget.keyboardService.getActionDisplayName(action)),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -43,10 +43,7 @@ class _StylingSliderSection extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [Text(label), Text(formattedValue)],
|
||||
),
|
||||
Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [Text(label), Text(formattedValue)]),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
@@ -154,11 +151,7 @@ class _SubtitleStylingScreenState extends State<SubtitleStylingScreen> {
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
Future<void> _showColorPicker(
|
||||
String title,
|
||||
String currentColor,
|
||||
Function(String) onColorSelected,
|
||||
) async {
|
||||
Future<void> _showColorPicker(String title, String currentColor, Function(String) onColorSelected) async {
|
||||
Color initialColor = _hexToColor(currentColor);
|
||||
|
||||
final Color selectedColor = await showColorPickerDialog(
|
||||
@@ -182,11 +175,7 @@ class _SubtitleStylingScreenState extends State<SubtitleStylingScreen> {
|
||||
ColorPickerType.wheel: true,
|
||||
ColorPickerType.custom: false,
|
||||
},
|
||||
actionButtons: const ColorPickerActionButtons(
|
||||
okButton: true,
|
||||
closeButton: true,
|
||||
dialogActionButtons: false,
|
||||
),
|
||||
actionButtons: const ColorPickerActionButtons(okButton: true, closeButton: true, dialogActionButtons: false),
|
||||
);
|
||||
|
||||
final hexColor = _colorToHex(selectedColor);
|
||||
@@ -205,12 +194,7 @@ class _SubtitleStylingScreenState extends State<SubtitleStylingScreen> {
|
||||
CustomAppBar(title: Text(t.screens.subtitleStyling), pinned: true),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildListDelegate([
|
||||
_buildStylingCard(),
|
||||
const SizedBox(height: 24),
|
||||
]),
|
||||
),
|
||||
sliver: SliverList(delegate: SliverChildListDelegate([_buildStylingCard(), const SizedBox(height: 24)])),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -226,9 +210,7 @@ class _SubtitleStylingScreenState extends State<SubtitleStylingScreen> {
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
t.subtitlingStyling.stylingOptions,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
// Font Size Slider
|
||||
@@ -254,9 +236,7 @@ class _SubtitleStylingScreenState extends State<SubtitleStylingScreen> {
|
||||
currentColor: _textColor,
|
||||
hexToColor: _hexToColor,
|
||||
onTap: () {
|
||||
_showColorPicker(t.subtitlingStyling.textColor, _textColor, (
|
||||
color,
|
||||
) {
|
||||
_showColorPicker(t.subtitlingStyling.textColor, _textColor, (color) {
|
||||
setState(() {
|
||||
_textColor = color;
|
||||
});
|
||||
@@ -288,9 +268,7 @@ class _SubtitleStylingScreenState extends State<SubtitleStylingScreen> {
|
||||
currentColor: _borderColor,
|
||||
hexToColor: _hexToColor,
|
||||
onTap: () {
|
||||
_showColorPicker(t.subtitlingStyling.borderColor, _borderColor, (
|
||||
color,
|
||||
) {
|
||||
_showColorPicker(t.subtitlingStyling.borderColor, _borderColor, (color) {
|
||||
setState(() {
|
||||
_borderColor = color;
|
||||
});
|
||||
@@ -323,16 +301,12 @@ class _SubtitleStylingScreenState extends State<SubtitleStylingScreen> {
|
||||
currentColor: _backgroundColor,
|
||||
hexToColor: _hexToColor,
|
||||
onTap: () {
|
||||
_showColorPicker(
|
||||
t.subtitlingStyling.backgroundColor,
|
||||
_backgroundColor,
|
||||
(color) {
|
||||
setState(() {
|
||||
_backgroundColor = color;
|
||||
});
|
||||
_settingsService.setSubtitleBackgroundColor(color);
|
||||
},
|
||||
);
|
||||
_showColorPicker(t.subtitlingStyling.backgroundColor, _backgroundColor, (color) {
|
||||
setState(() {
|
||||
_backgroundColor = color;
|
||||
});
|
||||
_settingsService.setSubtitleBackgroundColor(color);
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
|
||||
@@ -60,8 +60,7 @@ class VideoPlayerScreen extends StatefulWidget {
|
||||
State<VideoPlayerScreen> createState() => VideoPlayerScreenState();
|
||||
}
|
||||
|
||||
class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
||||
with WidgetsBindingObserver {
|
||||
class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindingObserver {
|
||||
Player? player;
|
||||
bool _isPlayerInitialized = false;
|
||||
PlexMetadata? _nextEpisode;
|
||||
@@ -78,8 +77,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
||||
StreamSubscription<dynamic>? _mediaControlSubscription;
|
||||
StreamSubscription<bool>? _bufferingSubscription;
|
||||
StreamSubscription<Tracks>? _trackLoadingSubscription;
|
||||
bool _isReplacingWithVideo =
|
||||
false; // Flag to skip orientation restoration during video-to-video navigation
|
||||
bool _isReplacingWithVideo = false; // Flag to skip orientation restoration during video-to-video navigation
|
||||
bool _isDisposingForNavigation = false;
|
||||
|
||||
// App lifecycle state tracking
|
||||
@@ -89,17 +87,14 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
||||
MediaControlsManager? _mediaControlsManager;
|
||||
PlaybackProgressTracker? _progressTracker;
|
||||
VideoFilterManager? _videoFilterManager;
|
||||
final EpisodeNavigationService _episodeNavigation =
|
||||
EpisodeNavigationService();
|
||||
final EpisodeNavigationService _episodeNavigation = EpisodeNavigationService();
|
||||
|
||||
/// Get the correct PlexClient for this metadata's server
|
||||
PlexClient _getClientForMetadata(BuildContext context) {
|
||||
return context.getClientForServer(widget.metadata.serverId!);
|
||||
}
|
||||
|
||||
final ValueNotifier<bool> _isBuffering = ValueNotifier<bool>(
|
||||
false,
|
||||
); // Track if video is currently buffering
|
||||
final ValueNotifier<bool> _isBuffering = ValueNotifier<bool>(false); // Track if video is currently buffering
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -135,10 +130,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
||||
});
|
||||
} catch (e) {
|
||||
// Provider might not be available yet during initialization
|
||||
appLogger.d(
|
||||
'Deferred playback state update (provider not ready)',
|
||||
error: e,
|
||||
);
|
||||
appLogger.d('Deferred playback state update (provider not ready)', error: e);
|
||||
}
|
||||
|
||||
// Register app lifecycle observer
|
||||
@@ -192,9 +184,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
||||
OsMediaControls.clear();
|
||||
// Disable wakelock when app goes to background
|
||||
WakelockPlus.disable();
|
||||
appLogger.d(
|
||||
'Media controls cleared and wakelock disabled due to app being paused/backgrounded',
|
||||
);
|
||||
appLogger.d('Media controls cleared and wakelock disabled due to app being paused/backgrounded');
|
||||
break;
|
||||
case AppLifecycleState.resumed:
|
||||
// Restore media controls and wakelock when app is resumed
|
||||
@@ -208,9 +198,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
||||
_mediaControlsManager!.updateMetadata(
|
||||
metadata: widget.metadata,
|
||||
client: client,
|
||||
duration: widget.metadata.duration != null
|
||||
? Duration(milliseconds: widget.metadata.duration!)
|
||||
: null,
|
||||
duration: widget.metadata.duration != null ? Duration(milliseconds: widget.metadata.duration!) : null,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -222,9 +210,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
||||
}
|
||||
|
||||
_updateMediaControlsPlaybackState();
|
||||
appLogger.d(
|
||||
'Media controls restored and wakelock re-enabled on app resume',
|
||||
);
|
||||
appLogger.d('Media controls restored and wakelock re-enabled on app resume');
|
||||
}
|
||||
break;
|
||||
case AppLifecycleState.detached:
|
||||
@@ -263,50 +249,24 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
||||
final settingsService = await SettingsService.getInstance();
|
||||
final bufferSizeMB = settingsService.getBufferSize();
|
||||
final bufferSizeBytes = bufferSizeMB * 1024 * 1024;
|
||||
final enableHardwareDecoding = settingsService
|
||||
.getEnableHardwareDecoding();
|
||||
final enableHardwareDecoding = settingsService.getEnableHardwareDecoding();
|
||||
final debugLoggingEnabled = settingsService.getEnableDebugLogging();
|
||||
|
||||
// Create player
|
||||
player = Player();
|
||||
|
||||
await player!.setProperty('sub-ass', 'yes'); // Enable libass
|
||||
await player!.setProperty(
|
||||
'demuxer-max-bytes',
|
||||
bufferSizeBytes.toString(),
|
||||
);
|
||||
await player!.setProperty(
|
||||
'msg-level',
|
||||
debugLoggingEnabled ? 'all=debug' : 'all=error',
|
||||
);
|
||||
await player!.setProperty(
|
||||
'hwdec',
|
||||
_getHwdecValue(enableHardwareDecoding),
|
||||
);
|
||||
await player!.setProperty('demuxer-max-bytes', bufferSizeBytes.toString());
|
||||
await player!.setProperty('msg-level', debugLoggingEnabled ? 'all=debug' : 'all=error');
|
||||
await player!.setProperty('hwdec', _getHwdecValue(enableHardwareDecoding));
|
||||
|
||||
// Subtitle styling
|
||||
await player!.setProperty(
|
||||
'sub-font-size',
|
||||
settingsService.getSubtitleFontSize().toString(),
|
||||
);
|
||||
await player!.setProperty(
|
||||
'sub-color',
|
||||
settingsService.getSubtitleTextColor(),
|
||||
);
|
||||
await player!.setProperty(
|
||||
'sub-border-size',
|
||||
settingsService.getSubtitleBorderSize().toString(),
|
||||
);
|
||||
await player!.setProperty(
|
||||
'sub-border-color',
|
||||
settingsService.getSubtitleBorderColor(),
|
||||
);
|
||||
final bgOpacity =
|
||||
(settingsService.getSubtitleBackgroundOpacity() * 255 / 100).toInt();
|
||||
final bgColor = settingsService.getSubtitleBackgroundColor().replaceFirst(
|
||||
'#',
|
||||
'',
|
||||
);
|
||||
await player!.setProperty('sub-font-size', settingsService.getSubtitleFontSize().toString());
|
||||
await player!.setProperty('sub-color', settingsService.getSubtitleTextColor());
|
||||
await player!.setProperty('sub-border-size', settingsService.getSubtitleBorderSize().toString());
|
||||
await player!.setProperty('sub-border-color', settingsService.getSubtitleBorderColor());
|
||||
final bgOpacity = (settingsService.getSubtitleBackgroundOpacity() * 255 / 100).toInt();
|
||||
final bgColor = settingsService.getSubtitleBackgroundColor().replaceFirst('#', '');
|
||||
await player!.setProperty(
|
||||
'sub-back-color',
|
||||
'#${bgOpacity.toRadixString(16).padLeft(2, '0').toUpperCase()}$bgColor',
|
||||
@@ -377,14 +337,10 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
||||
}
|
||||
|
||||
// Listen to playback state changes
|
||||
_playingSubscription = player!.streams.playing.listen(
|
||||
_onPlayingStateChanged,
|
||||
);
|
||||
_playingSubscription = player!.streams.playing.listen(_onPlayingStateChanged);
|
||||
|
||||
// Listen to completion
|
||||
_completedSubscription = player!.streams.completed.listen(
|
||||
_onVideoCompleted,
|
||||
);
|
||||
_completedSubscription = player!.streams.completed.listen(_onVideoCompleted);
|
||||
|
||||
// Listen to MPV logs
|
||||
_logSubscription = player!.streams.log.listen(_onPlayerLog);
|
||||
@@ -416,14 +372,10 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
||||
}
|
||||
|
||||
/// Add external subtitle tracks to the player
|
||||
Future<void> _addExternalSubtitles(
|
||||
List<SubtitleTrack> externalSubtitles,
|
||||
) async {
|
||||
Future<void> _addExternalSubtitles(List<SubtitleTrack> externalSubtitles) async {
|
||||
if (player == null || externalSubtitles.isEmpty) return;
|
||||
|
||||
appLogger.d(
|
||||
'Adding ${externalSubtitles.length} external subtitle(s) to player',
|
||||
);
|
||||
appLogger.d('Adding ${externalSubtitles.length} external subtitle(s) to player');
|
||||
|
||||
// Wait for media to be ready
|
||||
await _waitForMediaReady();
|
||||
@@ -438,14 +390,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
||||
language: subtitleTrack.language,
|
||||
select: false, // Don't auto-select
|
||||
);
|
||||
appLogger.d(
|
||||
'Added external subtitle: ${subtitleTrack.title ?? subtitleTrack.uri}',
|
||||
);
|
||||
appLogger.d('Added external subtitle: ${subtitleTrack.title ?? subtitleTrack.uri}');
|
||||
} catch (e) {
|
||||
appLogger.w(
|
||||
'Failed to add external subtitle: ${subtitleTrack.title ?? subtitleTrack.uri}',
|
||||
error: e,
|
||||
);
|
||||
appLogger.w('Failed to add external subtitle: ${subtitleTrack.title ?? subtitleTrack.uri}', error: e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -486,11 +433,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
||||
_progressTracker!.startTracking();
|
||||
} else if (client != null) {
|
||||
// Online mode: send progress to server
|
||||
_progressTracker = PlaybackProgressTracker(
|
||||
client: client,
|
||||
metadata: widget.metadata,
|
||||
player: player!,
|
||||
);
|
||||
_progressTracker = PlaybackProgressTracker(client: client, metadata: widget.metadata, player: player!);
|
||||
_progressTracker!.startTracking();
|
||||
}
|
||||
|
||||
@@ -498,17 +441,13 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
||||
_mediaControlsManager = MediaControlsManager();
|
||||
|
||||
// Set up media control event handling
|
||||
_mediaControlSubscription = _mediaControlsManager!.controlEvents.listen((
|
||||
event,
|
||||
) {
|
||||
_mediaControlSubscription = _mediaControlsManager!.controlEvents.listen((event) {
|
||||
if (event is PlayEvent) {
|
||||
appLogger.d('Media control: Play event received');
|
||||
if (player != null) {
|
||||
player!.play();
|
||||
_wasPlayingBeforeInactive = false;
|
||||
appLogger.d(
|
||||
'Cleared _wasPlayingBeforeInactive due to manual play via media controls',
|
||||
);
|
||||
appLogger.d('Cleared _wasPlayingBeforeInactive due to manual play via media controls');
|
||||
_updateMediaControlsPlaybackState();
|
||||
}
|
||||
} else if (event is PauseEvent) {
|
||||
@@ -538,9 +477,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
||||
await _mediaControlsManager!.updateMetadata(
|
||||
metadata: widget.metadata,
|
||||
client: client,
|
||||
duration: widget.metadata.duration != null
|
||||
? Duration(milliseconds: widget.metadata.duration!)
|
||||
: null,
|
||||
duration: widget.metadata.duration != null ? Duration(milliseconds: widget.metadata.duration!) : null,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
@@ -591,9 +528,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
||||
// For episodes, grandparentRatingKey points to the show
|
||||
final showRatingKey = widget.metadata.grandparentRatingKey;
|
||||
if (showRatingKey == null) {
|
||||
appLogger.d(
|
||||
'Episode missing grandparentRatingKey, skipping play queue creation',
|
||||
);
|
||||
appLogger.d('Episode missing grandparentRatingKey, skipping play queue creation');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -617,9 +552,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
||||
startingEpisodeKey: widget.metadata.ratingKey,
|
||||
);
|
||||
|
||||
if (playQueue != null &&
|
||||
playQueue.items != null &&
|
||||
playQueue.items!.isNotEmpty) {
|
||||
if (playQueue != null && playQueue.items != null && playQueue.items!.isNotEmpty) {
|
||||
// Initialize playback state with the play queue
|
||||
await playbackState.setPlaybackFromPlayQueue(
|
||||
playQueue,
|
||||
@@ -631,16 +564,11 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
||||
// Set the client for loading more items
|
||||
playbackState.setClient(client);
|
||||
|
||||
appLogger.d(
|
||||
'Sequential play queue created with ${playQueue.items!.length} items',
|
||||
);
|
||||
appLogger.d('Sequential play queue created with ${playQueue.items!.length} items');
|
||||
}
|
||||
} catch (e) {
|
||||
// Non-critical: Sequential playback will fall back to non-queue navigation
|
||||
appLogger.d(
|
||||
'Could not create play queue for sequential playback',
|
||||
error: e,
|
||||
);
|
||||
appLogger.d('Could not create play queue for sequential playback', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -685,10 +613,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
||||
} else {
|
||||
// Online mode: use server-specific client
|
||||
final client = _getClientForMetadata(context);
|
||||
final playbackService = PlaybackInitializationService(
|
||||
client: client,
|
||||
database: PlexApiCache.instance.database,
|
||||
);
|
||||
final playbackService = PlaybackInitializationService(client: client, database: PlexApiCache.instance.database);
|
||||
result = await playbackService.getPlaybackData(
|
||||
metadata: widget.metadata,
|
||||
selectedMediaIndex: widget.selectedMediaIndex,
|
||||
@@ -744,9 +669,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
||||
});
|
||||
} on PlaybackException catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(e.message)));
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.message)));
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
@@ -760,12 +683,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
||||
final downloadProvider = context.read<DownloadProvider>();
|
||||
|
||||
// Debug: log metadata info
|
||||
appLogger.d(
|
||||
'Offline playback - serverId: ${widget.metadata.serverId}, ratingKey: ${widget.metadata.ratingKey}',
|
||||
);
|
||||
appLogger.d('Offline playback - serverId: ${widget.metadata.serverId}, ratingKey: ${widget.metadata.ratingKey}');
|
||||
|
||||
final globalKey =
|
||||
'${widget.metadata.serverId}:${widget.metadata.ratingKey}';
|
||||
final globalKey = '${widget.metadata.serverId}:${widget.metadata.ratingKey}';
|
||||
appLogger.d('Looking up video with globalKey: $globalKey');
|
||||
|
||||
final videoPath = await downloadProvider.getVideoFilePath(globalKey);
|
||||
@@ -840,10 +760,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
||||
try {
|
||||
if (_isPhone) {
|
||||
// Phone: portrait only
|
||||
SystemChrome.setPreferredOrientations([
|
||||
DeviceOrientation.portraitUp,
|
||||
DeviceOrientation.portraitDown,
|
||||
]);
|
||||
SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp, DeviceOrientation.portraitDown]);
|
||||
} else {
|
||||
// Tablet/Desktop: all orientations
|
||||
SystemChrome.setPreferredOrientations([
|
||||
@@ -979,16 +896,13 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
||||
PlexAudioTrack? matched;
|
||||
final normalizedTrackLang = _iso6391ToPlex6392(track.language);
|
||||
|
||||
appLogger.d(
|
||||
'Normalized media_kit language: ${track.language} -> $normalizedTrackLang',
|
||||
);
|
||||
appLogger.d('Normalized media_kit language: ${track.language} -> $normalizedTrackLang');
|
||||
|
||||
for (final plexTrack in _currentMediaInfo!.audioTracks) {
|
||||
final matchLang = plexTrack.languageCode == normalizedTrackLang;
|
||||
final matchTitle = (track.title == null || track.title!.isEmpty)
|
||||
? true
|
||||
: (plexTrack.displayTitle == track.title ||
|
||||
plexTrack.title == track.title);
|
||||
: (plexTrack.displayTitle == track.title || plexTrack.title == track.title);
|
||||
|
||||
if (matchLang && matchTitle) {
|
||||
matched = plexTrack;
|
||||
@@ -1006,16 +920,11 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
||||
try {
|
||||
final trackIndex = int.parse(track.id);
|
||||
|
||||
if (trackIndex >= 0 &&
|
||||
trackIndex < _currentMediaInfo!.audioTracks.length) {
|
||||
if (trackIndex >= 0 && trackIndex < _currentMediaInfo!.audioTracks.length) {
|
||||
streamID = _currentMediaInfo!.audioTracks[trackIndex].id;
|
||||
appLogger.d(
|
||||
'Using fallback: audio index $trackIndex -> streamID $streamID',
|
||||
);
|
||||
appLogger.d('Using fallback: audio index $trackIndex -> streamID $streamID');
|
||||
} else {
|
||||
appLogger.e(
|
||||
'Fallback index $trackIndex out of bounds (total: ${_currentMediaInfo!.audioTracks.length})',
|
||||
);
|
||||
appLogger.e('Fallback index $trackIndex out of bounds (total: ${_currentMediaInfo!.audioTracks.length})');
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to parse track index', error: e);
|
||||
@@ -1035,18 +944,11 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
||||
|
||||
// 1. Language preference (series/movie level)
|
||||
if (languageCode != null && languageCode.isNotEmpty) {
|
||||
futures.add(
|
||||
client.setMetadataPreferences(
|
||||
languagePrefRatingKey,
|
||||
audioLanguage: languageCode,
|
||||
),
|
||||
);
|
||||
futures.add(client.setMetadataPreferences(languagePrefRatingKey, audioLanguage: languageCode));
|
||||
}
|
||||
// 2. Exact stream selection (part level)
|
||||
if (streamID != null) {
|
||||
futures.add(
|
||||
client.selectStreams(partId, audioStreamID: streamID, allParts: true),
|
||||
);
|
||||
futures.add(client.selectStreams(partId, audioStreamID: streamID, allParts: true));
|
||||
}
|
||||
|
||||
await Future.wait(futures);
|
||||
@@ -1090,21 +992,16 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
||||
PlexSubtitleTrack? matched;
|
||||
final normalizedTrackLang = _iso6391ToPlex6392(track.language);
|
||||
|
||||
appLogger.d(
|
||||
'Normalized media_kit language: ${track.language} -> $normalizedTrackLang',
|
||||
);
|
||||
appLogger.d('Normalized media_kit language: ${track.language} -> $normalizedTrackLang');
|
||||
|
||||
for (final plexTrack in _currentMediaInfo!.subtitleTracks) {
|
||||
final matchLang = plexTrack.languageCode == normalizedTrackLang;
|
||||
final matchTitle = (track.title == null || track.title!.isEmpty)
|
||||
? true
|
||||
: (plexTrack.displayTitle == track.title ||
|
||||
plexTrack.title == track.title);
|
||||
: (plexTrack.displayTitle == track.title || plexTrack.title == track.title);
|
||||
|
||||
appLogger.d('Comparing with streamID ${plexTrack.id}:');
|
||||
appLogger.d(
|
||||
' matchLang: $matchLang (${plexTrack.languageCode} == $normalizedTrackLang)',
|
||||
);
|
||||
appLogger.d(' matchLang: $matchLang (${plexTrack.languageCode} == $normalizedTrackLang)');
|
||||
appLogger.d(' matchTitle: $matchTitle');
|
||||
|
||||
if (matchLang && matchTitle) {
|
||||
@@ -1127,16 +1024,11 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
||||
// We need to subtract 1 to get the actual index in PlexMediaInfo
|
||||
final plexIndex = trackIndex > 0 ? trackIndex - 1 : 0;
|
||||
|
||||
if (plexIndex >= 0 &&
|
||||
plexIndex < _currentMediaInfo!.subtitleTracks.length) {
|
||||
if (plexIndex >= 0 && plexIndex < _currentMediaInfo!.subtitleTracks.length) {
|
||||
streamID = _currentMediaInfo!.subtitleTracks[plexIndex].id;
|
||||
appLogger.d(
|
||||
'Using fallback: media_kit index $trackIndex -> Plex index $plexIndex -> streamID $streamID',
|
||||
);
|
||||
appLogger.d('Using fallback: media_kit index $trackIndex -> Plex index $plexIndex -> streamID $streamID');
|
||||
} else {
|
||||
appLogger.e(
|
||||
'Fallback index $plexIndex out of bounds (total: ${_currentMediaInfo!.subtitleTracks.length})',
|
||||
);
|
||||
appLogger.e('Fallback index $plexIndex out of bounds (total: ${_currentMediaInfo!.subtitleTracks.length})');
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to parse track index', error: e);
|
||||
@@ -1162,28 +1054,15 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
||||
|
||||
// 1. Save language preference at series/movie level
|
||||
if (languageCode != null) {
|
||||
futures.add(
|
||||
client.setMetadataPreferences(
|
||||
languagePrefRatingKey,
|
||||
subtitleLanguage: languageCode,
|
||||
),
|
||||
);
|
||||
futures.add(client.setMetadataPreferences(languagePrefRatingKey, subtitleLanguage: languageCode));
|
||||
}
|
||||
// 2. Save exact stream selection using part ID
|
||||
if (streamID != null) {
|
||||
futures.add(
|
||||
client.selectStreams(
|
||||
partId,
|
||||
subtitleStreamID: streamID,
|
||||
allParts: true,
|
||||
),
|
||||
);
|
||||
futures.add(client.selectStreams(partId, subtitleStreamID: streamID, allParts: true));
|
||||
}
|
||||
|
||||
await Future.wait(futures);
|
||||
appLogger.d(
|
||||
'Successfully saved subtitle preferences (language + stream)',
|
||||
);
|
||||
appLogger.d('Successfully saved subtitle preferences (language + stream)');
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to save subtitle preferences', error: e);
|
||||
}
|
||||
@@ -1202,11 +1081,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
||||
// If player isn't available, navigate without preserving settings
|
||||
if (player == null) {
|
||||
if (mounted) {
|
||||
navigateToVideoPlayer(
|
||||
context,
|
||||
metadata: episodeMetadata,
|
||||
usePushReplacement: true,
|
||||
);
|
||||
navigateToVideoPlayer(context, metadata: episodeMetadata, usePushReplacement: true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1216,11 +1091,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
||||
if (currentPlayer == null) {
|
||||
// Player already disposed, navigate without preserving settings
|
||||
if (mounted) {
|
||||
navigateToVideoPlayer(
|
||||
context,
|
||||
metadata: episodeMetadata,
|
||||
usePushReplacement: true,
|
||||
);
|
||||
navigateToVideoPlayer(context, metadata: episodeMetadata, usePushReplacement: true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1281,8 +1152,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
||||
final isMobile = PlatformDetector.isMobile(context);
|
||||
|
||||
return PopScope(
|
||||
canPop:
|
||||
false, // Disable swipe-back gesture to prevent interference with timeline scrubbing
|
||||
canPop: false, // Disable swipe-back gesture to prevent interference with timeline scrubbing
|
||||
onPopInvokedWithResult: (didPop, result) {
|
||||
// Allow programmatic back navigation from UI controls
|
||||
if (!didPop) {
|
||||
@@ -1293,8 +1163,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
||||
// Use transparent background on macOS when native video layer is active
|
||||
backgroundColor: Colors.transparent,
|
||||
body: GestureDetector(
|
||||
behavior: HitTestBehavior
|
||||
.translucent, // Allow taps to pass through to controls
|
||||
behavior: HitTestBehavior.translucent, // Allow taps to pass through to controls
|
||||
onScaleStart: (details) {
|
||||
// Initialize pinch gesture tracking (mobile only)
|
||||
if (!isMobile) return;
|
||||
@@ -1312,8 +1181,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
||||
onScaleEnd: (details) {
|
||||
// Only toggle if we detected a pinch gesture on mobile
|
||||
if (!isMobile) return;
|
||||
if (_videoFilterManager != null &&
|
||||
_videoFilterManager!.isPinching) {
|
||||
if (_videoFilterManager != null && _videoFilterManager!.isPinching) {
|
||||
_toggleContainCover();
|
||||
_videoFilterManager!.isPinching = false;
|
||||
}
|
||||
@@ -1325,10 +1193,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// Update player size when layout changes
|
||||
final newSize = Size(
|
||||
constraints.maxWidth,
|
||||
constraints.maxHeight,
|
||||
);
|
||||
final newSize = Size(constraints.maxWidth, constraints.maxHeight);
|
||||
|
||||
// Update player size in video filter manager and native layer
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
@@ -1345,9 +1210,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
||||
player!,
|
||||
widget.metadata,
|
||||
onNext: _nextEpisode != null ? _playNext : null,
|
||||
onPrevious: _previousEpisode != null
|
||||
? _playPrevious
|
||||
: null,
|
||||
onPrevious: _previousEpisode != null ? _playPrevious : null,
|
||||
availableVersions: _availableVersions,
|
||||
selectedMediaIndex: widget.selectedMediaIndex,
|
||||
boxFitMode: _videoFilterManager?.boxFitMode ?? 0,
|
||||
@@ -1368,43 +1231,25 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 32),
|
||||
padding: const EdgeInsets.all(32),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[900],
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
decoration: BoxDecoration(color: Colors.grey[900], borderRadius: BorderRadius.circular(16)),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const AppIcon(
|
||||
Symbols.play_circle_rounded,
|
||||
fill: 1,
|
||||
size: 64,
|
||||
color: Colors.white,
|
||||
),
|
||||
const AppIcon(Symbols.play_circle_rounded, fill: 1, size: 64, color: Colors.white),
|
||||
const SizedBox(height: 24),
|
||||
Consumer<PlaybackStateProvider>(
|
||||
builder: (context, playbackState, child) {
|
||||
final isShuffleActive =
|
||||
playbackState.isShuffleActive;
|
||||
final isShuffleActive = playbackState.isShuffleActive;
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
'Up Next',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
style: TextStyle(color: Colors.white, fontSize: 24, fontWeight: FontWeight.bold),
|
||||
),
|
||||
if (isShuffleActive) ...[
|
||||
const SizedBox(width: 8),
|
||||
const AppIcon(
|
||||
Symbols.shuffle_rounded,
|
||||
fill: 1,
|
||||
size: 20,
|
||||
color: Colors.white70,
|
||||
),
|
||||
const AppIcon(Symbols.shuffle_rounded, fill: 1, size: 20, color: Colors.white70),
|
||||
],
|
||||
],
|
||||
);
|
||||
@@ -1412,23 +1257,15 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_nextEpisode!.grandparentTitle ??
|
||||
_nextEpisode!.title,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
),
|
||||
_nextEpisode!.grandparentTitle ?? _nextEpisode!.title,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 18),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (_nextEpisode!.parentIndex != null &&
|
||||
_nextEpisode!.index != null)
|
||||
if (_nextEpisode!.parentIndex != null && _nextEpisode!.index != null)
|
||||
Text(
|
||||
'S${_nextEpisode!.parentIndex} · E${_nextEpisode!.index} · ${_nextEpisode!.title}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 16,
|
||||
),
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 16),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
@@ -1444,10 +1281,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Colors.white,
|
||||
side: const BorderSide(color: Colors.white),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 32,
|
||||
vertical: 16,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
|
||||
),
|
||||
child: Text(t.dialog.cancel),
|
||||
),
|
||||
@@ -1457,10 +1291,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Colors.black,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 32,
|
||||
vertical: 16,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
|
||||
),
|
||||
child: Text(t.dialog.playNow),
|
||||
),
|
||||
@@ -1481,14 +1312,8 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
||||
child: Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.5),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const CircularProgressIndicator(
|
||||
color: Colors.white,
|
||||
strokeWidth: 3,
|
||||
),
|
||||
decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.5), shape: BoxShape.circle),
|
||||
child: const CircularProgressIndicator(color: Colors.white, strokeWidth: 3),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -23,9 +23,7 @@ abstract class BaseSharedPreferencesService {
|
||||
/// - Singleton instance management
|
||||
/// - SharedPreferences initialization
|
||||
/// - Calling onInit() hook for subclass-specific setup
|
||||
static Future<T> initializeInstance<T extends BaseSharedPreferencesService>(
|
||||
T Function() constructor,
|
||||
) async {
|
||||
static Future<T> initializeInstance<T extends BaseSharedPreferencesService>(T Function() constructor) async {
|
||||
if (_instances[T] == null) {
|
||||
final instance = constructor();
|
||||
_instances[T] = instance;
|
||||
|
||||
@@ -65,9 +65,7 @@ class DataAggregationService {
|
||||
});
|
||||
|
||||
// Apply limit if specified
|
||||
final result = limit != null && limit < allOnDeck.length
|
||||
? allOnDeck.sublist(0, limit)
|
||||
: allOnDeck;
|
||||
final result = limit != null && limit < allOnDeck.length ? allOnDeck.sublist(0, limit) : allOnDeck;
|
||||
|
||||
appLogger.i('Fetched ${result.length} on deck items from all servers');
|
||||
|
||||
@@ -76,9 +74,7 @@ class DataAggregationService {
|
||||
|
||||
/// Fetch libraries from all servers and cache them for hub fetching
|
||||
/// This allows libraries to be fetched in parallel with other operations
|
||||
Future<Map<String, List<PlexLibrary>>> getLibrariesFromAllServersGrouped({
|
||||
bool forceRefresh = false,
|
||||
}) async {
|
||||
Future<Map<String, List<PlexLibrary>>> getLibrariesFromAllServersGrouped({bool forceRefresh = false}) async {
|
||||
// Return cached libraries if still valid and not forcing refresh
|
||||
if (!forceRefresh && _isLibrariesCacheValid) {
|
||||
appLogger.d('Using cached libraries data');
|
||||
@@ -96,13 +92,8 @@ class DataAggregationService {
|
||||
_cachedLibrariesByServer = librariesByServer;
|
||||
_librariesCacheTime = DateTime.now();
|
||||
|
||||
final totalLibraries = librariesByServer.values.fold<int>(
|
||||
0,
|
||||
(sum, libs) => sum + libs.length,
|
||||
);
|
||||
appLogger.d(
|
||||
'Fetched $totalLibraries libraries from ${librariesByServer.length} servers',
|
||||
);
|
||||
final totalLibraries = librariesByServer.values.fold<int>(0, (sum, libs) => sum + libs.length);
|
||||
appLogger.d('Fetched $totalLibraries libraries from ${librariesByServer.length} servers');
|
||||
|
||||
return librariesByServer;
|
||||
}
|
||||
@@ -121,8 +112,7 @@ class DataAggregationService {
|
||||
}
|
||||
|
||||
// Use pre-fetched libraries or fetch them if not provided
|
||||
final libraries =
|
||||
librariesByServer ?? await getLibrariesFromAllServersGrouped();
|
||||
final libraries = librariesByServer ?? await getLibrariesFromAllServersGrouped();
|
||||
|
||||
appLogger.d('Fetching hubs from ${clients.length} servers');
|
||||
|
||||
@@ -150,8 +140,7 @@ class DataAggregationService {
|
||||
return false;
|
||||
}
|
||||
// Check app-level hidden libraries
|
||||
if (hiddenLibraryKeys != null &&
|
||||
hiddenLibraryKeys.contains(library.globalKey)) {
|
||||
if (hiddenLibraryKeys != null && hiddenLibraryKeys.contains(library.globalKey)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -162,14 +151,10 @@ class DataAggregationService {
|
||||
try {
|
||||
// Hubs are now tagged with server info at the source
|
||||
final hubs = await client.getLibraryHubs(library.key);
|
||||
appLogger.d(
|
||||
'Fetched ${hubs.length} hubs for ${library.title} on $serverId',
|
||||
);
|
||||
appLogger.d('Fetched ${hubs.length} hubs for ${library.title} on $serverId');
|
||||
return hubs;
|
||||
} catch (e) {
|
||||
appLogger.w(
|
||||
'Failed to fetch hubs for library ${library.title}: $e',
|
||||
);
|
||||
appLogger.w('Failed to fetch hubs for library ${library.title}: $e');
|
||||
return <PlexHub>[];
|
||||
}
|
||||
});
|
||||
@@ -184,11 +169,7 @@ class DataAggregationService {
|
||||
|
||||
return serverHubs;
|
||||
} catch (e, stackTrace) {
|
||||
appLogger.e(
|
||||
'Failed to fetch hubs from server $serverId',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
appLogger.e('Failed to fetch hubs from server $serverId', error: e, stackTrace: stackTrace);
|
||||
_serverManager.updateServerStatus(serverId, false);
|
||||
return <PlexHub>[];
|
||||
}
|
||||
@@ -202,9 +183,7 @@ class DataAggregationService {
|
||||
}
|
||||
|
||||
// Apply limit if specified
|
||||
final result = limit != null && limit < allHubs.length
|
||||
? allHubs.sublist(0, limit)
|
||||
: allHubs;
|
||||
final result = limit != null && limit < allHubs.length ? allHubs.sublist(0, limit) : allHubs;
|
||||
|
||||
appLogger.i('Fetched ${result.length} hubs from all servers');
|
||||
|
||||
@@ -213,10 +192,7 @@ class DataAggregationService {
|
||||
|
||||
/// Search across all online servers
|
||||
/// Results are automatically tagged with server info by PlexClient
|
||||
Future<List<PlexMetadata>> searchAcrossServers(
|
||||
String query, {
|
||||
int? limit,
|
||||
}) async {
|
||||
Future<List<PlexMetadata>> searchAcrossServers(String query, {int? limit}) async {
|
||||
if (query.trim().isEmpty) {
|
||||
return [];
|
||||
}
|
||||
@@ -229,9 +205,7 @@ class DataAggregationService {
|
||||
);
|
||||
|
||||
// Apply limit if specified
|
||||
final result = limit != null && limit < allResults.length
|
||||
? allResults.sublist(0, limit)
|
||||
: allResults;
|
||||
final result = limit != null && limit < allResults.length ? allResults.sublist(0, limit) : allResults;
|
||||
|
||||
appLogger.i('Found ${result.length} search results across all servers');
|
||||
|
||||
@@ -251,20 +225,14 @@ class DataAggregationService {
|
||||
// Libraries are automatically tagged with server info by PlexClient
|
||||
return await client.getLibraries();
|
||||
} catch (e, stackTrace) {
|
||||
appLogger.e(
|
||||
'Failed to fetch libraries for server $serverId',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
appLogger.e('Failed to fetch libraries for server $serverId', error: e, stackTrace: stackTrace);
|
||||
_serverManager.updateServerStatus(serverId, false);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Group libraries by server
|
||||
Map<String, List<PlexLibrary>> groupLibrariesByServer(
|
||||
List<PlexLibrary> libraries,
|
||||
) {
|
||||
Map<String, List<PlexLibrary>> groupLibrariesByServer(List<PlexLibrary> libraries) {
|
||||
final grouped = <String, List<PlexLibrary>>{};
|
||||
|
||||
for (final library in libraries) {
|
||||
@@ -289,12 +257,7 @@ class DataAggregationService {
|
||||
/// [operation] is the async function to run per server, returning `List<T>`
|
||||
Future<List<T>> _perServer<T>({
|
||||
required String operationName,
|
||||
required Future<List<T>> Function(
|
||||
String serverId,
|
||||
PlexClient client,
|
||||
PlexServer? server,
|
||||
)
|
||||
operation,
|
||||
required Future<List<T>> Function(String serverId, PlexClient client, PlexServer? server) operation,
|
||||
}) async {
|
||||
final clients = _serverManager.onlineClients;
|
||||
|
||||
@@ -308,9 +271,7 @@ class DataAggregationService {
|
||||
final allResults = <T>[];
|
||||
|
||||
// Execute operation on all servers in parallel
|
||||
final Iterable<Future<List<T>>> futures = clients.entries.map((
|
||||
entry,
|
||||
) async {
|
||||
final Iterable<Future<List<T>>> futures = clients.entries.map((entry) async {
|
||||
final serverId = entry.key;
|
||||
final client = entry.value;
|
||||
final server = _serverManager.getServer(serverId);
|
||||
@@ -323,15 +284,9 @@ class DataAggregationService {
|
||||
);
|
||||
return result;
|
||||
} catch (e, stackTrace) {
|
||||
appLogger.e(
|
||||
'Failed $operationName from server $serverId',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
appLogger.e('Failed $operationName from server $serverId', error: e, stackTrace: stackTrace);
|
||||
_serverManager.updateServerStatus(serverId, false);
|
||||
appLogger.d(
|
||||
'$operationName for server $serverId failed after ${sw.elapsedMilliseconds}ms',
|
||||
);
|
||||
appLogger.d('$operationName for server $serverId failed after ${sw.elapsedMilliseconds}ms');
|
||||
return <T>[];
|
||||
}
|
||||
});
|
||||
@@ -356,12 +311,7 @@ class DataAggregationService {
|
||||
/// [operation] is the async function to run per server, returning `List<T>`
|
||||
Future<Map<String, List<T>>> _perServerGrouped<T>({
|
||||
required String operationName,
|
||||
required Future<List<T>> Function(
|
||||
String serverId,
|
||||
PlexClient client,
|
||||
PlexServer? server,
|
||||
)
|
||||
operation,
|
||||
required Future<List<T>> Function(String serverId, PlexClient client, PlexServer? server) operation,
|
||||
}) async {
|
||||
final clients = _serverManager.onlineClients;
|
||||
|
||||
@@ -386,15 +336,9 @@ class DataAggregationService {
|
||||
);
|
||||
return MapEntry(serverId, result);
|
||||
} catch (e, stackTrace) {
|
||||
appLogger.e(
|
||||
'Failed $operationName from server $serverId',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
appLogger.e('Failed $operationName from server $serverId', error: e, stackTrace: stackTrace);
|
||||
_serverManager.updateServerStatus(serverId, false);
|
||||
appLogger.d(
|
||||
'$operationName for server $serverId failed after ${sw.elapsedMilliseconds}ms',
|
||||
);
|
||||
appLogger.d('$operationName for server $serverId failed after ${sw.elapsedMilliseconds}ms');
|
||||
return MapEntry(serverId, <T>[]);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -66,20 +66,14 @@ extension DownloadDatabaseOperations on AppDatabase {
|
||||
/// Only returns items that are not paused
|
||||
Future<DownloadQueueItem?> getNextQueueItem() async {
|
||||
// Join with downloadedMedia to check status and filter out paused items
|
||||
final query = select(downloadQueue).join([
|
||||
innerJoin(
|
||||
downloadedMedia,
|
||||
downloadedMedia.globalKey.equalsExp(downloadQueue.mediaGlobalKey),
|
||||
),
|
||||
]);
|
||||
final query = select(
|
||||
downloadQueue,
|
||||
).join([innerJoin(downloadedMedia, downloadedMedia.globalKey.equalsExp(downloadQueue.mediaGlobalKey))]);
|
||||
|
||||
query
|
||||
..where(downloadedMedia.status.equals(DownloadStatus.paused.index).not())
|
||||
..orderBy([
|
||||
OrderingTerm(
|
||||
expression: downloadQueue.priority,
|
||||
mode: OrderingMode.desc,
|
||||
),
|
||||
OrderingTerm(expression: downloadQueue.priority, mode: OrderingMode.desc),
|
||||
OrderingTerm(expression: downloadQueue.addedAt),
|
||||
])
|
||||
..limit(1);
|
||||
@@ -90,20 +84,14 @@ extension DownloadDatabaseOperations on AppDatabase {
|
||||
|
||||
/// Update download status
|
||||
Future<void> updateDownloadStatus(String globalKey, int status) async {
|
||||
await (update(downloadedMedia)..where((t) => t.globalKey.equals(globalKey)))
|
||||
.write(DownloadedMediaCompanion(status: Value(status)));
|
||||
await (update(
|
||||
downloadedMedia,
|
||||
)..where((t) => t.globalKey.equals(globalKey))).write(DownloadedMediaCompanion(status: Value(status)));
|
||||
}
|
||||
|
||||
/// Update download progress
|
||||
Future<void> updateDownloadProgress(
|
||||
String globalKey,
|
||||
int progress,
|
||||
int downloadedBytes,
|
||||
int totalBytes,
|
||||
) async {
|
||||
await (update(
|
||||
downloadedMedia,
|
||||
)..where((t) => t.globalKey.equals(globalKey))).write(
|
||||
Future<void> updateDownloadProgress(String globalKey, int progress, int downloadedBytes, int totalBytes) async {
|
||||
await (update(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).write(
|
||||
DownloadedMediaCompanion(
|
||||
progress: Value(progress),
|
||||
downloadedBytes: Value(downloadedBytes),
|
||||
@@ -114,9 +102,7 @@ extension DownloadDatabaseOperations on AppDatabase {
|
||||
|
||||
/// Update video file path
|
||||
Future<void> updateVideoFilePath(String globalKey, String filePath) async {
|
||||
await (update(
|
||||
downloadedMedia,
|
||||
)..where((t) => t.globalKey.equals(globalKey))).write(
|
||||
await (update(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).write(
|
||||
DownloadedMediaCompanion(
|
||||
videoFilePath: Value(filePath),
|
||||
downloadedAt: Value(DateTime.now().millisecondsSinceEpoch),
|
||||
@@ -125,81 +111,54 @@ extension DownloadDatabaseOperations on AppDatabase {
|
||||
}
|
||||
|
||||
/// Update artwork paths
|
||||
Future<void> updateArtworkPaths({
|
||||
required String globalKey,
|
||||
String? thumbPath,
|
||||
}) async {
|
||||
await (update(downloadedMedia)..where((t) => t.globalKey.equals(globalKey)))
|
||||
.write(DownloadedMediaCompanion(thumbPath: Value(thumbPath)));
|
||||
Future<void> updateArtworkPaths({required String globalKey, String? thumbPath}) async {
|
||||
await (update(
|
||||
downloadedMedia,
|
||||
)..where((t) => t.globalKey.equals(globalKey))).write(DownloadedMediaCompanion(thumbPath: Value(thumbPath)));
|
||||
}
|
||||
|
||||
/// Update download error and increment retry count
|
||||
Future<void> updateDownloadError(
|
||||
String globalKey,
|
||||
String errorMessage,
|
||||
) async {
|
||||
Future<void> updateDownloadError(String globalKey, String errorMessage) async {
|
||||
// Get current retry count to increment it
|
||||
final existing = await getDownloadedMedia(globalKey);
|
||||
final currentCount = existing?.retryCount ?? 0;
|
||||
|
||||
await (update(
|
||||
downloadedMedia,
|
||||
)..where((t) => t.globalKey.equals(globalKey))).write(
|
||||
DownloadedMediaCompanion(
|
||||
errorMessage: Value(errorMessage),
|
||||
retryCount: Value(currentCount + 1),
|
||||
),
|
||||
await (update(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).write(
|
||||
DownloadedMediaCompanion(errorMessage: Value(errorMessage), retryCount: Value(currentCount + 1)),
|
||||
);
|
||||
}
|
||||
|
||||
/// Clear download error and reset retry count (for retry)
|
||||
Future<void> clearDownloadError(String globalKey) async {
|
||||
await (update(
|
||||
downloadedMedia,
|
||||
)..where((t) => t.globalKey.equals(globalKey))).write(
|
||||
const DownloadedMediaCompanion(
|
||||
errorMessage: Value(null),
|
||||
retryCount: Value(0),
|
||||
),
|
||||
await (update(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).write(
|
||||
const DownloadedMediaCompanion(errorMessage: Value(null), retryCount: Value(0)),
|
||||
);
|
||||
}
|
||||
|
||||
/// Remove item from queue
|
||||
Future<void> removeFromQueue(String mediaGlobalKey) async {
|
||||
await (delete(
|
||||
downloadQueue,
|
||||
)..where((t) => t.mediaGlobalKey.equals(mediaGlobalKey))).go();
|
||||
await (delete(downloadQueue)..where((t) => t.mediaGlobalKey.equals(mediaGlobalKey))).go();
|
||||
}
|
||||
|
||||
/// Get downloaded media item
|
||||
Future<DownloadedMediaItem?> getDownloadedMedia(String globalKey) async {
|
||||
return (select(
|
||||
downloadedMedia,
|
||||
)..where((t) => t.globalKey.equals(globalKey))).getSingleOrNull();
|
||||
return (select(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).getSingleOrNull();
|
||||
}
|
||||
|
||||
/// Delete a download
|
||||
Future<void> deleteDownload(String globalKey) async {
|
||||
await (delete(
|
||||
downloadedMedia,
|
||||
)..where((t) => t.globalKey.equals(globalKey))).go();
|
||||
await (delete(
|
||||
downloadQueue,
|
||||
)..where((t) => t.mediaGlobalKey.equals(globalKey))).go();
|
||||
await (delete(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).go();
|
||||
await (delete(downloadQueue)..where((t) => t.mediaGlobalKey.equals(globalKey))).go();
|
||||
}
|
||||
|
||||
/// Get all downloaded episodes for a season
|
||||
Future<List<DownloadedMediaItem>> getEpisodesBySeason(String seasonKey) {
|
||||
return (select(downloadedMedia)
|
||||
..where((t) => t.parentRatingKey.equals(seasonKey)))
|
||||
.get();
|
||||
return (select(downloadedMedia)..where((t) => t.parentRatingKey.equals(seasonKey))).get();
|
||||
}
|
||||
|
||||
/// Get all downloaded episodes for a show
|
||||
Future<List<DownloadedMediaItem>> getEpisodesByShow(String showKey) {
|
||||
return (select(downloadedMedia)
|
||||
..where((t) => t.grandparentRatingKey.equals(showKey)))
|
||||
.get();
|
||||
return (select(downloadedMedia)..where((t) => t.grandparentRatingKey.equals(showKey))).get();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,10 +173,8 @@ class DownloadManagerService {
|
||||
Stream<DownloadProgress> get progressStream => _progressController.stream;
|
||||
|
||||
// Stream controller for deletion progress updates
|
||||
final _deletionProgressController =
|
||||
StreamController<DeletionProgress>.broadcast();
|
||||
Stream<DeletionProgress> get deletionProgressStream =>
|
||||
_deletionProgressController.stream;
|
||||
final _deletionProgressController = StreamController<DeletionProgress>.broadcast();
|
||||
Stream<DeletionProgress> get deletionProgressStream => _deletionProgressController.stream;
|
||||
|
||||
// Active downloads with cancel tokens
|
||||
final Map<String, CancelToken> _activeDownloads = {};
|
||||
@@ -249,13 +206,10 @@ class DownloadManagerService {
|
||||
!connectivity.contains(ConnectivityResult.ethernet);
|
||||
}
|
||||
|
||||
DownloadManagerService({
|
||||
required AppDatabase database,
|
||||
required DownloadStorageService storageService,
|
||||
Dio? dio,
|
||||
}) : _database = database,
|
||||
_storageService = storageService,
|
||||
_dio = dio ?? Dio();
|
||||
DownloadManagerService({required AppDatabase database, required DownloadStorageService storageService, Dio? dio})
|
||||
: _database = database,
|
||||
_storageService = storageService,
|
||||
_dio = dio ?? Dio();
|
||||
|
||||
/// Delete a file if it exists and log the deletion
|
||||
/// Returns true if file was deleted, false otherwise
|
||||
@@ -281,11 +235,8 @@ class DownloadManagerService {
|
||||
// Check if already downloading or completed
|
||||
final existing = await _database.getDownloadedMedia(globalKey);
|
||||
if (existing != null &&
|
||||
(existing.status == DownloadStatus.downloading.index ||
|
||||
existing.status == DownloadStatus.completed.index)) {
|
||||
appLogger.i(
|
||||
'Download already exists for $globalKey with status ${existing.status}',
|
||||
);
|
||||
(existing.status == DownloadStatus.downloading.index || existing.status == DownloadStatus.completed.index)) {
|
||||
appLogger.i('Download already exists for $globalKey with status ${existing.status}');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -333,9 +284,7 @@ class DownloadManagerService {
|
||||
while (true) {
|
||||
// Check if we should pause due to cellular
|
||||
if (await _shouldBlockDownload()) {
|
||||
appLogger.i(
|
||||
'Pausing downloads - on cellular data with WiFi-only enabled',
|
||||
);
|
||||
appLogger.i('Pausing downloads - on cellular data with WiFi-only enabled');
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -356,12 +305,9 @@ class DownloadManagerService {
|
||||
/// Setup connectivity listener to auto-resume downloads when WiFi becomes available
|
||||
void _setupConnectivityListener() {
|
||||
_connectivitySubscription?.cancel();
|
||||
_connectivitySubscription = Connectivity().onConnectivityChanged.listen((
|
||||
results,
|
||||
) async {
|
||||
_connectivitySubscription = Connectivity().onConnectivityChanged.listen((results) async {
|
||||
// If WiFi becomes available, try to resume queue
|
||||
if (results.contains(ConnectivityResult.wifi) ||
|
||||
results.contains(ConnectivityResult.ethernet)) {
|
||||
if (results.contains(ConnectivityResult.wifi) || results.contains(ConnectivityResult.ethernet)) {
|
||||
final hasQueuedItems = await _database.getNextQueueItem() != null;
|
||||
if (hasQueuedItems && !_isProcessingQueue && _lastClient != null) {
|
||||
appLogger.i('WiFi available - resuming downloads');
|
||||
@@ -372,11 +318,7 @@ class DownloadManagerService {
|
||||
}
|
||||
|
||||
/// Start downloading a specific item
|
||||
Future<void> _startDownload(
|
||||
String globalKey,
|
||||
PlexClient client,
|
||||
DownloadQueueItem queueItem,
|
||||
) async {
|
||||
Future<void> _startDownload(String globalKey, PlexClient client, DownloadQueueItem queueItem) async {
|
||||
try {
|
||||
appLogger.i('Starting download for $globalKey');
|
||||
|
||||
@@ -390,10 +332,7 @@ class DownloadManagerService {
|
||||
final ratingKey = parts[1];
|
||||
|
||||
// Get metadata from cache
|
||||
final cachedResponse = await _apiCache.get(
|
||||
serverId,
|
||||
'/library/metadata/$ratingKey',
|
||||
);
|
||||
final cachedResponse = await _apiCache.get(serverId, '/library/metadata/$ratingKey');
|
||||
if (cachedResponse == null) {
|
||||
throw Exception('Metadata not found in cache for $globalKey');
|
||||
}
|
||||
@@ -403,14 +342,10 @@ class DownloadManagerService {
|
||||
if (firstMetadata == null) {
|
||||
throw Exception('Invalid cached metadata for $globalKey');
|
||||
}
|
||||
final metadata = PlexMetadata.fromJson(
|
||||
firstMetadata,
|
||||
).copyWith(serverId: serverId);
|
||||
final metadata = PlexMetadata.fromJson(firstMetadata).copyWith(serverId: serverId);
|
||||
|
||||
// Get video playback data (includes URL, streams, etc.)
|
||||
final playbackData = await client.getVideoPlaybackData(
|
||||
metadata.ratingKey,
|
||||
);
|
||||
final playbackData = await client.getVideoPlaybackData(metadata.ratingKey);
|
||||
if (playbackData.videoUrl == null) {
|
||||
throw Exception('Could not get video URL');
|
||||
}
|
||||
@@ -426,8 +361,7 @@ class DownloadManagerService {
|
||||
|
||||
// For episodes, look up the show's year from cached show metadata
|
||||
int? showYear;
|
||||
if (metadataWithServer.type == 'episode' &&
|
||||
metadataWithServer.grandparentRatingKey != null) {
|
||||
if (metadataWithServer.type == 'episode' && metadataWithServer.grandparentRatingKey != null) {
|
||||
final showCached = await _apiCache.get(
|
||||
serverId,
|
||||
'/library/metadata/${metadataWithServer.grandparentRatingKey}',
|
||||
@@ -452,9 +386,7 @@ class DownloadManagerService {
|
||||
if (_storageService.isUsingSaf) {
|
||||
// SAF mode: download to temp cache first, then copy to SAF
|
||||
final tempFileName = '${globalKey.replaceAll(':', '_')}.$extension';
|
||||
downloadFilePath = await _storageService.getTempDownloadPath(
|
||||
tempFileName,
|
||||
);
|
||||
downloadFilePath = await _storageService.getTempDownloadPath(tempFileName);
|
||||
|
||||
// Download to temp path
|
||||
await _downloadFile(
|
||||
@@ -470,22 +402,11 @@ class DownloadManagerService {
|
||||
final List<String> pathComponents;
|
||||
final String safFileName;
|
||||
if (metadataWithServer.type == 'movie') {
|
||||
pathComponents = _storageService.getMovieSafPathComponents(
|
||||
metadataWithServer,
|
||||
);
|
||||
safFileName = _storageService.getMovieSafFileName(
|
||||
metadataWithServer,
|
||||
extension,
|
||||
);
|
||||
pathComponents = _storageService.getMovieSafPathComponents(metadataWithServer);
|
||||
safFileName = _storageService.getMovieSafFileName(metadataWithServer, extension);
|
||||
} else if (metadataWithServer.type == 'episode') {
|
||||
pathComponents = _storageService.getEpisodeSafPathComponents(
|
||||
metadataWithServer,
|
||||
showYear: showYear,
|
||||
);
|
||||
safFileName = _storageService.getEpisodeSafFileName(
|
||||
metadataWithServer,
|
||||
extension,
|
||||
);
|
||||
pathComponents = _storageService.getEpisodeSafPathComponents(metadataWithServer, showYear: showYear);
|
||||
safFileName = _storageService.getEpisodeSafFileName(metadataWithServer, extension);
|
||||
} else {
|
||||
pathComponents = [serverId, metadataWithServer.ratingKey];
|
||||
safFileName = 'video.$extension';
|
||||
@@ -507,10 +428,7 @@ class DownloadManagerService {
|
||||
} else {
|
||||
// Normal mode: download directly to final path
|
||||
if (metadataWithServer.type == 'movie') {
|
||||
downloadFilePath = await _storageService.getMovieVideoPath(
|
||||
metadataWithServer,
|
||||
extension,
|
||||
);
|
||||
downloadFilePath = await _storageService.getMovieVideoPath(metadataWithServer, extension);
|
||||
} else if (metadataWithServer.type == 'episode') {
|
||||
downloadFilePath = await _storageService.getEpisodeVideoPath(
|
||||
metadataWithServer,
|
||||
@@ -518,11 +436,7 @@ class DownloadManagerService {
|
||||
showYear: showYear,
|
||||
);
|
||||
} else {
|
||||
downloadFilePath = await _storageService.getVideoFilePath(
|
||||
serverId,
|
||||
metadataWithServer.ratingKey,
|
||||
extension,
|
||||
);
|
||||
downloadFilePath = await _storageService.getVideoFilePath(serverId, metadataWithServer.ratingKey, extension);
|
||||
}
|
||||
|
||||
await _downloadFile(
|
||||
@@ -544,30 +458,15 @@ class DownloadManagerService {
|
||||
// Download artwork if enabled (only episode-specific artwork, not show/season)
|
||||
// Use the passed queueItem's settings (not getNextQueueItem which would return the NEXT item)
|
||||
if (queueItem.downloadArtwork) {
|
||||
await _downloadArtwork(
|
||||
globalKey,
|
||||
metadataWithServer,
|
||||
client,
|
||||
showYear: showYear,
|
||||
);
|
||||
await _downloadArtwork(globalKey, metadataWithServer, client, showYear: showYear);
|
||||
|
||||
// Download chapter thumbnails
|
||||
await _downloadChapterThumbnails(
|
||||
metadataWithServer.serverId!,
|
||||
metadataWithServer.ratingKey,
|
||||
client,
|
||||
);
|
||||
await _downloadChapterThumbnails(metadataWithServer.serverId!, metadataWithServer.ratingKey, client);
|
||||
}
|
||||
|
||||
// Download subtitles if enabled
|
||||
if (queueItem.downloadSubtitles && playbackData.mediaInfo != null) {
|
||||
await _downloadSubtitles(
|
||||
globalKey,
|
||||
metadataWithServer,
|
||||
playbackData.mediaInfo!,
|
||||
client,
|
||||
showYear: showYear,
|
||||
);
|
||||
await _downloadSubtitles(globalKey, metadataWithServer, playbackData.mediaInfo!, client, showYear: showYear);
|
||||
}
|
||||
|
||||
// Mark as completed
|
||||
@@ -588,11 +487,7 @@ class DownloadManagerService {
|
||||
}
|
||||
|
||||
appLogger.e('Download failed for $globalKey', error: e);
|
||||
await _transitionStatus(
|
||||
globalKey,
|
||||
DownloadStatus.failed,
|
||||
errorMessage: e.toString(),
|
||||
);
|
||||
await _transitionStatus(globalKey, DownloadStatus.failed, errorMessage: e.toString());
|
||||
await _database.updateDownloadError(globalKey, e.toString());
|
||||
// Remove from queue to prevent endless retry loop
|
||||
await _database.removeFromQueue(globalKey);
|
||||
@@ -620,19 +515,14 @@ class DownloadManagerService {
|
||||
// Calculate speed (update every 500ms)
|
||||
final now = DateTime.now();
|
||||
if (now.difference(lastUpdate).inMilliseconds >= 500) {
|
||||
final elapsedSeconds = now
|
||||
.difference(lastUpdate)
|
||||
.inSeconds
|
||||
.clamp(1, double.infinity);
|
||||
final elapsedSeconds = now.difference(lastUpdate).inSeconds.clamp(1, double.infinity);
|
||||
final bytesPerSecond = (received - lastBytes) / elapsedSeconds;
|
||||
lastUpdate = now;
|
||||
lastBytes = received;
|
||||
|
||||
final progress = total > 0 ? ((received / total) * 100).round() : 0;
|
||||
|
||||
appLogger.d(
|
||||
'Download progress: $progress% ($received/$total bytes) for $globalKey',
|
||||
);
|
||||
appLogger.d('Download progress: $progress% ($received/$total bytes) for $globalKey');
|
||||
|
||||
_progressController.add(
|
||||
DownloadProgress(
|
||||
@@ -647,14 +537,9 @@ class DownloadManagerService {
|
||||
);
|
||||
|
||||
// Update database asynchronously (non-blocking)
|
||||
_database
|
||||
.updateDownloadProgress(globalKey, progress, received, total)
|
||||
.catchError((e) {
|
||||
appLogger.w(
|
||||
'Failed to update download progress in DB',
|
||||
error: e,
|
||||
);
|
||||
});
|
||||
_database.updateDownloadProgress(globalKey, progress, received, total).catchError((e) {
|
||||
appLogger.w('Failed to update download progress in DB', error: e);
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -662,21 +547,11 @@ class DownloadManagerService {
|
||||
|
||||
/// Download artwork for a media item using hash-based storage
|
||||
/// Downloads all artwork types: thumb/poster, clearLogo, and background art
|
||||
Future<void> _downloadArtwork(
|
||||
String globalKey,
|
||||
PlexMetadata metadata,
|
||||
PlexClient client, {
|
||||
int? showYear,
|
||||
}) async {
|
||||
Future<void> _downloadArtwork(String globalKey, PlexMetadata metadata, PlexClient client, {int? showYear}) async {
|
||||
if (metadata.serverId == null) return;
|
||||
|
||||
try {
|
||||
_emitProgress(
|
||||
globalKey,
|
||||
DownloadStatus.downloading,
|
||||
0,
|
||||
currentFile: 'artwork',
|
||||
);
|
||||
_emitProgress(globalKey, DownloadStatus.downloading, 0, currentFile: 'artwork');
|
||||
|
||||
final serverId = metadata.serverId!;
|
||||
|
||||
@@ -696,10 +571,7 @@ class DownloadManagerService {
|
||||
}
|
||||
|
||||
// Store thumb reference in database (primary artwork for display)
|
||||
await _database.updateArtworkPaths(
|
||||
globalKey: globalKey,
|
||||
thumbPath: metadata.thumb,
|
||||
);
|
||||
await _database.updateArtworkPaths(globalKey: globalKey, thumbPath: metadata.thumb);
|
||||
|
||||
_emitProgressWithArtwork(globalKey, thumbPath: metadata.thumb);
|
||||
appLogger.d('Artwork downloaded for $globalKey');
|
||||
@@ -710,11 +582,7 @@ class DownloadManagerService {
|
||||
}
|
||||
|
||||
/// Download a single artwork file if it doesn't already exist
|
||||
Future<void> _downloadSingleArtwork(
|
||||
String serverId,
|
||||
String artworkPath,
|
||||
PlexClient client,
|
||||
) async {
|
||||
Future<void> _downloadSingleArtwork(String serverId, String artworkPath, PlexClient client) async {
|
||||
try {
|
||||
// Check if already downloaded (deduplication)
|
||||
if (await _storageService.artworkExists(serverId, artworkPath)) {
|
||||
@@ -728,10 +596,7 @@ class DownloadManagerService {
|
||||
return;
|
||||
}
|
||||
|
||||
final filePath = await _storageService.getArtworkPathFromThumb(
|
||||
serverId,
|
||||
artworkPath,
|
||||
);
|
||||
final filePath = await _storageService.getArtworkPathFromThumb(serverId, artworkPath);
|
||||
final file = File(filePath);
|
||||
|
||||
// Ensure parent directory exists
|
||||
@@ -741,21 +606,14 @@ class DownloadManagerService {
|
||||
await _dio.download(url, filePath);
|
||||
appLogger.i('Downloaded artwork: $artworkPath -> $filePath');
|
||||
} catch (e, stack) {
|
||||
appLogger.w(
|
||||
'Failed to download artwork: $artworkPath',
|
||||
error: e,
|
||||
stackTrace: stack,
|
||||
);
|
||||
appLogger.w('Failed to download artwork: $artworkPath', error: e, stackTrace: stack);
|
||||
// Don't throw - artwork download failures shouldn't kill the entire download
|
||||
}
|
||||
}
|
||||
|
||||
/// Download all artwork for a metadata item (public method for parent metadata)
|
||||
/// Downloads thumb/poster, clearLogo, and background art
|
||||
Future<void> downloadArtworkForMetadata(
|
||||
PlexMetadata metadata,
|
||||
PlexClient client,
|
||||
) async {
|
||||
Future<void> downloadArtworkForMetadata(PlexMetadata metadata, PlexClient client) async {
|
||||
if (metadata.serverId == null) return;
|
||||
final serverId = metadata.serverId!;
|
||||
|
||||
@@ -776,11 +634,7 @@ class DownloadManagerService {
|
||||
}
|
||||
|
||||
/// Download chapter thumbnail images for a media item
|
||||
Future<void> _downloadChapterThumbnails(
|
||||
String serverId,
|
||||
String ratingKey,
|
||||
PlexClient client,
|
||||
) async {
|
||||
Future<void> _downloadChapterThumbnails(String serverId, String ratingKey, PlexClient client) async {
|
||||
try {
|
||||
// Get chapters from the cached API response
|
||||
final extras = await client.getPlaybackExtras(ratingKey);
|
||||
@@ -809,12 +663,7 @@ class DownloadManagerService {
|
||||
int? showYear,
|
||||
}) async {
|
||||
try {
|
||||
_emitProgress(
|
||||
globalKey,
|
||||
DownloadStatus.downloading,
|
||||
0,
|
||||
currentFile: 'subtitles',
|
||||
);
|
||||
_emitProgress(globalKey, DownloadStatus.downloading, 0, currentFile: 'subtitles');
|
||||
|
||||
for (final subtitle in mediaInfo.subtitleTracks) {
|
||||
// Only download external subtitles
|
||||
@@ -840,11 +689,7 @@ class DownloadManagerService {
|
||||
showYear: showYear,
|
||||
);
|
||||
} else if (metadata.isMovie) {
|
||||
subtitlePath = await _storageService.getMovieSubtitlePath(
|
||||
metadata,
|
||||
subtitle.id,
|
||||
extension,
|
||||
);
|
||||
subtitlePath = await _storageService.getMovieSubtitlePath(metadata, subtitle.id, extension);
|
||||
} else {
|
||||
// Fallback to old structure
|
||||
subtitlePath = await _storageService.getSubtitlePath(
|
||||
@@ -902,12 +747,7 @@ class DownloadManagerService {
|
||||
/// 2. Emit progress to listeners
|
||||
///
|
||||
/// Default progress is 0 for most statuses, 100 for completed.
|
||||
Future<void> _transitionStatus(
|
||||
String globalKey,
|
||||
DownloadStatus status, {
|
||||
int? progress,
|
||||
String? errorMessage,
|
||||
}) async {
|
||||
Future<void> _transitionStatus(String globalKey, DownloadStatus status, {int? progress, String? errorMessage}) async {
|
||||
await _database.updateDownloadStatus(globalKey, status.index);
|
||||
_emitProgress(
|
||||
globalKey,
|
||||
@@ -1008,12 +848,7 @@ class DownloadManagerService {
|
||||
|
||||
// Emit initial progress
|
||||
_emitDeletionProgress(
|
||||
DeletionProgress(
|
||||
globalKey: globalKey,
|
||||
itemTitle: metadata.title,
|
||||
currentItem: 0,
|
||||
totalItems: totalItems,
|
||||
),
|
||||
DeletionProgress(globalKey: globalKey, itemTitle: metadata.title, currentItem: 0, totalItems: totalItems),
|
||||
);
|
||||
|
||||
// Delete files from storage (with progress updates)
|
||||
@@ -1042,10 +877,7 @@ class DownloadManagerService {
|
||||
}
|
||||
|
||||
/// Calculate total items to delete (for progress tracking)
|
||||
Future<int> _getTotalItemsToDelete(
|
||||
PlexMetadata metadata,
|
||||
String serverId,
|
||||
) async {
|
||||
Future<int> _getTotalItemsToDelete(PlexMetadata metadata, String serverId) async {
|
||||
switch (metadata.type.toLowerCase()) {
|
||||
case 'episode':
|
||||
return 1; // Single episode
|
||||
@@ -1065,19 +897,14 @@ class DownloadManagerService {
|
||||
}
|
||||
|
||||
/// Delete media files using metadata to find correct paths
|
||||
Future<void> _deleteMediaFilesWithMetadata(
|
||||
String serverId,
|
||||
String ratingKey,
|
||||
) async {
|
||||
Future<void> _deleteMediaFilesWithMetadata(String serverId, String ratingKey) async {
|
||||
try {
|
||||
// Get metadata from API cache
|
||||
final metadata = await _getMetadataFromCache(serverId, ratingKey);
|
||||
|
||||
if (metadata == null) {
|
||||
// Fallback: Try database record
|
||||
final downloadRecord = await _database.getDownloadedMedia(
|
||||
'$serverId:$ratingKey',
|
||||
);
|
||||
final downloadRecord = await _database.getDownloadedMedia('$serverId:$ratingKey');
|
||||
if (downloadRecord?.videoFilePath != null) {
|
||||
await _deleteByFilePath(downloadRecord!);
|
||||
return;
|
||||
@@ -1109,14 +936,8 @@ class DownloadManagerService {
|
||||
}
|
||||
|
||||
/// Get metadata from API cache
|
||||
Future<PlexMetadata?> _getMetadataFromCache(
|
||||
String serverId,
|
||||
String ratingKey,
|
||||
) async {
|
||||
final cachedData = await _apiCache.get(
|
||||
serverId,
|
||||
'/library/metadata/$ratingKey',
|
||||
);
|
||||
Future<PlexMetadata?> _getMetadataFromCache(String serverId, String ratingKey) async {
|
||||
final cachedData = await _apiCache.get(serverId, '/library/metadata/$ratingKey');
|
||||
final metadataJson = PlexCacheParser.extractFirstMetadata(cachedData);
|
||||
if (metadataJson != null) {
|
||||
return PlexMetadata.fromJson(metadataJson).copyWith(serverId: serverId);
|
||||
@@ -1125,15 +946,9 @@ class DownloadManagerService {
|
||||
}
|
||||
|
||||
/// Get chapter thumb paths from cached metadata
|
||||
Future<List<String>> _getChapterThumbPaths(
|
||||
String serverId,
|
||||
String ratingKey,
|
||||
) async {
|
||||
Future<List<String>> _getChapterThumbPaths(String serverId, String ratingKey) async {
|
||||
try {
|
||||
final cachedData = await _apiCache.get(
|
||||
serverId,
|
||||
'/library/metadata/$ratingKey',
|
||||
);
|
||||
final cachedData = await _apiCache.get(serverId, '/library/metadata/$ratingKey');
|
||||
final chapters = PlexCacheParser.extractChapters(cachedData);
|
||||
if (chapters == null) return [];
|
||||
|
||||
@@ -1149,11 +964,7 @@ class DownloadManagerService {
|
||||
}
|
||||
|
||||
/// Check if a chapter thumbnail is used by any other downloaded items
|
||||
Future<bool> _isChapterThumbnailInUse(
|
||||
String serverId,
|
||||
String thumbPath,
|
||||
String excludeRatingKey,
|
||||
) async {
|
||||
Future<bool> _isChapterThumbnailInUse(String serverId, String thumbPath, String excludeRatingKey) async {
|
||||
try {
|
||||
// Get all downloaded items
|
||||
final allItems = await _database.select(_database.downloadedMedia).get();
|
||||
@@ -1166,10 +977,7 @@ class DownloadManagerService {
|
||||
}
|
||||
|
||||
// Get chapter thumb paths for this item
|
||||
final itemChapterPaths = await _getChapterThumbPaths(
|
||||
serverId,
|
||||
item.ratingKey,
|
||||
);
|
||||
final itemChapterPaths = await _getChapterThumbPaths(serverId, item.ratingKey);
|
||||
|
||||
// Check if this item has the same thumb path
|
||||
if (itemChapterPaths.contains(thumbPath)) {
|
||||
@@ -1179,20 +987,14 @@ class DownloadManagerService {
|
||||
|
||||
return false; // Thumbnail is not in use
|
||||
} catch (e) {
|
||||
appLogger.w(
|
||||
'Error checking chapter thumbnail usage: $thumbPath',
|
||||
error: e,
|
||||
);
|
||||
appLogger.w('Error checking chapter thumbnail usage: $thumbPath', error: e);
|
||||
// On error, assume in use to be safe (don't delete)
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete chapter thumbnails for a media item (with reference counting)
|
||||
Future<void> _deleteChapterThumbnails(
|
||||
String serverId,
|
||||
String ratingKey,
|
||||
) async {
|
||||
Future<void> _deleteChapterThumbnails(String serverId, String ratingKey) async {
|
||||
try {
|
||||
final thumbPaths = await _getChapterThumbPaths(serverId, ratingKey);
|
||||
|
||||
@@ -1207,11 +1009,7 @@ class DownloadManagerService {
|
||||
for (final thumbPath in thumbPaths) {
|
||||
try {
|
||||
// Check if this thumbnail is used by other items
|
||||
final inUse = await _isChapterThumbnailInUse(
|
||||
serverId,
|
||||
thumbPath,
|
||||
ratingKey,
|
||||
);
|
||||
final inUse = await _isChapterThumbnailInUse(serverId, thumbPath, ratingKey);
|
||||
|
||||
if (inUse) {
|
||||
appLogger.d('Preserving chapter thumbnail (in use): $thumbPath');
|
||||
@@ -1220,45 +1018,27 @@ class DownloadManagerService {
|
||||
}
|
||||
|
||||
// Get artwork file path and delete
|
||||
final artworkPath = await _storageService.getArtworkPathFromThumb(
|
||||
serverId,
|
||||
thumbPath,
|
||||
);
|
||||
if (await _deleteFileIfExists(
|
||||
File(artworkPath),
|
||||
'chapter thumbnail',
|
||||
)) {
|
||||
final artworkPath = await _storageService.getArtworkPathFromThumb(serverId, thumbPath);
|
||||
if (await _deleteFileIfExists(File(artworkPath), 'chapter thumbnail')) {
|
||||
deletedCount++;
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.w(
|
||||
'Failed to delete chapter thumbnail: $thumbPath',
|
||||
error: e,
|
||||
);
|
||||
appLogger.w('Failed to delete chapter thumbnail: $thumbPath', error: e);
|
||||
// Continue with other chapters even if one fails
|
||||
}
|
||||
}
|
||||
|
||||
if (deletedCount > 0 || preservedCount > 0) {
|
||||
appLogger.i(
|
||||
'Deleted $deletedCount of ${thumbPaths.length} chapter thumbnails ($preservedCount preserved)',
|
||||
);
|
||||
appLogger.i('Deleted $deletedCount of ${thumbPaths.length} chapter thumbnails ($preservedCount preserved)');
|
||||
}
|
||||
} catch (e, stack) {
|
||||
appLogger.w(
|
||||
'Error deleting chapter thumbnails for $ratingKey',
|
||||
error: e,
|
||||
stackTrace: stack,
|
||||
);
|
||||
appLogger.w('Error deleting chapter thumbnails for $ratingKey', error: e, stackTrace: stack);
|
||||
// Don't throw - chapter deletion shouldn't block main deletion
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete episode files
|
||||
Future<void> _deleteEpisodeFiles(
|
||||
PlexMetadata episode,
|
||||
String serverId,
|
||||
) async {
|
||||
Future<void> _deleteEpisodeFiles(PlexMetadata episode, String serverId) async {
|
||||
try {
|
||||
final parentMetadata = episode.grandparentRatingKey != null
|
||||
? await _getMetadataFromCache(serverId, episode.grandparentRatingKey!)
|
||||
@@ -1266,34 +1046,19 @@ class DownloadManagerService {
|
||||
final showYear = parentMetadata?.year;
|
||||
|
||||
// Delete video file
|
||||
final videoPathTemplate = await _storageService.getEpisodeVideoPath(
|
||||
episode,
|
||||
'tmp',
|
||||
showYear: showYear,
|
||||
);
|
||||
final videoPathWithoutExt = videoPathTemplate.substring(
|
||||
0,
|
||||
videoPathTemplate.lastIndexOf('.'),
|
||||
);
|
||||
final actualVideoFile = await _findFileWithAnyExtension(
|
||||
videoPathWithoutExt,
|
||||
);
|
||||
final videoPathTemplate = await _storageService.getEpisodeVideoPath(episode, 'tmp', showYear: showYear);
|
||||
final videoPathWithoutExt = videoPathTemplate.substring(0, videoPathTemplate.lastIndexOf('.'));
|
||||
final actualVideoFile = await _findFileWithAnyExtension(videoPathWithoutExt);
|
||||
if (actualVideoFile != null) {
|
||||
await _deleteFileIfExists(actualVideoFile, 'episode video');
|
||||
}
|
||||
|
||||
// Delete thumbnail
|
||||
final thumbPath = await _storageService.getEpisodeThumbnailPath(
|
||||
episode,
|
||||
showYear: showYear,
|
||||
);
|
||||
final thumbPath = await _storageService.getEpisodeThumbnailPath(episode, showYear: showYear);
|
||||
await _deleteFileIfExists(File(thumbPath), 'episode thumbnail');
|
||||
|
||||
// Delete subtitles directory
|
||||
final subsDir = await _storageService.getEpisodeSubtitlesDirectory(
|
||||
episode,
|
||||
showYear: showYear,
|
||||
);
|
||||
final subsDir = await _storageService.getEpisodeSubtitlesDirectory(episode, showYear: showYear);
|
||||
if (await subsDir.exists()) {
|
||||
await subsDir.delete(recursive: true);
|
||||
appLogger.i('Deleted episode subtitles: ${subsDir.path}');
|
||||
@@ -1318,12 +1083,9 @@ class DownloadManagerService {
|
||||
final showYear = parentMetadata?.year;
|
||||
|
||||
// Get all episodes in this season
|
||||
final episodesInSeason =
|
||||
await _database.getEpisodesBySeason(season.ratingKey);
|
||||
final episodesInSeason = await _database.getEpisodesBySeason(season.ratingKey);
|
||||
|
||||
appLogger.d(
|
||||
'Deleting ${episodesInSeason.length} episodes in season ${season.ratingKey}',
|
||||
);
|
||||
appLogger.d('Deleting ${episodesInSeason.length} episodes in season ${season.ratingKey}');
|
||||
await _deleteEpisodesInCollection(
|
||||
episodes: episodesInSeason,
|
||||
serverId: serverId,
|
||||
@@ -1331,10 +1093,7 @@ class DownloadManagerService {
|
||||
parentTitle: season.title,
|
||||
);
|
||||
|
||||
final seasonDir = await _storageService.getSeasonDirectory(
|
||||
season,
|
||||
showYear: showYear,
|
||||
);
|
||||
final seasonDir = await _storageService.getSeasonDirectory(season, showYear: showYear);
|
||||
if (await seasonDir.exists()) {
|
||||
await seasonDir.delete(recursive: true);
|
||||
appLogger.i('Deleted season directory: ${seasonDir.path}');
|
||||
@@ -1365,8 +1124,7 @@ class DownloadManagerService {
|
||||
itemTitle: parentTitle,
|
||||
currentItem: i + 1,
|
||||
totalItems: episodes.length,
|
||||
currentOperation:
|
||||
'Deleting episode ${i + 1} of ${episodes.length}',
|
||||
currentOperation: 'Deleting episode ${i + 1} of ${episodes.length}',
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1390,9 +1148,7 @@ class DownloadManagerService {
|
||||
// Get all episodes in this show
|
||||
final episodesInShow = await _database.getEpisodesByShow(show.ratingKey);
|
||||
|
||||
appLogger.d(
|
||||
'Deleting ${episodesInShow.length} episodes in show ${show.ratingKey}',
|
||||
);
|
||||
appLogger.d('Deleting ${episodesInShow.length} episodes in show ${show.ratingKey}');
|
||||
await _deleteEpisodesInCollection(
|
||||
episodes: episodesInShow,
|
||||
serverId: serverId,
|
||||
@@ -1427,14 +1183,8 @@ class DownloadManagerService {
|
||||
}
|
||||
|
||||
/// Clean up empty directories after deleting episode
|
||||
Future<void> _cleanupEmptyDirectories(
|
||||
PlexMetadata episode,
|
||||
int? showYear,
|
||||
) async {
|
||||
final seasonDir = await _storageService.getSeasonDirectory(
|
||||
episode,
|
||||
showYear: showYear,
|
||||
);
|
||||
Future<void> _cleanupEmptyDirectories(PlexMetadata episode, int? showYear) async {
|
||||
final seasonDir = await _storageService.getSeasonDirectory(episode, showYear: showYear);
|
||||
|
||||
if (await seasonDir.exists()) {
|
||||
final contents = await seasonDir.list().toList();
|
||||
@@ -1459,20 +1209,12 @@ class DownloadManagerService {
|
||||
}
|
||||
|
||||
/// Clean up show directory if empty
|
||||
Future<void> _cleanupShowDirectory(
|
||||
PlexMetadata metadata,
|
||||
int? showYear,
|
||||
) async {
|
||||
final showDir = await _storageService.getShowDirectory(
|
||||
metadata,
|
||||
showYear: showYear,
|
||||
);
|
||||
Future<void> _cleanupShowDirectory(PlexMetadata metadata, int? showYear) async {
|
||||
final showDir = await _storageService.getShowDirectory(metadata, showYear: showYear);
|
||||
|
||||
if (await showDir.exists()) {
|
||||
final contents = await showDir.list().toList();
|
||||
final hasSeasons = contents.any(
|
||||
(e) => e is Directory && e.path.contains('Season '),
|
||||
);
|
||||
final hasSeasons = contents.any((e) => e is Directory && e.path.contains('Season '));
|
||||
|
||||
if (!hasSeasons) {
|
||||
if (!await _isShowArtworkInUse(metadata, showYear)) {
|
||||
@@ -1484,35 +1226,26 @@ class DownloadManagerService {
|
||||
}
|
||||
|
||||
/// Check if season artwork is in use
|
||||
Future<bool> _isSeasonArtworkInUse(
|
||||
PlexMetadata episode,
|
||||
int? showYear,
|
||||
) async {
|
||||
Future<bool> _isSeasonArtworkInUse(PlexMetadata episode, int? showYear) async {
|
||||
final seasonKey = episode.parentRatingKey;
|
||||
if (seasonKey == null) return false;
|
||||
|
||||
final otherEpisodes = await _database.getEpisodesBySeason(seasonKey);
|
||||
|
||||
// Check if any episodes besides this one
|
||||
return otherEpisodes.any(
|
||||
(e) => e.globalKey != '${episode.serverId}:${episode.ratingKey}',
|
||||
);
|
||||
return otherEpisodes.any((e) => e.globalKey != '${episode.serverId}:${episode.ratingKey}');
|
||||
}
|
||||
|
||||
/// Check if show artwork is in use
|
||||
Future<bool> _isShowArtworkInUse(PlexMetadata metadata, int? showYear) async {
|
||||
final showKey =
|
||||
metadata.grandparentRatingKey ??
|
||||
metadata.parentRatingKey ??
|
||||
metadata.ratingKey;
|
||||
final showKey = metadata.grandparentRatingKey ?? metadata.parentRatingKey ?? metadata.ratingKey;
|
||||
|
||||
final allItems = await _database.select(_database.downloadedMedia).get();
|
||||
|
||||
// Check if any items belong to this show besides this one
|
||||
return allItems.any(
|
||||
(item) =>
|
||||
(item.grandparentRatingKey == showKey ||
|
||||
item.parentRatingKey == showKey) &&
|
||||
(item.grandparentRatingKey == showKey || item.parentRatingKey == showKey) &&
|
||||
item.globalKey != '${metadata.serverId}:${metadata.ratingKey}',
|
||||
);
|
||||
}
|
||||
@@ -1527,10 +1260,7 @@ class DownloadManagerService {
|
||||
try {
|
||||
final files = await dir
|
||||
.list()
|
||||
.where(
|
||||
(e) =>
|
||||
e is File && path.basenameWithoutExtension(e.path) == baseName,
|
||||
)
|
||||
.where((e) => e is File && path.basenameWithoutExtension(e.path) == baseName)
|
||||
.toList();
|
||||
|
||||
return files.isNotEmpty ? files.first as File : null;
|
||||
@@ -1544,11 +1274,8 @@ class DownloadManagerService {
|
||||
Future<void> _deleteByFilePath(DownloadedMediaItem record) async {
|
||||
try {
|
||||
if (record.videoFilePath != null) {
|
||||
final videoPath = await _storageService.toAbsolutePath(
|
||||
record.videoFilePath!,
|
||||
);
|
||||
final videoDeleted =
|
||||
await _deleteFileIfExists(File(videoPath), 'video file');
|
||||
final videoPath = await _storageService.toAbsolutePath(record.videoFilePath!);
|
||||
final videoDeleted = await _deleteFileIfExists(File(videoPath), 'video file');
|
||||
|
||||
// Delete subtitle directory if video was deleted
|
||||
if (videoDeleted) {
|
||||
@@ -1562,9 +1289,7 @@ class DownloadManagerService {
|
||||
}
|
||||
|
||||
if (record.thumbPath != null) {
|
||||
final thumbPath = await _storageService.toAbsolutePath(
|
||||
record.thumbPath!,
|
||||
);
|
||||
final thumbPath = await _storageService.toAbsolutePath(record.thumbPath!);
|
||||
await _deleteFileIfExists(File(thumbPath), 'thumbnail');
|
||||
}
|
||||
} catch (e, stack) {
|
||||
@@ -1573,12 +1298,8 @@ class DownloadManagerService {
|
||||
}
|
||||
|
||||
/// Get all downloads with a specific status
|
||||
Stream<List<DownloadedMediaItem>> watchDownloadsByStatus(
|
||||
DownloadStatus status,
|
||||
) {
|
||||
return (_database.select(
|
||||
_database.downloadedMedia,
|
||||
)..where((t) => t.status.equals(status.index))).watch();
|
||||
Stream<List<DownloadedMediaItem>> watchDownloadsByStatus(DownloadStatus status) {
|
||||
return (_database.select(_database.downloadedMedia)..where((t) => t.status.equals(status.index))).watch();
|
||||
}
|
||||
|
||||
/// Get all downloaded media items (for loading persisted data)
|
||||
@@ -1600,20 +1321,12 @@ class DownloadManagerService {
|
||||
}
|
||||
|
||||
// Cache to API cache for offline use
|
||||
await _cacheMetadataForOffline(
|
||||
metadata.serverId!,
|
||||
metadata.ratingKey,
|
||||
metadata,
|
||||
);
|
||||
await _cacheMetadataForOffline(metadata.serverId!, metadata.ratingKey, metadata);
|
||||
}
|
||||
|
||||
/// Cache metadata in the API response format for offline access
|
||||
/// This simulates what PlexClient would receive from the server
|
||||
Future<void> _cacheMetadataForOffline(
|
||||
String serverId,
|
||||
String ratingKey,
|
||||
PlexMetadata metadata,
|
||||
) async {
|
||||
Future<void> _cacheMetadataForOffline(String serverId, String ratingKey, PlexMetadata metadata) async {
|
||||
final endpoint = '/library/metadata/$ratingKey';
|
||||
|
||||
// Build a response structure that matches the Plex API format
|
||||
@@ -1628,11 +1341,7 @@ class DownloadManagerService {
|
||||
}
|
||||
|
||||
/// Cache children (seasons or episodes) in the API response format
|
||||
Future<void> cacheChildrenForOffline(
|
||||
String serverId,
|
||||
String parentRatingKey,
|
||||
List<PlexMetadata> children,
|
||||
) async {
|
||||
Future<void> cacheChildrenForOffline(String serverId, String parentRatingKey, List<PlexMetadata> children) async {
|
||||
final endpoint = '/library/metadata/$parentRatingKey/children';
|
||||
|
||||
// Build a response structure that matches the Plex API format
|
||||
|
||||
@@ -11,8 +11,7 @@ import 'saf_storage_service.dart';
|
||||
|
||||
class DownloadStorageService {
|
||||
static DownloadStorageService? _instance;
|
||||
static DownloadStorageService get instance =>
|
||||
_instance ??= DownloadStorageService._();
|
||||
static DownloadStorageService get instance => _instance ??= DownloadStorageService._();
|
||||
DownloadStorageService._();
|
||||
|
||||
Directory? _baseDownloadsDir;
|
||||
@@ -24,10 +23,7 @@ class DownloadStorageService {
|
||||
String _customPathType = 'file';
|
||||
|
||||
/// Check if currently using SAF mode (Android only)
|
||||
bool get isUsingSaf =>
|
||||
Platform.isAndroid &&
|
||||
_customPathType == 'saf' &&
|
||||
_customDownloadPath != null;
|
||||
bool get isUsingSaf => Platform.isAndroid && _customPathType == 'saf' && _customDownloadPath != null;
|
||||
|
||||
/// Get the SAF base URI (only valid when isUsingSaf is true)
|
||||
String? get safBaseUri => isUsingSaf ? _customDownloadPath : null;
|
||||
@@ -97,12 +93,7 @@ class DownloadStorageService {
|
||||
await dir.create(recursive: true);
|
||||
}
|
||||
// Test write access with a temp file
|
||||
final testFile = File(
|
||||
path.join(
|
||||
dir.path,
|
||||
'.write_test_${DateTime.now().millisecondsSinceEpoch}',
|
||||
),
|
||||
);
|
||||
final testFile = File(path.join(dir.path, '.write_test_${DateTime.now().millisecondsSinceEpoch}'));
|
||||
await testFile.writeAsString('test');
|
||||
await testFile.delete();
|
||||
return true;
|
||||
@@ -127,9 +118,7 @@ class DownloadStorageService {
|
||||
|
||||
// Default path logic
|
||||
final baseDir = await _getBaseAppDir();
|
||||
_baseDownloadsDir = await _ensureDirectoryExists(
|
||||
Directory(path.join(baseDir.path, 'downloads')),
|
||||
);
|
||||
_baseDownloadsDir = await _ensureDirectoryExists(Directory(path.join(baseDir.path, 'downloads')));
|
||||
return _baseDownloadsDir!;
|
||||
}
|
||||
|
||||
@@ -154,9 +143,7 @@ class DownloadStorageService {
|
||||
|
||||
// Default: Get the app base directory directly (not downloads directory)
|
||||
final baseDir = await _getBaseAppDir();
|
||||
final artworkDir = await _ensureDirectoryExists(
|
||||
Directory(path.join(baseDir.path, 'artwork')),
|
||||
);
|
||||
final artworkDir = await _ensureDirectoryExists(Directory(path.join(baseDir.path, 'artwork')));
|
||||
// Cache the path for synchronous access
|
||||
_artworkDirectoryPath = artworkDir.path;
|
||||
return artworkDir;
|
||||
@@ -167,9 +154,7 @@ class DownloadStorageService {
|
||||
/// Example: artwork/a1b2c3d4e5f6.jpg
|
||||
String getArtworkPathSync(String serverId, String thumbPath) {
|
||||
if (_artworkDirectoryPath == null) {
|
||||
throw StateError(
|
||||
'Artwork directory not initialized. Call getArtworkDirectory() first.',
|
||||
);
|
||||
throw StateError('Artwork directory not initialized. Call getArtworkDirectory() first.');
|
||||
}
|
||||
// Create hash from serverId:thumbPath for deduplication
|
||||
final hash = _hashArtworkPath(serverId, thumbPath);
|
||||
@@ -177,10 +162,7 @@ class DownloadStorageService {
|
||||
}
|
||||
|
||||
/// Get artwork file path from Plex thumb path (async version)
|
||||
Future<String> getArtworkPathFromThumb(
|
||||
String serverId,
|
||||
String thumbPath,
|
||||
) async {
|
||||
Future<String> getArtworkPathFromThumb(String serverId, String thumbPath) async {
|
||||
final artworkDir = await getArtworkDirectory();
|
||||
final hash = _hashArtworkPath(serverId, thumbPath);
|
||||
return path.join(artworkDir.path, '$hash.jpg');
|
||||
@@ -201,36 +183,23 @@ class DownloadStorageService {
|
||||
/// Get directory for a specific media item
|
||||
Future<Directory> getMediaDirectory(String serverId, String ratingKey) async {
|
||||
final baseDir = await getDownloadsDirectory();
|
||||
return _ensureDirectoryExists(
|
||||
Directory(path.join(baseDir.path, serverId, ratingKey)),
|
||||
);
|
||||
return _ensureDirectoryExists(Directory(path.join(baseDir.path, serverId, ratingKey)));
|
||||
}
|
||||
|
||||
/// Get video file path
|
||||
Future<String> getVideoFilePath(
|
||||
String serverId,
|
||||
String ratingKey,
|
||||
String extension,
|
||||
) async {
|
||||
Future<String> getVideoFilePath(String serverId, String ratingKey, String extension) async {
|
||||
final mediaDir = await getMediaDirectory(serverId, ratingKey);
|
||||
return path.join(mediaDir.path, 'video.$extension');
|
||||
}
|
||||
|
||||
/// Get artwork file path (poster, art, thumb)
|
||||
Future<String> getArtworkPath(
|
||||
String serverId,
|
||||
String ratingKey,
|
||||
String artworkType,
|
||||
) async {
|
||||
Future<String> getArtworkPath(String serverId, String ratingKey, String artworkType) async {
|
||||
final mediaDir = await getMediaDirectory(serverId, ratingKey);
|
||||
return path.join(mediaDir.path, '$artworkType.jpg');
|
||||
}
|
||||
|
||||
/// Get subtitles directory
|
||||
Future<Directory> getSubtitlesDirectory(
|
||||
String serverId,
|
||||
String ratingKey,
|
||||
) async {
|
||||
Future<Directory> getSubtitlesDirectory(String serverId, String ratingKey) async {
|
||||
final mediaDir = await getMediaDirectory(serverId, ratingKey);
|
||||
final subtitlesDir = Directory(path.join(mediaDir.path, 'subtitles'));
|
||||
if (!await subtitlesDir.exists()) {
|
||||
@@ -240,12 +209,7 @@ class DownloadStorageService {
|
||||
}
|
||||
|
||||
/// Get subtitle file path
|
||||
Future<String> getSubtitlePath(
|
||||
String serverId,
|
||||
String ratingKey,
|
||||
int trackId,
|
||||
String extension,
|
||||
) async {
|
||||
Future<String> getSubtitlePath(String serverId, String ratingKey, int trackId, String extension) async {
|
||||
final subtitlesDir = await getSubtitlesDirectory(serverId, ratingKey);
|
||||
return path.join(subtitlesDir.path, '$trackId.$extension');
|
||||
}
|
||||
@@ -258,10 +222,7 @@ class DownloadStorageService {
|
||||
String _sanitizeFileName(String name) {
|
||||
// Remove invalid filesystem characters: < > : " / \ | ? *
|
||||
// Also remove leading/trailing whitespace and dots
|
||||
return name
|
||||
.replaceAll(RegExp(r'[<>:"/\\|?*]'), '')
|
||||
.replaceAll(RegExp(r'^\.+|\.+$'), '')
|
||||
.trim();
|
||||
return name.replaceAll(RegExp(r'[<>:"/\\|?*]'), '').replaceAll(RegExp(r'^\.+|\.+$'), '').trim();
|
||||
}
|
||||
|
||||
/// Ensure a directory exists, creating it if necessary
|
||||
@@ -295,9 +256,7 @@ class DownloadStorageService {
|
||||
Future<Directory> getMovieDirectory(PlexMetadata movie) async {
|
||||
final baseDir = await getDownloadsDirectory();
|
||||
final movieFolder = _getMovieFolderName(movie);
|
||||
return _ensureDirectoryExists(
|
||||
Directory(path.join(baseDir.path, 'Movies', movieFolder)),
|
||||
);
|
||||
return _ensureDirectoryExists(Directory(path.join(baseDir.path, 'Movies', movieFolder)));
|
||||
}
|
||||
|
||||
/// Get movie video file path: .../Movie Name (YYYY)/Movie Name (YYYY).{ext}
|
||||
@@ -308,10 +267,7 @@ class DownloadStorageService {
|
||||
}
|
||||
|
||||
/// Get movie artwork path: .../Movie Name (YYYY)/{artworkType}.jpg
|
||||
Future<String> getMovieArtworkPath(
|
||||
PlexMetadata movie,
|
||||
String artworkType,
|
||||
) async {
|
||||
Future<String> getMovieArtworkPath(PlexMetadata movie, String artworkType) async {
|
||||
final movieDir = await getMovieDirectory(movie);
|
||||
return path.join(movieDir.path, '$artworkType.jpg');
|
||||
}
|
||||
@@ -319,56 +275,35 @@ class DownloadStorageService {
|
||||
/// Get show directory: downloads/TV Shows/{Show Name} ({Year})/
|
||||
/// [showYear]: Pass the show's premiere year explicitly (for episodes, the episode's
|
||||
/// year may differ from the show's year). If not provided, uses metadata.year.
|
||||
Future<Directory> getShowDirectory(
|
||||
PlexMetadata metadata, {
|
||||
int? showYear,
|
||||
}) async {
|
||||
Future<Directory> getShowDirectory(PlexMetadata metadata, {int? showYear}) async {
|
||||
final baseDir = await getDownloadsDirectory();
|
||||
final showFolder = _getShowFolderName(metadata, showYear: showYear);
|
||||
return _ensureDirectoryExists(
|
||||
Directory(path.join(baseDir.path, 'TV Shows', showFolder)),
|
||||
);
|
||||
return _ensureDirectoryExists(Directory(path.join(baseDir.path, 'TV Shows', showFolder)));
|
||||
}
|
||||
|
||||
/// Get show artwork path: downloads/TV Shows/{Show}/poster.jpg
|
||||
Future<String> getShowArtworkPath(
|
||||
PlexMetadata metadata,
|
||||
String artworkType, {
|
||||
int? showYear,
|
||||
}) async {
|
||||
Future<String> getShowArtworkPath(PlexMetadata metadata, String artworkType, {int? showYear}) async {
|
||||
final showDir = await getShowDirectory(metadata, showYear: showYear);
|
||||
return path.join(showDir.path, '$artworkType.jpg');
|
||||
}
|
||||
|
||||
/// Get season directory: .../TV Shows/{Show}/Season {XX}/
|
||||
/// [showYear]: Pass the show's premiere year (not episode or season year)
|
||||
Future<Directory> getSeasonDirectory(
|
||||
PlexMetadata metadata, {
|
||||
int? showYear,
|
||||
}) async {
|
||||
Future<Directory> getSeasonDirectory(PlexMetadata metadata, {int? showYear}) async {
|
||||
final showDir = await getShowDirectory(metadata, showYear: showYear);
|
||||
final seasonNum = padNumber(metadata.parentIndex ?? 0, 2);
|
||||
return _ensureDirectoryExists(
|
||||
Directory(path.join(showDir.path, 'Season $seasonNum')),
|
||||
);
|
||||
return _ensureDirectoryExists(Directory(path.join(showDir.path, 'Season $seasonNum')));
|
||||
}
|
||||
|
||||
/// Get season artwork path: .../Season XX/poster.jpg
|
||||
Future<String> getSeasonArtworkPath(
|
||||
PlexMetadata metadata,
|
||||
String artworkType, {
|
||||
int? showYear,
|
||||
}) async {
|
||||
Future<String> getSeasonArtworkPath(PlexMetadata metadata, String artworkType, {int? showYear}) async {
|
||||
final seasonDir = await getSeasonDirectory(metadata, showYear: showYear);
|
||||
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 {
|
||||
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);
|
||||
@@ -376,49 +311,29 @@ class DownloadStorageService {
|
||||
|
||||
/// Get episode video file path: .../Season XX/S{XX}E{XX} - {Title}.{ext}
|
||||
/// [showYear]: Pass the show's premiere year (not episode year)
|
||||
Future<String> getEpisodeVideoPath(
|
||||
PlexMetadata episode,
|
||||
String extension, {
|
||||
int? showYear,
|
||||
}) async {
|
||||
Future<String> getEpisodeVideoPath(PlexMetadata episode, String extension, {int? showYear}) async {
|
||||
final base = await _getEpisodeBasePath(episode, showYear: showYear);
|
||||
return path.join(base.seasonDirPath, '${base.fileName}.$extension');
|
||||
}
|
||||
|
||||
/// Get episode thumbnail path: .../Season XX/S{XX}E{XX} - {Title}.jpg
|
||||
/// [showYear]: Pass the show's premiere year (not episode year)
|
||||
Future<String> getEpisodeThumbnailPath(
|
||||
PlexMetadata episode, {
|
||||
int? showYear,
|
||||
}) async {
|
||||
Future<String> getEpisodeThumbnailPath(PlexMetadata episode, {int? showYear}) async {
|
||||
final base = await _getEpisodeBasePath(episode, showYear: showYear);
|
||||
return path.join(base.seasonDirPath, '${base.fileName}.jpg');
|
||||
}
|
||||
|
||||
/// Get subtitles directory for episode: .../Season XX/S{XX}E{XX} - {Title}_subs/
|
||||
/// [showYear]: Pass the show's premiere year (not episode year)
|
||||
Future<Directory> getEpisodeSubtitlesDirectory(
|
||||
PlexMetadata episode, {
|
||||
int? showYear,
|
||||
}) async {
|
||||
Future<Directory> getEpisodeSubtitlesDirectory(PlexMetadata episode, {int? showYear}) async {
|
||||
final base = await _getEpisodeBasePath(episode, showYear: showYear);
|
||||
return _ensureDirectoryExists(
|
||||
Directory(path.join(base.seasonDirPath, '${base.fileName}_subs')),
|
||||
);
|
||||
return _ensureDirectoryExists(Directory(path.join(base.seasonDirPath, '${base.fileName}_subs')));
|
||||
}
|
||||
|
||||
/// Get episode subtitle path
|
||||
/// [showYear]: Pass the show's premiere year (not episode year)
|
||||
Future<String> getEpisodeSubtitlePath(
|
||||
PlexMetadata episode,
|
||||
int trackId,
|
||||
String extension, {
|
||||
int? showYear,
|
||||
}) async {
|
||||
final subsDir = await getEpisodeSubtitlesDirectory(
|
||||
episode,
|
||||
showYear: showYear,
|
||||
);
|
||||
Future<String> getEpisodeSubtitlePath(PlexMetadata episode, int trackId, String extension, {int? showYear}) async {
|
||||
final subsDir = await getEpisodeSubtitlesDirectory(episode, showYear: showYear);
|
||||
return path.join(subsDir.path, '$trackId.$extension');
|
||||
}
|
||||
|
||||
@@ -426,17 +341,11 @@ class DownloadStorageService {
|
||||
Future<Directory> getMovieSubtitlesDirectory(PlexMetadata movie) async {
|
||||
final movieDir = await getMovieDirectory(movie);
|
||||
final baseName = _getMovieFolderName(movie);
|
||||
return _ensureDirectoryExists(
|
||||
Directory(path.join(movieDir.path, '${baseName}_subs')),
|
||||
);
|
||||
return _ensureDirectoryExists(Directory(path.join(movieDir.path, '${baseName}_subs')));
|
||||
}
|
||||
|
||||
/// Get movie subtitle path
|
||||
Future<String> getMovieSubtitlePath(
|
||||
PlexMetadata movie,
|
||||
int trackId,
|
||||
String extension,
|
||||
) async {
|
||||
Future<String> getMovieSubtitlePath(PlexMetadata movie, int trackId, String extension) async {
|
||||
final subsDir = await getMovieSubtitlesDirectory(movie);
|
||||
return path.join(subsDir.path, '$trackId.$extension');
|
||||
}
|
||||
@@ -537,9 +446,7 @@ class DownloadStorageService {
|
||||
/// Files are downloaded here first, then copied to SAF if using SAF mode
|
||||
Future<Directory> getCacheDownloadDirectory() async {
|
||||
final cacheDir = await getApplicationDocumentsDirectory();
|
||||
return _ensureDirectoryExists(
|
||||
Directory(path.join(cacheDir.path, '.download_cache')),
|
||||
);
|
||||
return _ensureDirectoryExists(Directory(path.join(cacheDir.path, '.download_cache')));
|
||||
}
|
||||
|
||||
/// Get temporary file path for downloading (before copying to SAF)
|
||||
@@ -551,34 +458,21 @@ class DownloadStorageService {
|
||||
/// Copy a file from temp cache to SAF and return the SAF URI
|
||||
/// Returns null if SAF is not available or copy fails
|
||||
/// Always cleans up temp file regardless of success/failure
|
||||
Future<String?> copyToSaf(
|
||||
String tempFilePath,
|
||||
List<String> pathComponents,
|
||||
String fileName,
|
||||
String mimeType,
|
||||
) async {
|
||||
Future<String?> copyToSaf(String tempFilePath, List<String> pathComponents, String fileName, String mimeType) async {
|
||||
if (!isUsingSaf || _customDownloadPath == null) return null;
|
||||
|
||||
final safService = SafStorageService.instance;
|
||||
|
||||
try {
|
||||
// Create nested directory structure in SAF
|
||||
final targetDirUri = await safService.createNestedDirectories(
|
||||
_customDownloadPath!,
|
||||
pathComponents,
|
||||
);
|
||||
final targetDirUri = await safService.createNestedDirectories(_customDownloadPath!, pathComponents);
|
||||
|
||||
if (targetDirUri == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Copy the file to SAF using native copy
|
||||
final safUri = await safService.copyFileToSaf(
|
||||
tempFilePath,
|
||||
targetDirUri,
|
||||
fileName,
|
||||
mimeType,
|
||||
);
|
||||
final safUri = await safService.copyFileToSaf(tempFilePath, targetDirUri, fileName, mimeType);
|
||||
|
||||
return safUri;
|
||||
} finally {
|
||||
@@ -633,10 +527,7 @@ class DownloadStorageService {
|
||||
}
|
||||
|
||||
/// Get path components for episode SAF storage
|
||||
List<String> getEpisodeSafPathComponents(
|
||||
PlexMetadata episode, {
|
||||
int? showYear,
|
||||
}) {
|
||||
List<String> getEpisodeSafPathComponents(PlexMetadata episode, {int? showYear}) {
|
||||
final showFolder = _getShowFolderName(episode, showYear: showYear);
|
||||
final seasonNum = padNumber(episode.parentIndex ?? 0, 2);
|
||||
return ['TV Shows', showFolder, 'Season $seasonNum'];
|
||||
|
||||
@@ -49,18 +49,11 @@ class EpisodeNavigationService {
|
||||
}
|
||||
|
||||
// Use the play queue for next/previous navigation
|
||||
final next = await playbackState.getNextEpisode(
|
||||
metadata.ratingKey,
|
||||
loopQueue: false,
|
||||
);
|
||||
final previous = await playbackState.getPreviousEpisode(
|
||||
metadata.ratingKey,
|
||||
);
|
||||
final next = await playbackState.getNextEpisode(metadata.ratingKey, loopQueue: false);
|
||||
final previous = await playbackState.getPreviousEpisode(metadata.ratingKey);
|
||||
|
||||
final mode = playbackState.isShuffleActive ? 'Shuffle' : 'Sequential';
|
||||
appLogger.d(
|
||||
'$mode mode - Next: ${next?.title}, Previous: ${previous?.title}',
|
||||
);
|
||||
appLogger.d('$mode mode - Next: ${next?.title}, Previous: ${previous?.title}');
|
||||
|
||||
return AdjacentEpisodes(next: next, previous: previous);
|
||||
} catch (e) {
|
||||
|
||||
@@ -4,8 +4,7 @@ import 'package:window_manager/window_manager.dart';
|
||||
|
||||
/// Global manager for tracking fullscreen state across the app
|
||||
class FullscreenStateManager extends ChangeNotifier with WindowListener {
|
||||
static final FullscreenStateManager _instance =
|
||||
FullscreenStateManager._internal();
|
||||
static final FullscreenStateManager _instance = FullscreenStateManager._internal();
|
||||
|
||||
factory FullscreenStateManager() => _instance;
|
||||
|
||||
|
||||
@@ -54,17 +54,11 @@ class FullscreenWindowDelegate extends NSWindowDelegate {
|
||||
WindowManipulator.makeTitlebarOpaque();
|
||||
|
||||
// Set traffic lights to standard fullscreen positions (null = default)
|
||||
WindowManipulator.overrideStandardWindowButtonPosition(
|
||||
buttonType: NSWindowButtonType.closeButton,
|
||||
offset: null,
|
||||
);
|
||||
WindowManipulator.overrideStandardWindowButtonPosition(buttonType: NSWindowButtonType.closeButton, offset: null);
|
||||
WindowManipulator.overrideStandardWindowButtonPosition(
|
||||
buttonType: NSWindowButtonType.miniaturizeButton,
|
||||
offset: null,
|
||||
);
|
||||
WindowManipulator.overrideStandardWindowButtonPosition(
|
||||
buttonType: NSWindowButtonType.zoomButton,
|
||||
offset: null,
|
||||
);
|
||||
WindowManipulator.overrideStandardWindowButtonPosition(buttonType: NSWindowButtonType.zoomButton, offset: null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,9 +176,7 @@ class GamepadService {
|
||||
final keyDownEvent = KeyDownEvent(
|
||||
physicalKey: _getPhysicalKey(logicalKey),
|
||||
logicalKey: logicalKey,
|
||||
timeStamp: Duration(
|
||||
milliseconds: DateTime.now().millisecondsSinceEpoch,
|
||||
),
|
||||
timeStamp: Duration(milliseconds: DateTime.now().millisecondsSinceEpoch),
|
||||
);
|
||||
|
||||
// Dispatch through the focus system by walking up the focus tree
|
||||
@@ -198,9 +196,7 @@ class GamepadService {
|
||||
final keyUpEvent = KeyUpEvent(
|
||||
physicalKey: _getPhysicalKey(logicalKey),
|
||||
logicalKey: logicalKey,
|
||||
timeStamp: Duration(
|
||||
milliseconds: DateTime.now().millisecondsSinceEpoch,
|
||||
),
|
||||
timeStamp: Duration(milliseconds: DateTime.now().millisecondsSinceEpoch),
|
||||
);
|
||||
|
||||
node = focusNode;
|
||||
@@ -241,10 +237,8 @@ class GamepadService {
|
||||
bool _isDpadXAxis(String key) => key == 'dpad - xaxis';
|
||||
|
||||
// Face buttons - macOS uses SF Symbol names for PlayStation controllers
|
||||
bool _isButtonA(String key) =>
|
||||
key == 'xmark.circle'; // Cross/X button (bottom)
|
||||
bool _isButtonB(String key) =>
|
||||
key == 'circle.circle'; // Circle/O button (right)
|
||||
bool _isButtonA(String key) => key == 'xmark.circle'; // Cross/X button (bottom)
|
||||
bool _isButtonB(String key) => key == 'circle.circle'; // Circle/O button (right)
|
||||
bool _isButtonX(String key) => key == 'square.circle'; // Square button (left)
|
||||
|
||||
// Analog sticks
|
||||
@@ -323,10 +317,8 @@ class GamepadService {
|
||||
// via gamepad. Synthetic key events we dispatch below don't go through the
|
||||
// platform key pipeline, so Flutter won't automatically flip highlight mode.
|
||||
void _setTraditionalFocusHighlight() {
|
||||
if (FocusManager.instance.highlightStrategy !=
|
||||
FocusHighlightStrategy.alwaysTraditional) {
|
||||
FocusManager.instance.highlightStrategy =
|
||||
FocusHighlightStrategy.alwaysTraditional;
|
||||
if (FocusManager.instance.highlightStrategy != FocusHighlightStrategy.alwaysTraditional) {
|
||||
FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTraditional;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,8 +10,7 @@ import '../utils/player_utils.dart';
|
||||
class KeyboardShortcutsService {
|
||||
static KeyboardShortcutsService? _instance;
|
||||
late SettingsService _settingsService;
|
||||
Map<String, String> _shortcuts =
|
||||
{}; // Legacy string shortcuts for backward compatibility
|
||||
Map<String, String> _shortcuts = {}; // Legacy string shortcuts for backward compatibility
|
||||
Map<String, HotKey> _hotkeys = {}; // New HotKey objects
|
||||
int _seekTimeSmall = 10; // Default, loaded from settings
|
||||
int _seekTimeLarge = 30; // Default, loaded from settings
|
||||
@@ -35,8 +34,7 @@ class KeyboardShortcutsService {
|
||||
_settingsService = await SettingsService.getInstance();
|
||||
// Ensure settings service is fully initialized before loading data
|
||||
await Future.delayed(Duration.zero); // Allow event loop to complete
|
||||
_shortcuts = _settingsService
|
||||
.getKeyboardShortcuts(); // Keep for legacy compatibility
|
||||
_shortcuts = _settingsService.getKeyboardShortcuts(); // Keep for legacy compatibility
|
||||
_hotkeys = await _settingsService.getKeyboardHotkeys(); // Primary method
|
||||
_seekTimeSmall = _settingsService.getSeekTimeSmall();
|
||||
_seekTimeLarge = _settingsService.getSeekTimeLarge();
|
||||
@@ -394,7 +392,6 @@ class KeyboardShortcutsService {
|
||||
final aModifiers = Set.from(a.modifiers ?? []);
|
||||
final bModifiers = Set.from(b.modifiers ?? []);
|
||||
|
||||
return aModifiers.length == bModifiers.length &&
|
||||
aModifiers.every((modifier) => bModifiers.contains(modifier));
|
||||
return aModifiers.length == bModifiers.length && aModifiers.every((modifier) => bModifiers.contains(modifier));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,11 +36,7 @@ class MediaControlsManager {
|
||||
/// Update media metadata displayed in OS media controls
|
||||
///
|
||||
/// This includes title, artist, artwork, and duration.
|
||||
Future<void> updateMetadata({
|
||||
required PlexMetadata metadata,
|
||||
PlexClient? client,
|
||||
Duration? duration,
|
||||
}) async {
|
||||
Future<void> updateMetadata({required PlexMetadata metadata, PlexClient? client, Duration? duration}) async {
|
||||
try {
|
||||
// Build artwork URL if client is available
|
||||
String? artworkUrl;
|
||||
@@ -79,11 +75,7 @@ class MediaControlsManager {
|
||||
required double speed,
|
||||
bool force = false,
|
||||
}) async {
|
||||
final params = _PlaybackStateParams(
|
||||
isPlaying: isPlaying,
|
||||
position: position,
|
||||
speed: speed,
|
||||
);
|
||||
final params = _PlaybackStateParams(isPlaying: isPlaying, position: position, speed: speed);
|
||||
|
||||
if (force) {
|
||||
// Bypass throttling for forced updates
|
||||
@@ -99,9 +91,7 @@ class MediaControlsManager {
|
||||
try {
|
||||
await OsMediaControls.setPlaybackState(
|
||||
MediaPlaybackState(
|
||||
state: params.isPlaying
|
||||
? PlaybackState.playing
|
||||
: PlaybackState.paused,
|
||||
state: params.isPlaying ? PlaybackState.playing : PlaybackState.paused,
|
||||
position: params.position,
|
||||
speed: params.speed,
|
||||
),
|
||||
@@ -118,10 +108,7 @@ class MediaControlsManager {
|
||||
/// - Episodes: Enable both if there are adjacent episodes
|
||||
/// - Playlist items: Enable based on playlist position
|
||||
/// - Movies: Usually disabled
|
||||
Future<void> setControlsEnabled({
|
||||
bool canGoNext = false,
|
||||
bool canGoPrevious = false,
|
||||
}) async {
|
||||
Future<void> setControlsEnabled({bool canGoNext = false, bool canGoPrevious = false}) async {
|
||||
// Skip if unchanged (avoid redundant platform calls)
|
||||
if (canGoNext == _lastCanGoNext && canGoPrevious == _lastCanGoPrevious) {
|
||||
return;
|
||||
@@ -137,14 +124,9 @@ class MediaControlsManager {
|
||||
|
||||
if (controls.isNotEmpty) {
|
||||
await OsMediaControls.enableControls(controls);
|
||||
appLogger.d(
|
||||
'Media controls enabled - Previous: $canGoPrevious, Next: $canGoNext',
|
||||
);
|
||||
appLogger.d('Media controls enabled - Previous: $canGoPrevious, Next: $canGoNext');
|
||||
} else {
|
||||
await OsMediaControls.disableControls([
|
||||
MediaControl.previous,
|
||||
MediaControl.next,
|
||||
]);
|
||||
await OsMediaControls.disableControls([MediaControl.previous, MediaControl.next]);
|
||||
appLogger.d('Media controls disabled');
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -210,9 +192,5 @@ class _PlaybackStateParams {
|
||||
final Duration position;
|
||||
final double speed;
|
||||
|
||||
const _PlaybackStateParams({
|
||||
required this.isPlaying,
|
||||
required this.position,
|
||||
required this.speed,
|
||||
});
|
||||
const _PlaybackStateParams({required this.isPlaying, required this.position, required this.speed});
|
||||
}
|
||||
|
||||
@@ -35,12 +35,10 @@ class MultiServerManager {
|
||||
List<String> get serverIds => _servers.keys.toList();
|
||||
|
||||
/// Get all online server IDs
|
||||
List<String> get onlineServerIds =>
|
||||
_serverStatus.entries.where((e) => e.value).map((e) => e.key).toList();
|
||||
List<String> get onlineServerIds => _serverStatus.entries.where((e) => e.value).map((e) => e.key).toList();
|
||||
|
||||
/// Get all offline server IDs
|
||||
List<String> get offlineServerIds =>
|
||||
_serverStatus.entries.where((e) => !e.value).map((e) => e.key).toList();
|
||||
List<String> get offlineServerIds => _serverStatus.entries.where((e) => !e.value).map((e) => e.key).toList();
|
||||
|
||||
/// Get client for specific server
|
||||
PlexClient? getClient(String serverId) => _clients[serverId];
|
||||
@@ -70,10 +68,7 @@ class MultiServerManager {
|
||||
///
|
||||
/// Handles finding working connection, loading cached endpoint,
|
||||
/// creating config, and building client with failover support.
|
||||
Future<PlexClient> _createClientForServer({
|
||||
required PlexServer server,
|
||||
required String clientIdentifier,
|
||||
}) async {
|
||||
Future<PlexClient> _createClientForServer({required PlexServer server, required String clientIdentifier}) async {
|
||||
final serverId = server.clientIdentifier;
|
||||
|
||||
// Find best working connection
|
||||
@@ -94,9 +89,7 @@ class MultiServerManager {
|
||||
final cachedEndpoint = storage.getServerEndpoint(serverId);
|
||||
|
||||
// Create PlexClient with failover support
|
||||
final prioritizedEndpoints = server.prioritizedEndpointUrls(
|
||||
preferredFirst: cachedEndpoint ?? baseUrl,
|
||||
);
|
||||
final prioritizedEndpoints = server.prioritizedEndpointUrls(preferredFirst: cachedEndpoint ?? baseUrl);
|
||||
final config = await PlexConfig.create(
|
||||
baseUrl: baseUrl,
|
||||
token: server.accessToken,
|
||||
@@ -110,9 +103,7 @@ class MultiServerManager {
|
||||
prioritizedEndpoints: prioritizedEndpoints,
|
||||
onEndpointChanged: (newUrl) async {
|
||||
await storage.saveServerEndpoint(serverId, newUrl);
|
||||
appLogger.i(
|
||||
'Updated endpoint for ${server.name} after failover: $newUrl',
|
||||
);
|
||||
appLogger.i('Updated endpoint for ${server.name} after failover: $newUrl');
|
||||
},
|
||||
);
|
||||
|
||||
@@ -139,8 +130,7 @@ class MultiServerManager {
|
||||
appLogger.i('Connecting to ${servers.length} servers...');
|
||||
|
||||
// Use provided client ID or generate a unique one for this app instance
|
||||
final effectiveClientId =
|
||||
clientIdentifier ?? DateTime.now().millisecondsSinceEpoch.toString();
|
||||
final effectiveClientId = clientIdentifier ?? DateTime.now().millisecondsSinceEpoch.toString();
|
||||
|
||||
// Create connection tasks for all servers
|
||||
final connectionFutures = servers.map((server) async {
|
||||
@@ -149,10 +139,7 @@ class MultiServerManager {
|
||||
try {
|
||||
appLogger.d('Attempting connection to server: ${server.name}');
|
||||
|
||||
final client = await _createClientForServer(
|
||||
server: server,
|
||||
clientIdentifier: effectiveClientId,
|
||||
);
|
||||
final client = await _createClientForServer(server: server, clientIdentifier: effectiveClientId);
|
||||
|
||||
// Store the client and server info
|
||||
_clients[serverId] = client;
|
||||
@@ -164,11 +151,7 @@ class MultiServerManager {
|
||||
|
||||
return serverId;
|
||||
} catch (e, stackTrace) {
|
||||
appLogger.e(
|
||||
'Failed to connect to ${server.name}',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
appLogger.e('Failed to connect to ${server.name}', error: e, stackTrace: stackTrace);
|
||||
|
||||
// Mark as offline
|
||||
_servers[serverId] = server;
|
||||
@@ -198,9 +181,7 @@ class MultiServerManager {
|
||||
// Notify listeners of status change
|
||||
_statusController.add(Map.from(_serverStatus));
|
||||
|
||||
appLogger.i(
|
||||
'Connected to $successCount/${servers.length} servers successfully',
|
||||
);
|
||||
appLogger.i('Connected to $successCount/${servers.length} servers successfully');
|
||||
|
||||
// Start network monitoring if we have any connected servers
|
||||
if (successCount > 0) {
|
||||
@@ -213,16 +194,12 @@ class MultiServerManager {
|
||||
/// Add a single server connection
|
||||
Future<bool> addServer(PlexServer server, {String? clientIdentifier}) async {
|
||||
final serverId = server.clientIdentifier;
|
||||
final effectiveClientId =
|
||||
clientIdentifier ?? DateTime.now().millisecondsSinceEpoch.toString();
|
||||
final effectiveClientId = clientIdentifier ?? DateTime.now().millisecondsSinceEpoch.toString();
|
||||
|
||||
try {
|
||||
appLogger.d('Adding server: ${server.name}');
|
||||
|
||||
final client = await _createClientForServer(
|
||||
server: server,
|
||||
clientIdentifier: effectiveClientId,
|
||||
);
|
||||
final client = await _createClientForServer(server: server, clientIdentifier: effectiveClientId);
|
||||
|
||||
// Store
|
||||
_clients[serverId] = client;
|
||||
@@ -235,11 +212,7 @@ class MultiServerManager {
|
||||
appLogger.i('Successfully added server: ${server.name}');
|
||||
return true;
|
||||
} catch (e, stackTrace) {
|
||||
appLogger.e(
|
||||
'Failed to add server ${server.name}',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
appLogger.e('Failed to add server ${server.name}', error: e, stackTrace: stackTrace);
|
||||
|
||||
_servers[serverId] = server;
|
||||
_serverStatus[serverId] = false;
|
||||
@@ -299,14 +272,10 @@ class MultiServerManager {
|
||||
final connectivity = Connectivity();
|
||||
_connectivitySubscription = connectivity.onConnectivityChanged.listen(
|
||||
(results) {
|
||||
final status = results.isNotEmpty
|
||||
? results.first
|
||||
: ConnectivityResult.none;
|
||||
final status = results.isNotEmpty ? results.first : ConnectivityResult.none;
|
||||
|
||||
if (status == ConnectivityResult.none) {
|
||||
appLogger.w(
|
||||
'Connectivity lost, pausing optimization until network returns',
|
||||
);
|
||||
appLogger.w('Connectivity lost, pausing optimization until network returns');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -323,11 +292,7 @@ class MultiServerManager {
|
||||
_reoptimizeAllServers(reason: 'connectivity:${status.name}');
|
||||
},
|
||||
onError: (error, stackTrace) {
|
||||
appLogger.w(
|
||||
'Connectivity listener error',
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
appLogger.w('Connectivity listener error', error: error, stackTrace: stackTrace);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -352,48 +317,32 @@ class MultiServerManager {
|
||||
|
||||
// Skip if optimization already running for this server
|
||||
if (_activeOptimizations.containsKey(serverId)) {
|
||||
appLogger.d(
|
||||
'Optimization already running for ${server.name}, skipping',
|
||||
error: {'reason': reason},
|
||||
);
|
||||
appLogger.d('Optimization already running for ${server.name}, skipping', error: {'reason': reason});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Run optimization
|
||||
_activeOptimizations[serverId] =
|
||||
_reoptimizeServer(
|
||||
serverId: serverId,
|
||||
server: server,
|
||||
reason: reason,
|
||||
).whenComplete(() {
|
||||
_activeOptimizations[serverId] = _reoptimizeServer(serverId: serverId, server: server, reason: reason)
|
||||
.whenComplete(() {
|
||||
_activeOptimizations.remove(serverId);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-optimize connection for a specific server
|
||||
Future<void> _reoptimizeServer({
|
||||
required String serverId,
|
||||
required PlexServer server,
|
||||
required String reason,
|
||||
}) async {
|
||||
Future<void> _reoptimizeServer({required String serverId, required PlexServer server, required String reason}) async {
|
||||
final storage = await StorageService.getInstance();
|
||||
final client = _clients[serverId];
|
||||
|
||||
try {
|
||||
appLogger.d(
|
||||
'Starting connection optimization for ${server.name}',
|
||||
error: {'reason': reason},
|
||||
);
|
||||
appLogger.d('Starting connection optimization for ${server.name}', error: {'reason': reason});
|
||||
|
||||
await for (final connection in server.findBestWorkingConnection()) {
|
||||
final newUrl = connection.uri;
|
||||
|
||||
// Check if this is actually a better connection than current
|
||||
if (client != null && client.config.baseUrl == newUrl) {
|
||||
appLogger.d(
|
||||
'Already using optimal endpoint for ${server.name}: $newUrl',
|
||||
);
|
||||
appLogger.d('Already using optimal endpoint for ${server.name}: $newUrl');
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -402,17 +351,10 @@ class MultiServerManager {
|
||||
|
||||
// If client has endpoint failover, it will automatically switch
|
||||
// Otherwise, we might need to recreate the client (but failover should handle it)
|
||||
appLogger.i(
|
||||
'Updated optimal endpoint for ${server.name}: $newUrl',
|
||||
error: {'type': connection.displayType},
|
||||
);
|
||||
appLogger.i('Updated optimal endpoint for ${server.name}: $newUrl', error: {'type': connection.displayType});
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
appLogger.w(
|
||||
'Connection optimization failed for ${server.name}',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
appLogger.w('Connection optimization failed for ${server.name}', error: e, stackTrace: stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,11 +37,9 @@ class OfflineWatchSyncService extends ChangeNotifier {
|
||||
/// Maximum sync attempts before giving up on an item
|
||||
static const int maxSyncAttempts = 5;
|
||||
|
||||
OfflineWatchSyncService({
|
||||
required AppDatabase database,
|
||||
required MultiServerManager serverManager,
|
||||
}) : _database = database,
|
||||
_serverManager = serverManager;
|
||||
OfflineWatchSyncService({required AppDatabase database, required MultiServerManager serverManager})
|
||||
: _database = database,
|
||||
_serverManager = serverManager;
|
||||
|
||||
/// Whether a sync is currently in progress
|
||||
bool get isSyncing => _isSyncing;
|
||||
@@ -57,9 +55,7 @@ class OfflineWatchSyncService extends ChangeNotifier {
|
||||
_offlineModeListener = () {
|
||||
if (!offlineModeProvider.isOffline) {
|
||||
// We just came online - trigger bidirectional sync
|
||||
appLogger.i(
|
||||
'Connectivity restored - starting bidirectional watch sync',
|
||||
);
|
||||
appLogger.i('Connectivity restored - starting bidirectional watch sync');
|
||||
_performBidirectionalSync();
|
||||
}
|
||||
};
|
||||
@@ -163,26 +159,14 @@ class OfflineWatchSyncService extends ChangeNotifier {
|
||||
/// Queue a manual "mark as watched" action.
|
||||
///
|
||||
/// Removes any conflicting actions for the same item.
|
||||
Future<void> queueMarkWatched({
|
||||
required String serverId,
|
||||
required String ratingKey,
|
||||
}) => _queueWatchStatusAction(
|
||||
serverId: serverId,
|
||||
ratingKey: ratingKey,
|
||||
actionType: 'watched',
|
||||
);
|
||||
Future<void> queueMarkWatched({required String serverId, required String ratingKey}) =>
|
||||
_queueWatchStatusAction(serverId: serverId, ratingKey: ratingKey, actionType: 'watched');
|
||||
|
||||
/// Queue a manual "mark as unwatched" action.
|
||||
///
|
||||
/// Removes any conflicting actions for the same item.
|
||||
Future<void> queueMarkUnwatched({
|
||||
required String serverId,
|
||||
required String ratingKey,
|
||||
}) => _queueWatchStatusAction(
|
||||
serverId: serverId,
|
||||
ratingKey: ratingKey,
|
||||
actionType: 'unwatched',
|
||||
);
|
||||
Future<void> queueMarkUnwatched({required String serverId, required String ratingKey}) =>
|
||||
_queueWatchStatusAction(serverId: serverId, ratingKey: ratingKey, actionType: 'unwatched');
|
||||
|
||||
/// Internal helper to queue watch/unwatch actions.
|
||||
Future<void> _queueWatchStatusAction({
|
||||
@@ -190,11 +174,7 @@ class OfflineWatchSyncService extends ChangeNotifier {
|
||||
required String ratingKey,
|
||||
required String actionType,
|
||||
}) async {
|
||||
await _database.insertWatchAction(
|
||||
serverId: serverId,
|
||||
ratingKey: ratingKey,
|
||||
actionType: actionType,
|
||||
);
|
||||
await _database.insertWatchAction(serverId: serverId, ratingKey: ratingKey, actionType: actionType);
|
||||
|
||||
appLogger.d('Queued offline mark $actionType: $serverId:$ratingKey');
|
||||
notifyListeners();
|
||||
@@ -233,9 +213,7 @@ class OfflineWatchSyncService extends ChangeNotifier {
|
||||
///
|
||||
/// Returns a map of globalKey -> watch status (true/false/null).
|
||||
/// More efficient than calling getLocalWatchStatus multiple times.
|
||||
Future<Map<String, bool?>> getLocalWatchStatusesBatched(
|
||||
Set<String> globalKeys,
|
||||
) async {
|
||||
Future<Map<String, bool?>> getLocalWatchStatusesBatched(Set<String> globalKeys) async {
|
||||
if (globalKeys.isEmpty) return {};
|
||||
|
||||
final actions = await _database.getLatestWatchActionsForKeys(globalKeys);
|
||||
@@ -322,9 +300,7 @@ class OfflineWatchSyncService extends ChangeNotifier {
|
||||
|
||||
// Check if server still exists
|
||||
if (_serverManager.getServer(action.serverId) == null) {
|
||||
appLogger.w(
|
||||
'Deleting action ${action.id} - server ${action.serverId} no longer exists',
|
||||
);
|
||||
appLogger.w('Deleting action ${action.id} - server ${action.serverId} no longer exists');
|
||||
await _database.deleteWatchAction(action.id);
|
||||
continue;
|
||||
}
|
||||
@@ -343,9 +319,7 @@ class OfflineWatchSyncService extends ChangeNotifier {
|
||||
await _syncAction(client, action);
|
||||
// Success - delete the action from queue
|
||||
await _database.deleteWatchAction(action.id);
|
||||
appLogger.d(
|
||||
'Successfully synced action ${action.id}: ${action.actionType} for ${action.ratingKey}',
|
||||
);
|
||||
appLogger.d('Successfully synced action ${action.id}: ${action.actionType} for ${action.ratingKey}');
|
||||
} catch (e) {
|
||||
appLogger.w('Failed to sync action ${action.id}: $e');
|
||||
await _database.updateSyncAttempt(action.id, e.toString());
|
||||
@@ -354,18 +328,12 @@ class OfflineWatchSyncService extends ChangeNotifier {
|
||||
});
|
||||
|
||||
// If _withOnlineClient returned null (server offline), mark actions for retry
|
||||
if (_serverManager.getClient(serverId) == null ||
|
||||
!_serverManager.isServerOnline(serverId)) {
|
||||
if (_serverManager.getClient(serverId) == null || !_serverManager.isServerOnline(serverId)) {
|
||||
for (final action in actions) {
|
||||
// Only update if we haven't already processed it
|
||||
final stillPending = await _database.getLatestWatchAction(
|
||||
'${action.serverId}:${action.ratingKey}',
|
||||
);
|
||||
final stillPending = await _database.getLatestWatchAction('${action.serverId}:${action.ratingKey}');
|
||||
if (stillPending != null && stillPending.id == action.id) {
|
||||
await _database.updateSyncAttempt(
|
||||
action.id,
|
||||
'Server not available',
|
||||
);
|
||||
await _database.updateSyncAttempt(action.id, 'Server not available');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -380,10 +348,7 @@ class OfflineWatchSyncService extends ChangeNotifier {
|
||||
///
|
||||
/// 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 {
|
||||
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');
|
||||
@@ -399,10 +364,7 @@ class OfflineWatchSyncService extends ChangeNotifier {
|
||||
}
|
||||
|
||||
/// Sync a single action to the server.
|
||||
Future<void> _syncAction(
|
||||
PlexClient client,
|
||||
OfflineWatchProgressItem action,
|
||||
) async {
|
||||
Future<void> _syncAction(PlexClient client, OfflineWatchProgressItem action) async {
|
||||
switch (action.actionType) {
|
||||
case 'watched':
|
||||
await client.markAsWatched(action.ratingKey);
|
||||
@@ -418,8 +380,7 @@ class OfflineWatchSyncService extends ChangeNotifier {
|
||||
await client.updateProgress(
|
||||
action.ratingKey,
|
||||
time: action.viewOffset!,
|
||||
state:
|
||||
'stopped', // Use 'stopped' since we're syncing after the fact
|
||||
state: 'stopped', // Use 'stopped' since we're syncing after the fact
|
||||
duration: action.duration,
|
||||
);
|
||||
}
|
||||
@@ -449,9 +410,7 @@ class OfflineWatchSyncService extends ChangeNotifier {
|
||||
return;
|
||||
}
|
||||
|
||||
appLogger.i(
|
||||
'Syncing watch states from server for ${downloadedItems.length} items',
|
||||
);
|
||||
appLogger.i('Syncing watch states from server for ${downloadedItems.length} items');
|
||||
|
||||
// Separate episodes (with season parent) from other items (movies, etc.)
|
||||
// Structure: serverId -> seasonRatingKey -> Set<episodeRatingKey>
|
||||
@@ -468,9 +427,7 @@ class OfflineWatchSyncService extends ChangeNotifier {
|
||||
.add(item.ratingKey);
|
||||
} else {
|
||||
// Movies, or episodes without parent (fallback to individual fetch)
|
||||
nonEpisodeItems
|
||||
.putIfAbsent(item.serverId, () => [])
|
||||
.add(item.ratingKey);
|
||||
nonEpisodeItems.putIfAbsent(item.serverId, () => []).add(item.ratingKey);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -495,22 +452,16 @@ class OfflineWatchSyncService extends ChangeNotifier {
|
||||
// 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()],
|
||||
},
|
||||
await PlexApiCache.instance.put(serverId, '/library/metadata/${episode.ratingKey}', {
|
||||
'MediaContainer': {
|
||||
'Metadata': [episode.toJson()],
|
||||
},
|
||||
);
|
||||
});
|
||||
syncedCount++;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.d(
|
||||
'Failed to sync watch states for season $seasonRatingKey: $e',
|
||||
);
|
||||
appLogger.d('Failed to sync watch states for season $seasonRatingKey: $e');
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -526,15 +477,11 @@ class OfflineWatchSyncService extends ChangeNotifier {
|
||||
try {
|
||||
final metadata = await client.getMetadataWithImages(ratingKey);
|
||||
if (metadata != null) {
|
||||
await PlexApiCache.instance.put(
|
||||
serverId,
|
||||
'/library/metadata/$ratingKey',
|
||||
{
|
||||
'MediaContainer': {
|
||||
'Metadata': [metadata.toJson()],
|
||||
},
|
||||
await PlexApiCache.instance.put(serverId, '/library/metadata/$ratingKey', {
|
||||
'MediaContainer': {
|
||||
'Metadata': [metadata.toJson()],
|
||||
},
|
||||
);
|
||||
});
|
||||
syncedCount++;
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -545,9 +492,7 @@ class OfflineWatchSyncService extends ChangeNotifier {
|
||||
}
|
||||
|
||||
final movieCount = nonEpisodeItems.values.fold(0, (a, b) => a + b.length);
|
||||
appLogger.i(
|
||||
'Synced watch states: $seasonCount seasons, $movieCount other items ($syncedCount total)',
|
||||
);
|
||||
appLogger.i('Synced watch states: $seasonCount seasons, $movieCount other items ($syncedCount total)');
|
||||
|
||||
// Notify download provider to refresh metadata from updated cache
|
||||
if (syncedCount > 0) {
|
||||
|
||||
@@ -42,12 +42,7 @@ class PlayQueueLauncher {
|
||||
final String? serverId;
|
||||
final String? serverName;
|
||||
|
||||
PlayQueueLauncher({
|
||||
required this.context,
|
||||
required this.client,
|
||||
this.serverId,
|
||||
this.serverName,
|
||||
});
|
||||
PlayQueueLauncher({required this.context, required this.client, this.serverId, this.serverName});
|
||||
|
||||
/// Launch playback from a collection or playlist.
|
||||
Future<PlayQueueResult> launchFromCollectionOrPlaylist({
|
||||
@@ -59,9 +54,7 @@ class PlayQueueLauncher {
|
||||
final isPlaylist = item is PlexPlaylist;
|
||||
|
||||
if (!isCollection && !isPlaylist) {
|
||||
return PlayQueueError(
|
||||
Exception('Item must be either a collection or playlist'),
|
||||
);
|
||||
return PlayQueueError(Exception('Item must be either a collection or playlist'));
|
||||
}
|
||||
|
||||
return _executeWithLoading(
|
||||
@@ -76,21 +69,14 @@ class PlayQueueLauncher {
|
||||
|
||||
if (isCollection) {
|
||||
// Get machine identifier (fetch if not cached in config)
|
||||
final machineId =
|
||||
client.config.machineIdentifier ??
|
||||
await client.getMachineIdentifier();
|
||||
final machineId = client.config.machineIdentifier ?? await client.getMachineIdentifier();
|
||||
|
||||
if (machineId == null) {
|
||||
throw Exception('Could not get server machine identifier');
|
||||
}
|
||||
|
||||
final collectionUri =
|
||||
'server://$machineId/com.plexapp.plugins.library/library/collections/${item.ratingKey}';
|
||||
playQueue = await client.createPlayQueue(
|
||||
uri: collectionUri,
|
||||
type: 'video',
|
||||
shuffle: shuffle ? 1 : 0,
|
||||
);
|
||||
final collectionUri = 'server://$machineId/com.plexapp.plugins.library/library/collections/${item.ratingKey}';
|
||||
playQueue = await client.createPlayQueue(uri: collectionUri, type: 'video', shuffle: shuffle ? 1 : 0);
|
||||
} else {
|
||||
// For playlists, use playlistID parameter
|
||||
playQueue = await client.createPlayQueue(
|
||||
@@ -101,12 +87,9 @@ class PlayQueueLauncher {
|
||||
}
|
||||
|
||||
// If the queue is empty, try fetching it again with getPlayQueue
|
||||
if (playQueue != null &&
|
||||
(playQueue.items == null || playQueue.items!.isEmpty)) {
|
||||
if (playQueue != null && (playQueue.items == null || playQueue.items!.isEmpty)) {
|
||||
final fetchedQueue = await client.getPlayQueue(playQueue.playQueueID);
|
||||
if (fetchedQueue != null &&
|
||||
fetchedQueue.items != null &&
|
||||
fetchedQueue.items!.isNotEmpty) {
|
||||
if (fetchedQueue != null && fetchedQueue.items != null && fetchedQueue.items!.isNotEmpty) {
|
||||
playQueue = fetchedQueue;
|
||||
}
|
||||
}
|
||||
@@ -155,16 +138,11 @@ class PlayQueueLauncher {
|
||||
}
|
||||
|
||||
/// Launch shuffled playback for a show or season.
|
||||
Future<PlayQueueResult> launchShuffledShow({
|
||||
required PlexMetadata metadata,
|
||||
bool showLoadingIndicator = true,
|
||||
}) async {
|
||||
Future<PlayQueueResult> launchShuffledShow({required PlexMetadata metadata, bool showLoadingIndicator = true}) async {
|
||||
final mediaType = metadata.mediaType;
|
||||
|
||||
if (mediaType != PlexMediaType.show && mediaType != PlexMediaType.season) {
|
||||
return PlayQueueError(
|
||||
Exception('Shuffle play only works for shows and seasons'),
|
||||
);
|
||||
return PlayQueueError(Exception('Shuffle play only works for shows and seasons'));
|
||||
}
|
||||
|
||||
return _executeWithLoading(
|
||||
@@ -183,10 +161,7 @@ class PlayQueueLauncher {
|
||||
showRatingKey = metadata.parentRatingKey!;
|
||||
}
|
||||
|
||||
final playQueue = await client.createShowPlayQueue(
|
||||
showRatingKey: showRatingKey,
|
||||
shuffle: 1,
|
||||
);
|
||||
final playQueue = await client.createShowPlayQueue(showRatingKey: showRatingKey, shuffle: 1);
|
||||
|
||||
// Close loading dialog before navigating to the player
|
||||
await dismissLoading();
|
||||
@@ -211,9 +186,7 @@ class PlayQueueLauncher {
|
||||
PlexMetadata? selectedItem,
|
||||
bool copyServerInfo = false,
|
||||
}) async {
|
||||
if (playQueue == null ||
|
||||
playQueue.items == null ||
|
||||
playQueue.items!.isEmpty) {
|
||||
if (playQueue == null || playQueue.items == null || playQueue.items!.isEmpty) {
|
||||
return const PlayQueueEmpty();
|
||||
}
|
||||
|
||||
@@ -222,12 +195,7 @@ class PlayQueueLauncher {
|
||||
// Set up playback state
|
||||
final playbackState = context.read<PlaybackStateProvider>();
|
||||
playbackState.setClient(client);
|
||||
await playbackState.setPlaybackFromPlayQueue(
|
||||
playQueue,
|
||||
ratingKey,
|
||||
serverId: serverId,
|
||||
serverName: serverName,
|
||||
);
|
||||
await playbackState.setPlaybackFromPlayQueue(playQueue, ratingKey, serverId: serverId, serverName: serverName);
|
||||
|
||||
if (!context.mounted) return const PlayQueueError('Context not mounted');
|
||||
|
||||
@@ -236,10 +204,7 @@ class PlayQueueLauncher {
|
||||
|
||||
// Copy server info if needed
|
||||
if (copyServerInfo && serverId != null) {
|
||||
itemToPlay = itemToPlay.copyWith(
|
||||
serverId: serverId,
|
||||
serverName: serverName,
|
||||
);
|
||||
itemToPlay = itemToPlay.copyWith(serverId: serverId, serverName: serverName);
|
||||
}
|
||||
|
||||
// Navigate to video player
|
||||
@@ -252,9 +217,7 @@ class PlayQueueLauncher {
|
||||
Future<PlayQueueResult> _executeWithLoading({
|
||||
required bool showLoading,
|
||||
required String action,
|
||||
required Future<PlayQueueResult> Function(
|
||||
Future<void> Function() dismissLoading,
|
||||
) execute,
|
||||
required Future<PlayQueueResult> Function(Future<void> Function() dismissLoading) execute,
|
||||
}) async {
|
||||
BuildContext? loadingDialogContext;
|
||||
var loadingVisible = false;
|
||||
@@ -301,10 +264,7 @@ class PlayQueueLauncher {
|
||||
appLogger.e('Failed to $action', error: e);
|
||||
|
||||
if (context.mounted) {
|
||||
showErrorSnackBar(
|
||||
context,
|
||||
t.messages.failedPlayback(action: action, error: e.toString()),
|
||||
);
|
||||
showErrorSnackBar(context, t.messages.failedPlayback(action: action, error: e.toString()));
|
||||
}
|
||||
|
||||
await dismissLoading();
|
||||
|
||||
@@ -34,16 +34,12 @@ class PlaybackInitializationService {
|
||||
try {
|
||||
// Query database for downloaded media with matching serverId and ratingKey
|
||||
final query = database!.select(database!.downloadedMedia)
|
||||
..where(
|
||||
(tbl) =>
|
||||
tbl.serverId.equals(serverId) & tbl.ratingKey.equals(ratingKey),
|
||||
);
|
||||
..where((tbl) => tbl.serverId.equals(serverId) & tbl.ratingKey.equals(ratingKey));
|
||||
|
||||
final downloadedItem = await query.getSingleOrNull();
|
||||
|
||||
// Return null if not found or not completed
|
||||
if (downloadedItem == null ||
|
||||
downloadedItem.status != DownloadStatus.completed.index) {
|
||||
if (downloadedItem == null || downloadedItem.status != DownloadStatus.completed.index) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -62,9 +58,7 @@ class PlaybackInitializationService {
|
||||
if (!storageService.isSafUri(storedPath)) {
|
||||
final file = File(readablePath);
|
||||
if (!await file.exists()) {
|
||||
appLogger.w(
|
||||
'Offline video file not found: $readablePath (stored as: $storedPath)',
|
||||
);
|
||||
appLogger.w('Offline video file not found: $readablePath (stored as: $storedPath)');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -90,10 +84,7 @@ class PlaybackInitializationService {
|
||||
// Check for offline content first if preferOffline is enabled
|
||||
String? offlineVideoPath;
|
||||
if (preferOffline && database != null) {
|
||||
offlineVideoPath = await getOfflineVideoPath(
|
||||
client.serverId,
|
||||
metadata.ratingKey,
|
||||
);
|
||||
offlineVideoPath = await getOfflineVideoPath(client.serverId, metadata.ratingKey);
|
||||
}
|
||||
|
||||
// If offline video is available, use it
|
||||
@@ -103,15 +94,10 @@ class PlaybackInitializationService {
|
||||
// For offline playback, we still need to fetch media info for subtitles
|
||||
// but use the local file path for video
|
||||
try {
|
||||
final playbackData = await client.getVideoPlaybackData(
|
||||
metadata.ratingKey,
|
||||
mediaIndex: selectedMediaIndex,
|
||||
);
|
||||
final playbackData = await client.getVideoPlaybackData(metadata.ratingKey, mediaIndex: selectedMediaIndex);
|
||||
|
||||
// Build list of external subtitle tracks
|
||||
final externalSubtitles = _buildExternalSubtitles(
|
||||
playbackData.mediaInfo,
|
||||
);
|
||||
final externalSubtitles = _buildExternalSubtitles(playbackData.mediaInfo);
|
||||
|
||||
// Return result with local file path
|
||||
return PlaybackInitializationResult(
|
||||
@@ -123,10 +109,7 @@ class PlaybackInitializationService {
|
||||
);
|
||||
} catch (e) {
|
||||
// If we can't fetch media info (e.g., no network), use offline-only mode
|
||||
appLogger.w(
|
||||
'Failed to fetch media info for offline video, using offline-only mode',
|
||||
error: e,
|
||||
);
|
||||
appLogger.w('Failed to fetch media info for offline video, using offline-only mode', error: e);
|
||||
return PlaybackInitializationResult(
|
||||
availableVersions: [],
|
||||
videoUrl: _formatVideoUrl(offlineVideoPath),
|
||||
@@ -138,10 +121,7 @@ class PlaybackInitializationService {
|
||||
}
|
||||
|
||||
// Fall back to network streaming
|
||||
final playbackData = await client.getVideoPlaybackData(
|
||||
metadata.ratingKey,
|
||||
mediaIndex: selectedMediaIndex,
|
||||
);
|
||||
final playbackData = await client.getVideoPlaybackData(metadata.ratingKey, mediaIndex: selectedMediaIndex);
|
||||
|
||||
if (!playbackData.hasValidVideoUrl) {
|
||||
throw PlaybackException(t.messages.fileInfoNotAvailable);
|
||||
@@ -174,9 +154,7 @@ class PlaybackInitializationService {
|
||||
return externalSubtitles;
|
||||
}
|
||||
|
||||
final externalTracks = mediaInfo.subtitleTracks
|
||||
.where((PlexSubtitleTrack track) => track.isExternal)
|
||||
.toList();
|
||||
final externalTracks = mediaInfo.subtitleTracks.where((PlexSubtitleTrack track) => track.isExternal).toList();
|
||||
|
||||
if (externalTracks.isNotEmpty) {
|
||||
appLogger.d('Found ${externalTracks.length} external subtitle track(s)');
|
||||
@@ -199,19 +177,13 @@ class PlaybackInitializationService {
|
||||
externalSubtitles.add(
|
||||
SubtitleTrack.uri(
|
||||
url,
|
||||
title:
|
||||
plexTrack.displayTitle ??
|
||||
plexTrack.language ??
|
||||
'Track ${plexTrack.id}',
|
||||
title: plexTrack.displayTitle ?? plexTrack.language ?? 'Track ${plexTrack.id}',
|
||||
language: plexTrack.languageCode,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
// Silent fallback - log error but continue with other subtitles
|
||||
appLogger.w(
|
||||
'Failed to add external subtitle track ${plexTrack.id}',
|
||||
error: e,
|
||||
);
|
||||
appLogger.w('Failed to add external subtitle track ${plexTrack.id}', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -43,14 +43,8 @@ class PlaybackProgressTracker {
|
||||
this.isOffline = false,
|
||||
this.offlineWatchService,
|
||||
this.updateInterval = const Duration(seconds: 10),
|
||||
}) : assert(
|
||||
!isOffline || offlineWatchService != null,
|
||||
'offlineWatchService is required when isOffline is true',
|
||||
),
|
||||
assert(
|
||||
isOffline || client != null,
|
||||
'client is required when isOffline is false',
|
||||
);
|
||||
}) : assert(!isOffline || offlineWatchService != null, 'offlineWatchService is required when isOffline is true'),
|
||||
assert(isOffline || client != null, 'client is required when isOffline is false');
|
||||
|
||||
/// Start tracking playback progress
|
||||
///
|
||||
@@ -73,9 +67,7 @@ class PlaybackProgressTracker {
|
||||
}
|
||||
});
|
||||
|
||||
appLogger.d(
|
||||
'Started progress tracking (interval: ${updateInterval.inSeconds}s, offline: $isOffline)',
|
||||
);
|
||||
appLogger.d('Started progress tracking (interval: ${updateInterval.inSeconds}s, offline: $isOffline)');
|
||||
}
|
||||
|
||||
/// Stop tracking playback progress
|
||||
@@ -117,11 +109,7 @@ class PlaybackProgressTracker {
|
||||
}
|
||||
|
||||
/// Send progress update to Plex server (online mode)
|
||||
Future<void> _sendOnlineProgress(
|
||||
String state,
|
||||
Duration position,
|
||||
Duration duration,
|
||||
) async {
|
||||
Future<void> _sendOnlineProgress(String state, Duration position, Duration duration) async {
|
||||
await client!.updateProgress(
|
||||
metadata.ratingKey,
|
||||
time: position.inMilliseconds,
|
||||
@@ -129,16 +117,11 @@ class PlaybackProgressTracker {
|
||||
duration: duration.inMilliseconds,
|
||||
);
|
||||
|
||||
appLogger.d(
|
||||
'Progress update sent: $state at ${position.inSeconds}s / ${duration.inSeconds}s',
|
||||
);
|
||||
appLogger.d('Progress update sent: $state at ${position.inSeconds}s / ${duration.inSeconds}s');
|
||||
}
|
||||
|
||||
/// Queue progress update locally (offline mode)
|
||||
Future<void> _sendOfflineProgress(
|
||||
Duration position,
|
||||
Duration duration,
|
||||
) async {
|
||||
Future<void> _sendOfflineProgress(Duration position, Duration duration) async {
|
||||
final serverId = metadata.serverId;
|
||||
if (serverId == null) {
|
||||
appLogger.w('Cannot queue offline progress: serverId is null');
|
||||
|
||||
@@ -10,9 +10,7 @@ class PlexApiCache {
|
||||
static PlexApiCache? _instance;
|
||||
static PlexApiCache get instance {
|
||||
if (_instance == null) {
|
||||
throw StateError(
|
||||
'PlexApiCache not initialized. Call PlexApiCache.initialize() first.',
|
||||
);
|
||||
throw StateError('PlexApiCache not initialized. Call PlexApiCache.initialize() first.');
|
||||
}
|
||||
return _instance!;
|
||||
}
|
||||
@@ -37,9 +35,7 @@ class PlexApiCache {
|
||||
/// Get cached response for an endpoint
|
||||
Future<Map<String, dynamic>?> get(String serverId, String endpoint) async {
|
||||
final key = _buildKey(serverId, endpoint);
|
||||
final result = await (_db.select(
|
||||
_db.apiCache,
|
||||
)..where((t) => t.cacheKey.equals(key))).getSingleOrNull();
|
||||
final result = await (_db.select(_db.apiCache)..where((t) => t.cacheKey.equals(key))).getSingleOrNull();
|
||||
|
||||
if (result != null) {
|
||||
return jsonDecode(result.data) as Map<String, dynamic>;
|
||||
@@ -48,86 +44,65 @@ class PlexApiCache {
|
||||
}
|
||||
|
||||
/// Cache a response for an endpoint
|
||||
Future<void> put(
|
||||
String serverId,
|
||||
String endpoint,
|
||||
Map<String, dynamic> data,
|
||||
) async {
|
||||
Future<void> put(String serverId, String endpoint, Map<String, dynamic> data) async {
|
||||
final key = _buildKey(serverId, endpoint);
|
||||
await _db
|
||||
.into(_db.apiCache)
|
||||
.insertOnConflictUpdate(
|
||||
ApiCacheCompanion(
|
||||
cacheKey: Value(key),
|
||||
data: Value(jsonEncode(data)),
|
||||
cachedAt: Value(DateTime.now()),
|
||||
),
|
||||
ApiCacheCompanion(cacheKey: Value(key), data: Value(jsonEncode(data)), cachedAt: Value(DateTime.now())),
|
||||
);
|
||||
}
|
||||
|
||||
/// Delete all cached data for a server
|
||||
Future<void> deleteForServer(String serverId) async {
|
||||
await (_db.delete(
|
||||
_db.apiCache,
|
||||
)..where((t) => t.cacheKey.like('$serverId:%'))).go();
|
||||
await (_db.delete(_db.apiCache)..where((t) => t.cacheKey.like('$serverId:%'))).go();
|
||||
}
|
||||
|
||||
/// Delete cached data for a specific item (when removing a download)
|
||||
Future<void> deleteForItem(String serverId, String ratingKey) async {
|
||||
// Delete the metadata endpoint
|
||||
final metadataKey = _buildKey(serverId, '/library/metadata/$ratingKey');
|
||||
final childrenKey = _buildKey(
|
||||
serverId,
|
||||
'/library/metadata/$ratingKey/children',
|
||||
);
|
||||
final childrenKey = _buildKey(serverId, '/library/metadata/$ratingKey/children');
|
||||
|
||||
await (_db.delete(_db.apiCache)..where(
|
||||
(t) =>
|
||||
t.cacheKey.equals(metadataKey) | t.cacheKey.equals(childrenKey),
|
||||
))
|
||||
.go();
|
||||
await (_db.delete(
|
||||
_db.apiCache,
|
||||
)..where((t) => t.cacheKey.equals(metadataKey) | t.cacheKey.equals(childrenKey))).go();
|
||||
}
|
||||
|
||||
/// Mark an item as pinned for offline access
|
||||
Future<void> pinForOffline(String serverId, String ratingKey) async {
|
||||
final metadataKey = _buildKey(serverId, '/library/metadata/$ratingKey');
|
||||
await (_db.update(_db.apiCache)
|
||||
..where((t) => t.cacheKey.equals(metadataKey)))
|
||||
.write(const ApiCacheCompanion(pinned: Value(true)));
|
||||
await (_db.update(
|
||||
_db.apiCache,
|
||||
)..where((t) => t.cacheKey.equals(metadataKey))).write(const ApiCacheCompanion(pinned: Value(true)));
|
||||
}
|
||||
|
||||
/// Unpin an item
|
||||
Future<void> unpinForOffline(String serverId, String ratingKey) async {
|
||||
final metadataKey = _buildKey(serverId, '/library/metadata/$ratingKey');
|
||||
await (_db.update(_db.apiCache)
|
||||
..where((t) => t.cacheKey.equals(metadataKey)))
|
||||
.write(const ApiCacheCompanion(pinned: Value(false)));
|
||||
await (_db.update(
|
||||
_db.apiCache,
|
||||
)..where((t) => t.cacheKey.equals(metadataKey))).write(const ApiCacheCompanion(pinned: Value(false)));
|
||||
}
|
||||
|
||||
/// Check if an item is pinned for offline
|
||||
Future<bool> isPinned(String serverId, String ratingKey) async {
|
||||
final metadataKey = _buildKey(serverId, '/library/metadata/$ratingKey');
|
||||
final result = await (_db.select(
|
||||
_db.apiCache,
|
||||
)..where((t) => t.cacheKey.equals(metadataKey))).getSingleOrNull();
|
||||
final result = await (_db.select(_db.apiCache)..where((t) => t.cacheKey.equals(metadataKey))).getSingleOrNull();
|
||||
return result?.pinned ?? false;
|
||||
}
|
||||
|
||||
/// Get all pinned rating keys for a server
|
||||
Future<Set<String>> getPinnedKeys(String serverId) async {
|
||||
final results =
|
||||
await (_db.select(_db.apiCache)..where(
|
||||
(t) => t.cacheKey.like('$serverId:%') & t.pinned.equals(true),
|
||||
))
|
||||
.get();
|
||||
final results = await (_db.select(
|
||||
_db.apiCache,
|
||||
)..where((t) => t.cacheKey.like('$serverId:%') & t.pinned.equals(true))).get();
|
||||
|
||||
final keys = <String>{};
|
||||
for (final row in results) {
|
||||
// Extract ratingKey from cache key like "serverId:/library/metadata/12345"
|
||||
// Rating keys can be alphanumeric, not just numeric
|
||||
final match = RegExp(
|
||||
r'/library/metadata/([^/]+)$',
|
||||
).firstMatch(row.cacheKey);
|
||||
final match = RegExp(r'/library/metadata/([^/]+)$').firstMatch(row.cacheKey);
|
||||
if (match != null) {
|
||||
keys.add(match.group(1)!);
|
||||
}
|
||||
|
||||
@@ -49,10 +49,7 @@ class PlexAuthService {
|
||||
}
|
||||
|
||||
Future<Response> _getUser(String authToken) {
|
||||
return _dio.get(
|
||||
'$_plexApiBase/user',
|
||||
options: _getCommonOptions(authToken: authToken),
|
||||
);
|
||||
return _dio.get('$_plexApiBase/user', options: _getCommonOptions(authToken: authToken));
|
||||
}
|
||||
|
||||
/// Verify if a plex.tv token is valid
|
||||
@@ -67,27 +64,17 @@ class PlexAuthService {
|
||||
|
||||
/// Create a PIN for authentication
|
||||
Future<Map<String, dynamic>> createPin() async {
|
||||
final response = await _dio.post(
|
||||
'$_plexApiBase/pins?strong=true',
|
||||
options: _getCommonOptions(),
|
||||
);
|
||||
final response = await _dio.post('$_plexApiBase/pins?strong=true', options: _getCommonOptions());
|
||||
|
||||
return response.data as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Construct the Auth App URL for the user to visit
|
||||
String getAuthUrl(String pinCode) {
|
||||
final params = {
|
||||
'clientID': _clientIdentifier,
|
||||
'code': pinCode,
|
||||
'context[device][product]': _appName,
|
||||
};
|
||||
final params = {'clientID': _clientIdentifier, 'code': pinCode, 'context[device][product]': _appName};
|
||||
|
||||
final queryString = params.entries
|
||||
.map(
|
||||
(e) =>
|
||||
'${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}',
|
||||
)
|
||||
.map((e) => '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}')
|
||||
.join('&');
|
||||
|
||||
return 'https://app.plex.tv/auth#?$queryString';
|
||||
@@ -96,10 +83,7 @@ class PlexAuthService {
|
||||
/// Poll the PIN to check if it has been claimed
|
||||
Future<String?> checkPin(int pinId) async {
|
||||
try {
|
||||
final response = await _dio.get(
|
||||
'$_plexApiBase/pins/$pinId',
|
||||
options: _getCommonOptions(),
|
||||
);
|
||||
final response = await _dio.get('$_plexApiBase/pins/$pinId', options: _getCommonOptions());
|
||||
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
return data['authToken'] as String?;
|
||||
@@ -179,30 +163,20 @@ class PlexAuthService {
|
||||
|
||||
/// Get user profile with preferences (audio/subtitle settings)
|
||||
Future<PlexUserProfile> getUserProfile(String authToken) async {
|
||||
final response = await _dio.get(
|
||||
'$_clientsApi/user',
|
||||
options: _getCommonOptions(authToken: authToken),
|
||||
);
|
||||
final response = await _dio.get('$_clientsApi/user', options: _getCommonOptions(authToken: authToken));
|
||||
|
||||
return PlexUserProfile.fromJson(response.data as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
/// Get home users for the authenticated user
|
||||
Future<PlexHome> getHomeUsers(String authToken) async {
|
||||
final response = await _dio.get(
|
||||
'$_clientsApi/home/users',
|
||||
options: _getCommonOptions(authToken: authToken),
|
||||
);
|
||||
final response = await _dio.get('$_clientsApi/home/users', options: _getCommonOptions(authToken: authToken));
|
||||
|
||||
return PlexHome.fromJson(response.data as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
/// Switch to a different user in the home
|
||||
Future<UserSwitchResponse> switchToUser(
|
||||
String userUUID,
|
||||
String currentToken, {
|
||||
String? pin,
|
||||
}) async {
|
||||
Future<UserSwitchResponse> switchToUser(String userUUID, String currentToken, {String? pin}) async {
|
||||
final queryParams = {
|
||||
'includeSubscriptions': '1',
|
||||
'includeProviders': '1',
|
||||
@@ -219,17 +193,12 @@ class PlexAuthService {
|
||||
};
|
||||
|
||||
final queryString = queryParams.entries
|
||||
.map(
|
||||
(e) =>
|
||||
'${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}',
|
||||
)
|
||||
.map((e) => '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}')
|
||||
.join('&');
|
||||
|
||||
final response = await _dio.post(
|
||||
'$_clientsApi/home/users/$userUUID/switch?$queryString',
|
||||
options: Options(
|
||||
headers: {'Accept': 'application/json', 'Content-Length': '0'},
|
||||
),
|
||||
options: Options(headers: {'Accept': 'application/json', 'Content-Length': '0'}),
|
||||
);
|
||||
|
||||
return UserSwitchResponse.fromJson(response.data as Map<String, dynamic>);
|
||||
@@ -243,12 +212,7 @@ class _ConnectionCandidate {
|
||||
final bool isPlexDirectUri;
|
||||
final bool isHttps;
|
||||
|
||||
_ConnectionCandidate(
|
||||
this.connection,
|
||||
this.url,
|
||||
this.isPlexDirectUri,
|
||||
this.isHttps,
|
||||
);
|
||||
_ConnectionCandidate(this.connection, this.url, this.isPlexDirectUri, this.isHttps);
|
||||
}
|
||||
|
||||
/// Represents a Plex Media Server
|
||||
@@ -318,10 +282,8 @@ class PlexServer {
|
||||
|
||||
return PlexServer(
|
||||
name: json['name'] as String, // Safe because validated above
|
||||
clientIdentifier:
|
||||
json['clientIdentifier'] as String, // Safe because validated above
|
||||
accessToken:
|
||||
json['accessToken'] as String, // Safe because validated above
|
||||
clientIdentifier: json['clientIdentifier'] as String, // Safe because validated above
|
||||
accessToken: json['accessToken'] as String, // Safe because validated above
|
||||
connections: connections,
|
||||
owned: json['owned'] as bool? ?? false,
|
||||
product: json['product'] as String?,
|
||||
@@ -337,12 +299,10 @@ class PlexServer {
|
||||
if (json['name'] is! String || (json['name'] as String).isEmpty) {
|
||||
return false;
|
||||
}
|
||||
if (json['clientIdentifier'] is! String ||
|
||||
(json['clientIdentifier'] as String).isEmpty) {
|
||||
if (json['clientIdentifier'] is! String || (json['clientIdentifier'] as String).isEmpty) {
|
||||
return false;
|
||||
}
|
||||
if (json['accessToken'] is! String ||
|
||||
(json['accessToken'] as String).isEmpty) {
|
||||
if (json['accessToken'] is! String || (json['accessToken'] as String).isEmpty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -398,9 +358,7 @@ class PlexServer {
|
||||
/// Priority: local > remote > relay, then HTTPS > HTTP, then lowest latency
|
||||
/// Tests both plex.direct URI and direct IP for each connection
|
||||
/// HTTPS connections are tested first, with HTTP as fallback
|
||||
Stream<PlexConnection> findBestWorkingConnection({
|
||||
String? preferredUri,
|
||||
}) async* {
|
||||
Stream<PlexConnection> findBestWorkingConnection({String? preferredUri}) async* {
|
||||
if (connections.isEmpty) {
|
||||
appLogger.w('No connections available for server discovery');
|
||||
return;
|
||||
@@ -427,10 +385,7 @@ class PlexServer {
|
||||
if (preferredUri != null) {
|
||||
final cachedCandidate = _candidateForUrl(preferredUri);
|
||||
if (cachedCandidate != null) {
|
||||
appLogger.d(
|
||||
'Testing cached endpoint before running full race',
|
||||
error: {'uri': preferredUri},
|
||||
);
|
||||
appLogger.d('Testing cached endpoint before running full race', error: {'uri': preferredUri});
|
||||
final result = await PlexClient.testConnectionWithLatency(
|
||||
cachedCandidate.url,
|
||||
accessToken,
|
||||
@@ -438,16 +393,10 @@ class PlexServer {
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
appLogger.i(
|
||||
'Cached endpoint succeeded, using immediately',
|
||||
error: {'uri': preferredUri},
|
||||
);
|
||||
appLogger.i('Cached endpoint succeeded, using immediately', error: {'uri': preferredUri});
|
||||
firstCandidate = cachedCandidate;
|
||||
} else {
|
||||
appLogger.w(
|
||||
'Cached endpoint failed, falling back to candidate race',
|
||||
error: {'uri': preferredUri},
|
||||
);
|
||||
appLogger.w('Cached endpoint failed, falling back to candidate race', error: {'uri': preferredUri});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -457,17 +406,10 @@ class PlexServer {
|
||||
final completer = Completer<_ConnectionCandidate?>();
|
||||
int completedTests = 0;
|
||||
|
||||
appLogger.d(
|
||||
'Running connection race to find first working endpoint',
|
||||
error: {'candidateCount': totalCandidates},
|
||||
);
|
||||
appLogger.d('Running connection race to find first working endpoint', error: {'candidateCount': totalCandidates});
|
||||
|
||||
for (final candidate in candidates) {
|
||||
PlexClient.testConnectionWithLatency(
|
||||
candidate.url,
|
||||
accessToken,
|
||||
timeout: raceTimeout,
|
||||
).then((result) {
|
||||
PlexClient.testConnectionWithLatency(candidate.url, accessToken, timeout: raceTimeout).then((result) {
|
||||
completedTests++;
|
||||
|
||||
if (result.success && !completer.isCompleted) {
|
||||
@@ -487,17 +429,11 @@ class PlexServer {
|
||||
}
|
||||
appLogger.i(
|
||||
'Connection race found first working endpoint',
|
||||
error: {
|
||||
'uri': firstCandidate.url,
|
||||
'type': firstCandidate.connection.displayType,
|
||||
},
|
||||
error: {'uri': firstCandidate.url, 'type': firstCandidate.connection.displayType},
|
||||
);
|
||||
}
|
||||
|
||||
final firstConnection = _updateConnectionUrl(
|
||||
firstCandidate.connection,
|
||||
firstCandidate.url,
|
||||
);
|
||||
final firstConnection = _updateConnectionUrl(firstCandidate.connection, firstCandidate.url);
|
||||
yield firstConnection;
|
||||
appLogger.d(
|
||||
'Emitted first working connection, continuing latency tests in background',
|
||||
@@ -510,11 +446,7 @@ class PlexServer {
|
||||
|
||||
await Future.wait(
|
||||
candidates.map((candidate) async {
|
||||
final result = await PlexClient.testConnectionWithAverageLatency(
|
||||
candidate.url,
|
||||
accessToken,
|
||||
attempts: 2,
|
||||
);
|
||||
final result = await PlexClient.testConnectionWithAverageLatency(candidate.url, accessToken, attempts: 2);
|
||||
|
||||
if (result.success) {
|
||||
candidateResults[candidate] = result;
|
||||
@@ -538,25 +470,14 @@ class PlexServer {
|
||||
|
||||
// Emit the best connection if it's different from the first one
|
||||
if (bestCandidate != null) {
|
||||
final upgradedCandidate =
|
||||
await _upgradeCandidateToHttpsIfPossible(bestCandidate) ??
|
||||
bestCandidate;
|
||||
final upgradedCandidate = await _upgradeCandidateToHttpsIfPossible(bestCandidate) ?? bestCandidate;
|
||||
|
||||
final bestConnection = _updateConnectionUrl(
|
||||
upgradedCandidate.connection,
|
||||
upgradedCandidate.url,
|
||||
);
|
||||
final bestConnection = _updateConnectionUrl(upgradedCandidate.connection, upgradedCandidate.url);
|
||||
if (bestConnection.uri != firstConnection.uri) {
|
||||
appLogger.i(
|
||||
'Latency sweep selected better endpoint',
|
||||
error: {'uri': bestConnection.uri},
|
||||
);
|
||||
appLogger.i('Latency sweep selected better endpoint', error: {'uri': bestConnection.uri});
|
||||
yield bestConnection;
|
||||
} else {
|
||||
appLogger.d(
|
||||
'Latency sweep confirmed initial endpoint is optimal',
|
||||
error: {'uri': bestConnection.uri},
|
||||
);
|
||||
appLogger.d('Latency sweep confirmed initial endpoint is optimal', error: {'uri': bestConnection.uri});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -598,9 +519,7 @@ class PlexServer {
|
||||
return null;
|
||||
}
|
||||
|
||||
List<_ConnectionCandidate> _buildPrioritizedCandidates({
|
||||
Set<String>? excludeUrls,
|
||||
}) {
|
||||
List<_ConnectionCandidate> _buildPrioritizedCandidates({Set<String>? excludeUrls}) {
|
||||
final seen = <String>{};
|
||||
if (excludeUrls != null) {
|
||||
seen.addAll(excludeUrls);
|
||||
@@ -613,10 +532,7 @@ class PlexServer {
|
||||
final httpRemote = <_ConnectionCandidate>[];
|
||||
final httpRelay = <_ConnectionCandidate>[];
|
||||
|
||||
List<_ConnectionCandidate> bucketFor(
|
||||
PlexConnection connection,
|
||||
bool isHttps,
|
||||
) {
|
||||
List<_ConnectionCandidate> bucketFor(PlexConnection connection, bool isHttps) {
|
||||
if (isHttps) {
|
||||
if (connection.relay) return httpsRelay;
|
||||
if (connection.local) return httpsLocal;
|
||||
@@ -628,20 +544,12 @@ class PlexServer {
|
||||
}
|
||||
}
|
||||
|
||||
void addCandidate(
|
||||
PlexConnection connection,
|
||||
String url,
|
||||
bool isPlexDirectUri,
|
||||
bool isHttps,
|
||||
) {
|
||||
void addCandidate(PlexConnection connection, String url, bool isPlexDirectUri, bool isHttps) {
|
||||
if (url.isEmpty || seen.contains(url)) {
|
||||
return;
|
||||
}
|
||||
seen.add(url);
|
||||
bucketFor(
|
||||
connection,
|
||||
isHttps,
|
||||
).add(_ConnectionCandidate(connection, url, isPlexDirectUri, isHttps));
|
||||
bucketFor(connection, isHttps).add(_ConnectionCandidate(connection, url, isPlexDirectUri, isHttps));
|
||||
}
|
||||
|
||||
for (final connection in connections) {
|
||||
@@ -657,14 +565,7 @@ class PlexServer {
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
...httpsLocal,
|
||||
...httpsRemote,
|
||||
...httpsRelay,
|
||||
...httpLocal,
|
||||
...httpRemote,
|
||||
...httpRelay,
|
||||
];
|
||||
return [...httpsLocal, ...httpsRemote, ...httpsRelay, ...httpLocal, ...httpRemote, ...httpRelay];
|
||||
}
|
||||
|
||||
List<String> prioritizedEndpointUrls({String? preferredFirst}) {
|
||||
@@ -681,9 +582,7 @@ class PlexServer {
|
||||
return urls;
|
||||
}
|
||||
|
||||
Future<_ConnectionCandidate?> _upgradeCandidateToHttpsIfPossible(
|
||||
_ConnectionCandidate candidate,
|
||||
) async {
|
||||
Future<_ConnectionCandidate?> _upgradeCandidateToHttpsIfPossible(_ConnectionCandidate candidate) async {
|
||||
final currentUrl = candidate.url;
|
||||
if (currentUrl.startsWith('https://')) {
|
||||
return null;
|
||||
@@ -713,8 +612,7 @@ class PlexServer {
|
||||
}
|
||||
|
||||
final upgradedHost = Uri.tryParse(httpsUrl)?.host;
|
||||
if (upgradedHost == null ||
|
||||
!upgradedHost.toLowerCase().endsWith('.plex.direct')) {
|
||||
if (upgradedHost == null || !upgradedHost.toLowerCase().endsWith('.plex.direct')) {
|
||||
appLogger.d(
|
||||
'Skipping HTTPS upgrade for raw IP candidate: no plex.direct alias available',
|
||||
error: {'candidate': currentUrl, 'target': httpsUrl},
|
||||
@@ -728,10 +626,7 @@ class PlexServer {
|
||||
return null;
|
||||
}
|
||||
|
||||
appLogger.d(
|
||||
'Attempting HTTPS upgrade for candidate endpoint',
|
||||
error: {'from': currentUrl, 'to': httpsUrl},
|
||||
);
|
||||
appLogger.d('Attempting HTTPS upgrade for candidate endpoint', error: {'from': currentUrl, 'to': httpsUrl});
|
||||
|
||||
final result = await PlexClient.testConnectionWithLatency(
|
||||
httpsUrl,
|
||||
@@ -740,17 +635,11 @@ class PlexServer {
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
appLogger.w(
|
||||
'HTTPS upgrade failed, staying on HTTP candidate',
|
||||
error: {'url': currentUrl},
|
||||
);
|
||||
appLogger.w('HTTPS upgrade failed, staying on HTTP candidate', error: {'url': currentUrl});
|
||||
return null;
|
||||
}
|
||||
|
||||
appLogger.i(
|
||||
'HTTPS upgrade succeeded for candidate endpoint',
|
||||
error: {'httpsUrl': httpsUrl},
|
||||
);
|
||||
appLogger.i('HTTPS upgrade succeeded for candidate endpoint', error: {'httpsUrl': httpsUrl});
|
||||
|
||||
final httpsConnection = PlexConnection(
|
||||
protocol: 'https',
|
||||
@@ -762,17 +651,10 @@ class PlexServer {
|
||||
ipv6: candidate.connection.ipv6,
|
||||
);
|
||||
|
||||
return _ConnectionCandidate(
|
||||
httpsConnection,
|
||||
httpsUrl,
|
||||
resultingIsPlexDirect,
|
||||
true,
|
||||
);
|
||||
return _ConnectionCandidate(httpsConnection, httpsUrl, resultingIsPlexDirect, true);
|
||||
}
|
||||
|
||||
Future<PlexConnection?> upgradeConnectionToHttps(
|
||||
PlexConnection current,
|
||||
) async {
|
||||
Future<PlexConnection?> upgradeConnectionToHttps(PlexConnection current) async {
|
||||
if (current.uri.startsWith('https://')) {
|
||||
return current;
|
||||
}
|
||||
@@ -788,16 +670,11 @@ class PlexServer {
|
||||
current.uri.contains('.plex.direct'),
|
||||
current.uri.startsWith('https://'),
|
||||
);
|
||||
final upgradedCandidate = await _upgradeCandidateToHttpsIfPossible(
|
||||
candidate,
|
||||
);
|
||||
final upgradedCandidate = await _upgradeCandidateToHttpsIfPossible(candidate);
|
||||
if (upgradedCandidate == null) {
|
||||
return null;
|
||||
}
|
||||
return _updateConnectionUrl(
|
||||
upgradedCandidate.connection,
|
||||
upgradedCandidate.url,
|
||||
);
|
||||
return _updateConnectionUrl(upgradedCandidate.connection, upgradedCandidate.url);
|
||||
}
|
||||
|
||||
PlexConnection? _findMatchingBaseConnection(PlexConnection connection) {
|
||||
@@ -814,19 +691,11 @@ class PlexServer {
|
||||
}
|
||||
|
||||
/// Select the best candidate considering priority, latency, and URL type preference
|
||||
_ConnectionCandidate? _selectBestCandidateWithLatency(
|
||||
Map<_ConnectionCandidate, ConnectionTestResult> results,
|
||||
) {
|
||||
_ConnectionCandidate? _selectBestCandidateWithLatency(Map<_ConnectionCandidate, ConnectionTestResult> results) {
|
||||
// Group candidates by connection type (local/remote/relay)
|
||||
final localCandidates = results.entries
|
||||
.where((e) => e.key.connection.local && !e.key.connection.relay)
|
||||
.toList();
|
||||
final remoteCandidates = results.entries
|
||||
.where((e) => !e.key.connection.local && !e.key.connection.relay)
|
||||
.toList();
|
||||
final relayCandidates = results.entries
|
||||
.where((e) => e.key.connection.relay)
|
||||
.toList();
|
||||
final localCandidates = results.entries.where((e) => e.key.connection.local && !e.key.connection.relay).toList();
|
||||
final remoteCandidates = results.entries.where((e) => !e.key.connection.local && !e.key.connection.relay).toList();
|
||||
final relayCandidates = results.entries.where((e) => e.key.connection.relay).toList();
|
||||
|
||||
// Find best in each category
|
||||
return _findLowestLatencyCandidate(localCandidates) ??
|
||||
@@ -884,9 +753,7 @@ class PlexConnection {
|
||||
factory PlexConnection.fromJson(Map<String, dynamic> json) {
|
||||
// Validate required fields
|
||||
if (!_isValidConnectionJson(json)) {
|
||||
throw FormatException(
|
||||
'Invalid connection data: missing required fields (protocol, address, port, or uri)',
|
||||
);
|
||||
throw FormatException('Invalid connection data: missing required fields (protocol, address, port, or uri)');
|
||||
}
|
||||
|
||||
return PlexConnection(
|
||||
@@ -953,10 +820,7 @@ class PlexConnection {
|
||||
/// Create an HTTP fallback version of this HTTPS connection
|
||||
/// This allows testing HTTP when HTTPS is unavailable (e.g., certificate issues)
|
||||
PlexConnection toHttpFallback() {
|
||||
assert(
|
||||
protocol == 'https',
|
||||
'Can only create HTTP fallback for HTTPS connections',
|
||||
);
|
||||
assert(protocol == 'https', 'Can only create HTTP fallback for HTTPS connections');
|
||||
|
||||
return PlexConnection(
|
||||
protocol: 'http',
|
||||
|
||||
+143
-428
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user