refactor: deduplicate logic
This commit is contained in:
@@ -106,6 +106,22 @@ class OfflineWatchProvider extends ChangeNotifier {
|
||||
return episodes;
|
||||
}
|
||||
|
||||
/// Batch resolve watch statuses for a list of episodes.
|
||||
///
|
||||
/// Returns a map of globalKey -> isWatched for each episode.
|
||||
Future<Map<String, bool>> _resolveEpisodeWatchStatuses(List<PlexMetadata> episodes) async {
|
||||
if (episodes.isEmpty) return {};
|
||||
|
||||
final globalKeys = episodes.map((e) => e.globalKey).toSet();
|
||||
final localStatuses = await _syncService.getLocalWatchStatusesBatched(globalKeys);
|
||||
|
||||
return {
|
||||
for (final episode in episodes)
|
||||
episode.globalKey:
|
||||
localStatuses[episode.globalKey] ?? _downloadProvider.getMetadata(episode.globalKey)?.isWatched ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
/// Find the next unwatched downloaded episode for a show.
|
||||
///
|
||||
/// This is the "offline OnDeck" calculation - finds the first
|
||||
@@ -118,15 +134,11 @@ class OfflineWatchProvider extends ChangeNotifier {
|
||||
final episodes = _getSortedEpisodes(showRatingKey);
|
||||
if (episodes.isEmpty) return null;
|
||||
|
||||
// Batch fetch all watch statuses in a single query
|
||||
final globalKeys = episodes.map((e) => e.globalKey).toSet();
|
||||
final localStatuses = await _syncService.getLocalWatchStatusesBatched(globalKeys);
|
||||
final watchStatuses = await _resolveEpisodeWatchStatuses(episodes);
|
||||
|
||||
// Find first unwatched episode
|
||||
for (final episode in episodes) {
|
||||
final localStatus = localStatuses[episode.globalKey];
|
||||
final watched = localStatus ?? _downloadProvider.getMetadata(episode.globalKey)?.isWatched ?? false;
|
||||
if (!watched) {
|
||||
if (!watchStatuses[episode.globalKey]!) {
|
||||
return episode;
|
||||
}
|
||||
}
|
||||
@@ -176,21 +188,11 @@ class OfflineWatchProvider extends ChangeNotifier {
|
||||
/// Uses batched database query for efficiency.
|
||||
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 watchStatuses = await _resolveEpisodeWatchStatuses(episodes);
|
||||
|
||||
final results = <(PlexMetadata, bool)>[];
|
||||
for (final episode in episodes) {
|
||||
final localStatus = localStatuses[episode.globalKey];
|
||||
final watched = localStatus ?? _downloadProvider.getMetadata(episode.globalKey)?.isWatched ?? false;
|
||||
results.add((episode, watched));
|
||||
}
|
||||
|
||||
return results;
|
||||
return [for (final episode in episodes) (episode, watchStatuses[episode.globalKey]!)];
|
||||
}
|
||||
|
||||
/// Trigger a manual sync of pending items.
|
||||
|
||||
@@ -129,20 +129,27 @@ class _MpvConfigScreenState extends State<MpvConfigScreen> {
|
||||
valueController.dispose();
|
||||
}
|
||||
|
||||
Future<void> _showDeleteEntryDialog(int index) async {
|
||||
Future<bool> _showConfirmDeleteDialog({required String title, required String content}) async {
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(t.mpvConfig.deleteProperty),
|
||||
content: Text(t.mpvConfig.confirmDeleteProperty),
|
||||
title: Text(title),
|
||||
content: Text(content),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context, false), child: Text(t.common.cancel)),
|
||||
TextButton(onPressed: () => Navigator.pop(context, true), child: Text(t.common.delete)),
|
||||
],
|
||||
),
|
||||
);
|
||||
return result == true;
|
||||
}
|
||||
|
||||
if (result == true) {
|
||||
Future<void> _showDeleteEntryDialog(int index) async {
|
||||
final confirmed = await _showConfirmDeleteDialog(
|
||||
title: t.mpvConfig.deleteProperty,
|
||||
content: t.mpvConfig.confirmDeleteProperty,
|
||||
);
|
||||
if (confirmed) {
|
||||
_deleteEntry(index);
|
||||
}
|
||||
}
|
||||
@@ -201,19 +208,12 @@ class _MpvConfigScreenState extends State<MpvConfigScreen> {
|
||||
}
|
||||
|
||||
Future<void> _deletePreset(MpvPreset preset) async {
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(t.mpvConfig.deletePreset),
|
||||
content: Text(t.mpvConfig.confirmDeletePreset),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context, false), child: Text(t.common.cancel)),
|
||||
TextButton(onPressed: () => Navigator.pop(context, true), child: Text(t.common.delete)),
|
||||
],
|
||||
),
|
||||
final confirmed = await _showConfirmDeleteDialog(
|
||||
title: t.mpvConfig.deletePreset,
|
||||
content: t.mpvConfig.confirmDeletePreset,
|
||||
);
|
||||
|
||||
if (result == true) {
|
||||
if (confirmed) {
|
||||
await _settingsService.deleteMpvPreset(preset.name);
|
||||
setState(() {
|
||||
_presets = _settingsService.getMpvPresets();
|
||||
|
||||
@@ -247,15 +247,11 @@ class DataAggregationService {
|
||||
|
||||
// Private helper methods
|
||||
|
||||
/// Higher-order helper for per-server fan-out operations
|
||||
/// Base helper for per-server fan-out operations
|
||||
///
|
||||
/// Iterates over all online clients, executes the operation for each server,
|
||||
/// handles errors, updates server status, and aggregates results.
|
||||
///
|
||||
/// Type parameter `T` is the item type returned by the operation
|
||||
/// [operationName] is used for logging (e.g., "fetching libraries")
|
||||
/// [operation] is the async function to run per server, returning `List<T>`
|
||||
Future<List<T>> _perServer<T>({
|
||||
/// Returns raw results as (serverId, result) tuples.
|
||||
/// Used by [_perServer] and [_perServerGrouped] for different aggregation strategies.
|
||||
Future<List<(String serverId, List<T> result)>> _perServerRaw<T>({
|
||||
required String operationName,
|
||||
required Future<List<T>> Function(String serverId, PlexClient client, PlexServer? server) operation,
|
||||
}) async {
|
||||
@@ -268,61 +264,6 @@ class DataAggregationService {
|
||||
|
||||
appLogger.d('$operationName from ${clients.length} servers');
|
||||
|
||||
final allResults = <T>[];
|
||||
|
||||
// Execute operation on all servers in parallel
|
||||
final Iterable<Future<List<T>>> futures = clients.entries.map((entry) async {
|
||||
final serverId = entry.key;
|
||||
final client = entry.value;
|
||||
final server = _serverManager.getServer(serverId);
|
||||
final sw = Stopwatch()..start();
|
||||
|
||||
try {
|
||||
final result = await operation(serverId, client, server);
|
||||
appLogger.d(
|
||||
'$operationName for server $serverId completed in ${sw.elapsedMilliseconds}ms with ${result.length} items',
|
||||
);
|
||||
return result;
|
||||
} catch (e, 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');
|
||||
return <T>[];
|
||||
}
|
||||
});
|
||||
|
||||
final List<List<T>> results = await Future.wait<List<T>>(futures);
|
||||
|
||||
// Flatten results
|
||||
for (final items in results) {
|
||||
allResults.addAll(items);
|
||||
}
|
||||
|
||||
return allResults;
|
||||
}
|
||||
|
||||
/// Higher-order helper for per-server fan-out operations that groups results by server
|
||||
///
|
||||
/// Similar to [_perServer] but returns a Map with results grouped by serverId
|
||||
/// instead of flattening into a single list.
|
||||
///
|
||||
/// Type parameter `T` is the item type returned by the operation
|
||||
/// [operationName] is used for logging (e.g., "fetching libraries")
|
||||
/// [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,
|
||||
}) async {
|
||||
final clients = _serverManager.onlineClients;
|
||||
|
||||
if (clients.isEmpty) {
|
||||
appLogger.w('No online servers available for $operationName');
|
||||
return {};
|
||||
}
|
||||
|
||||
appLogger.d('$operationName from ${clients.length} servers');
|
||||
|
||||
// Execute operation on all servers in parallel
|
||||
final futures = clients.entries.map((entry) async {
|
||||
final serverId = entry.key;
|
||||
final client = entry.value;
|
||||
@@ -334,17 +275,38 @@ class DataAggregationService {
|
||||
appLogger.d(
|
||||
'$operationName for server $serverId completed in ${sw.elapsedMilliseconds}ms with ${result.length} items',
|
||||
);
|
||||
return MapEntry(serverId, result);
|
||||
return (serverId, result);
|
||||
} catch (e, 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');
|
||||
return MapEntry(serverId, <T>[]);
|
||||
return (serverId, <T>[]);
|
||||
}
|
||||
});
|
||||
|
||||
final results = await Future.wait(futures);
|
||||
return await Future.wait(futures);
|
||||
}
|
||||
|
||||
return Map.fromEntries(results);
|
||||
/// Higher-order helper for per-server fan-out operations
|
||||
///
|
||||
/// Iterates over all online clients, executes the operation for each server,
|
||||
/// handles errors, updates server status, and flattens results into a single list.
|
||||
Future<List<T>> _perServer<T>({
|
||||
required String operationName,
|
||||
required Future<List<T>> Function(String serverId, PlexClient client, PlexServer? server) operation,
|
||||
}) async {
|
||||
final results = await _perServerRaw(operationName: operationName, operation: operation);
|
||||
return [for (final (_, items) in results) ...items];
|
||||
}
|
||||
|
||||
/// Higher-order helper for per-server fan-out operations that groups results by server
|
||||
///
|
||||
/// Similar to [_perServer] but returns a Map with results grouped by serverId.
|
||||
Future<Map<String, List<T>>> _perServerGrouped<T>({
|
||||
required String operationName,
|
||||
required Future<List<T>> Function(String serverId, PlexClient client, PlexServer? server) operation,
|
||||
}) async {
|
||||
final results = await _perServerRaw(operationName: operationName, operation: operation);
|
||||
return {for (final (id, items) in results) id: items};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,12 +32,7 @@ class TrackSelectionService {
|
||||
final PlexMetadata metadata;
|
||||
final PlexMediaInfo? plexMediaInfo;
|
||||
|
||||
TrackSelectionService({
|
||||
required this.player,
|
||||
this.profileSettings,
|
||||
required this.metadata,
|
||||
this.plexMediaInfo,
|
||||
});
|
||||
TrackSelectionService({required this.player, this.profileSettings, required this.metadata, this.plexMediaInfo});
|
||||
|
||||
/// Build list of preferred languages from a user profile
|
||||
List<String> _buildPreferredLanguages(PlexUserProfile profile, {required bool isAudio}) {
|
||||
@@ -324,7 +319,10 @@ class TrackSelectionService {
|
||||
/// Priority 3: Per-media language preference
|
||||
/// Priority 4: User profile preferences
|
||||
/// Priority 5: Default or first track
|
||||
TrackSelectionResult<AudioTrack>? selectAudioTrack(List<AudioTrack> availableTracks, AudioTrack? preferredAudioTrack) {
|
||||
TrackSelectionResult<AudioTrack>? selectAudioTrack(
|
||||
List<AudioTrack> availableTracks,
|
||||
AudioTrack? preferredAudioTrack,
|
||||
) {
|
||||
if (availableTracks.isEmpty) return null;
|
||||
|
||||
AudioTrack? trackToSelect;
|
||||
|
||||
@@ -76,6 +76,14 @@ class WatchTogetherProvider with ChangeNotifier {
|
||||
_displayName = name;
|
||||
}
|
||||
|
||||
/// Wire up sync manager's state change callback to update provider state
|
||||
void _wireSyncStateChanges() {
|
||||
_syncManager!.onSyncStateChanged = (isSyncing) {
|
||||
_isSyncing = isSyncing;
|
||||
notifyListeners();
|
||||
};
|
||||
}
|
||||
|
||||
/// Create a new watch together session as host
|
||||
Future<String> createSession({
|
||||
required ControlMode controlMode,
|
||||
@@ -113,10 +121,7 @@ class WatchTogetherProvider with ChangeNotifier {
|
||||
displayName: _displayName,
|
||||
);
|
||||
|
||||
_syncManager!.onSyncStateChanged = (isSyncing) {
|
||||
_isSyncing = isSyncing;
|
||||
notifyListeners();
|
||||
};
|
||||
_wireSyncStateChanges();
|
||||
|
||||
notifyListeners();
|
||||
appLogger.d('WatchTogether: Session created: $sessionId');
|
||||
@@ -164,10 +169,7 @@ class WatchTogetherProvider with ChangeNotifier {
|
||||
notifyListeners();
|
||||
};
|
||||
|
||||
_syncManager!.onSyncStateChanged = (isSyncing) {
|
||||
_isSyncing = isSyncing;
|
||||
notifyListeners();
|
||||
};
|
||||
_wireSyncStateChanges();
|
||||
|
||||
// Add self to participants
|
||||
_participants.add(Participant(peerId: _peerService!.myPeerId!, displayName: _displayName, isHost: false));
|
||||
|
||||
@@ -83,6 +83,31 @@ class WatchTogetherPeerService {
|
||||
return const Uuid().v4().substring(0, 8).toUpperCase();
|
||||
}
|
||||
|
||||
/// Attach common peer event listeners for disconnected/close/error events
|
||||
void _attachCommonPeerListeners({
|
||||
required Completer completer,
|
||||
required PeerErrorType errorType,
|
||||
required String errorMessage,
|
||||
}) {
|
||||
_peer!.on('disconnected').listen((_) {
|
||||
appLogger.w('WatchTogether: Peer disconnected from server');
|
||||
_handleDisconnectedFromServer();
|
||||
});
|
||||
|
||||
_peer!.on('close').listen((_) {
|
||||
appLogger.d('WatchTogether: Peer closed');
|
||||
_connectionStateController.add(false);
|
||||
});
|
||||
|
||||
_peer!.on('error').listen((error) {
|
||||
appLogger.e('WatchTogether: Peer error', error: error);
|
||||
_errorController.add(PeerError(type: errorType, message: '$errorMessage: $error', originalError: error));
|
||||
if (!completer.isCompleted) {
|
||||
completer.completeError(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Create a new session as host
|
||||
///
|
||||
/// Returns the session ID that others can use to join
|
||||
@@ -115,25 +140,11 @@ class WatchTogetherPeerService {
|
||||
_handleNewConnection(dataConn);
|
||||
});
|
||||
|
||||
_peer!.on('error').listen((error) {
|
||||
appLogger.e('WatchTogether: Peer error', error: error);
|
||||
_errorController.add(
|
||||
PeerError(type: PeerErrorType.serverError, message: error.toString(), originalError: error),
|
||||
);
|
||||
if (!completer.isCompleted) {
|
||||
completer.completeError(error);
|
||||
}
|
||||
});
|
||||
|
||||
_peer!.on('disconnected').listen((_) {
|
||||
appLogger.w('WatchTogether: Peer disconnected from server');
|
||||
_handleDisconnectedFromServer();
|
||||
});
|
||||
|
||||
_peer!.on('close').listen((_) {
|
||||
appLogger.d('WatchTogether: Peer closed');
|
||||
_connectionStateController.add(false);
|
||||
});
|
||||
_attachCommonPeerListeners(
|
||||
completer: completer,
|
||||
errorType: PeerErrorType.serverError,
|
||||
errorMessage: 'Server error',
|
||||
);
|
||||
} catch (e) {
|
||||
appLogger.e('WatchTogether: Failed to create peer', error: e);
|
||||
if (!completer.isCompleted) {
|
||||
@@ -178,29 +189,11 @@ class WatchTogetherPeerService {
|
||||
_handleNewConnection(conn, isOutgoing: true, completer: completer);
|
||||
});
|
||||
|
||||
_peer!.on('error').listen((error) {
|
||||
appLogger.e('WatchTogether: Peer error', error: error);
|
||||
_errorController.add(
|
||||
PeerError(
|
||||
type: PeerErrorType.connectionFailed,
|
||||
message: 'Failed to connect to session: $error',
|
||||
originalError: error,
|
||||
),
|
||||
);
|
||||
if (!completer.isCompleted) {
|
||||
completer.completeError(error);
|
||||
}
|
||||
});
|
||||
|
||||
_peer!.on('disconnected').listen((_) {
|
||||
appLogger.w('WatchTogether: Peer disconnected from server');
|
||||
_handleDisconnectedFromServer();
|
||||
});
|
||||
|
||||
_peer!.on('close').listen((_) {
|
||||
appLogger.d('WatchTogether: Peer closed');
|
||||
_connectionStateController.add(false);
|
||||
});
|
||||
_attachCommonPeerListeners(
|
||||
completer: completer,
|
||||
errorType: PeerErrorType.connectionFailed,
|
||||
errorMessage: 'Failed to connect to session',
|
||||
);
|
||||
} catch (e) {
|
||||
appLogger.e('WatchTogether: Failed to create peer for joining', error: e);
|
||||
if (!completer.isCompleted) {
|
||||
|
||||
@@ -241,19 +241,9 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
|
||||
);
|
||||
}
|
||||
|
||||
// Sort shows by status and title
|
||||
shows.sort((a, b) {
|
||||
final statusCompare = _compareByStatus(a.status, b.status);
|
||||
if (statusCompare != 0) return statusCompare;
|
||||
return a.title.compareTo(b.title);
|
||||
});
|
||||
|
||||
// Sort movies by status and title
|
||||
movies.sort((a, b) {
|
||||
final statusCompare = _compareByStatus(a.status, b.status);
|
||||
if (statusCompare != 0) return statusCompare;
|
||||
return a.title.compareTo(b.title);
|
||||
});
|
||||
// Sort shows and movies by status and title
|
||||
_sortNodesByStatusAndTitle(shows);
|
||||
_sortNodesByStatusAndTitle(movies);
|
||||
|
||||
// Combine movies and shows
|
||||
return [...movies, ...shows];
|
||||
@@ -292,6 +282,15 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
|
||||
return (statusOrder[a] ?? 99).compareTo(statusOrder[b] ?? 99);
|
||||
}
|
||||
|
||||
/// Sort nodes by status (downloading first) then by title
|
||||
void _sortNodesByStatusAndTitle(List<DownloadTreeNode> nodes) {
|
||||
nodes.sort((a, b) {
|
||||
final statusCompare = _compareByStatus(a.status, b.status);
|
||||
if (statusCompare != 0) return statusCompare;
|
||||
return a.title.compareTo(b.title);
|
||||
});
|
||||
}
|
||||
|
||||
/// Flatten the tree into a list of visible nodes with their depths
|
||||
List<_FlatNode> _flattenTree(List<DownloadTreeNode> nodes, [int depth = 0]) {
|
||||
final List<_FlatNode> result = [];
|
||||
|
||||
@@ -61,25 +61,16 @@ class _HorizontalScrollWithArrowsState extends State<HorizontalScrollWithArrows>
|
||||
});
|
||||
}
|
||||
|
||||
void _scrollLeft() {
|
||||
void _animateScroll(double direction) {
|
||||
final position = _scrollController.position;
|
||||
final targetScroll = (position.pixels - (position.viewportDimension * widget.scrollAmount)).clamp(
|
||||
0.0,
|
||||
position.maxScrollExtent,
|
||||
);
|
||||
|
||||
final delta = direction * position.viewportDimension * widget.scrollAmount;
|
||||
final targetScroll = (position.pixels + delta).clamp(0.0, position.maxScrollExtent);
|
||||
_scrollController.animateTo(targetScroll, duration: tokens(context).slow, curve: Curves.easeInOut);
|
||||
}
|
||||
|
||||
void _scrollRight() {
|
||||
final position = _scrollController.position;
|
||||
final targetScroll = (position.pixels + (position.viewportDimension * widget.scrollAmount)).clamp(
|
||||
0.0,
|
||||
position.maxScrollExtent,
|
||||
);
|
||||
void _scrollLeft() => _animateScroll(-1);
|
||||
|
||||
_scrollController.animateTo(targetScroll, duration: tokens(context).slow, curve: Curves.easeInOut);
|
||||
}
|
||||
void _scrollRight() => _animateScroll(1);
|
||||
|
||||
Widget _buildArrowButton({
|
||||
required double position,
|
||||
|
||||
Reference in New Issue
Block a user