refactor: update formatting

This commit is contained in:
edde746
2025-12-15 02:29:03 +01:00
parent e072960d36
commit 3c89d0eba8
175 changed files with 14599 additions and 15569 deletions
+3
View File
@@ -10,3 +10,6 @@ analyzer:
dart_code_linter:
rules:
- avoid-unused-parameters
formatter:
page_width: 120
+13 -38
View File
@@ -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();
}
}
File diff suppressed because it is too large Load Diff
+3 -6
View File
@@ -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()();
+1 -4
View File
@@ -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 {
+4 -13
View File
@@ -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,
);
}
}
+2 -7
View File
@@ -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;
}
+11 -33
View File
@@ -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;
+2 -5
View File
@@ -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() {
+1 -5
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+28 -80
View File
@@ -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)],
),
),
);
+2 -4
View File
@@ -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();
+5 -16
View File
@@ -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);
}
}
+12 -20
View File
@@ -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(),
);
+1 -2
View File
@@ -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)';
}
+2 -12
View File
@@ -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() {
+3 -9
View File
@@ -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 {
+1 -2
View File
@@ -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);
+12 -13
View File
@@ -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,
};
+3 -16
View File
@@ -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);
+1 -3
View File
@@ -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,
+3 -6
View File
@@ -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);
}
+42 -45
View File
@@ -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,
};
+1 -2
View File
@@ -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);
+19 -20
View File
@@ -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,
};
+2 -11
View File
@@ -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);
}
+1 -6
View File
@@ -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(
+2 -4
View File
@@ -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,
+1 -5
View File
@@ -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;
+1 -3
View File
@@ -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
View File
@@ -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;
+1 -3
View File
@@ -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});
}
}
+1 -6
View File
@@ -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
+9 -35
View File
@@ -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
+1 -2
View File
@@ -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
View File
@@ -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 &&
+7 -38
View File
@@ -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,
+63 -199
View File
@@ -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) {
+1 -2
View File
@@ -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
+5 -17
View File
@@ -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;
}
+3 -8
View File
@@ -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);
+11 -32
View File
@@ -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));
}
+13 -42
View File
@@ -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;
+7 -10
View File
@@ -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 {
+27 -89
View File
@@ -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);
+21 -76
View File
@@ -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),
),
],
+10 -37
View File
@@ -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();
+4 -14
View File
@@ -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
View File
@@ -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),
),
],
),
+7 -26
View File
@@ -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];
+7 -34
View File
@@ -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,
+6 -20
View File
@@ -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),
);
}
}
+12 -35
View File
@@ -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(
+6 -27
View File
@@ -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)),
),
],
),
+7 -32
View File
@@ -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(
+79 -270
View File
@@ -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),
),
),
],
+3 -14
View File
@@ -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},
+4 -18
View File
@@ -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,
);
},
+12 -40
View File
@@ -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),
),
],
),
+6 -28
View File
@@ -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),
);
}
+18 -50
View File
@@ -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),
);
}
+4 -18
View File
@@ -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));
}
}
}
+9 -37
View File
@@ -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;
}
+10 -34
View File
@@ -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,
+55 -157
View File
@@ -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,
),
+4 -15
View File
@@ -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),
),
],
+13 -47
View File
@@ -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),
],
);
}),
+22 -70
View File
@@ -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'),
),
),
],
+60 -224
View File
@@ -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);
});
},
),
],
+73 -248
View File
@@ -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;
+21 -77
View File
@@ -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>[]);
}
});
+115 -406
View File
@@ -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
+35 -144
View File
@@ -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'];
+3 -10
View File
@@ -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) {
+1 -2
View File
@@ -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;
+2 -8
View File
@@ -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);
}
}
+6 -14
View File
@@ -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;
}
}
+3 -6
View File
@@ -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));
}
}
+7 -29
View File
@@ -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});
}
+23 -81
View File
@@ -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);
}
}
+30 -85
View File
@@ -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) {
+15 -55
View File
@@ -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);
}
}
+6 -23
View File
@@ -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');
+20 -45
View File
@@ -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)!);
}
+48 -184
View File
@@ -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',
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