perf: reduce state notification fan-out

This commit is contained in:
edde746
2026-07-12 18:59:58 +02:00
parent b4575c9780
commit a561777456
11 changed files with 241 additions and 98 deletions
+10
View File
@@ -152,6 +152,7 @@ class ActiveProfileProvider extends ChangeNotifier with DisposableChangeNotifier
safeNotifyListeners();
});
_plexHomeSub = _plexHome.stream.listen((cache) {
if (_samePlexHomeUsers(cache, _plexHomeUsers)) return;
_plexHomeUsers = cache;
_recomputeProfiles();
_resolveActive();
@@ -189,6 +190,15 @@ class ActiveProfileProvider extends ChangeNotifier with DisposableChangeNotifier
return true;
}
static bool _samePlexHomeUsers(Map<String, List<PlexHomeUser>> a, Map<String, List<PlexHomeUser>> b) {
if (a.length != b.length) return false;
for (final entry in a.entries) {
final other = b[entry.key];
if (other == null || !listEquals(entry.value, other)) return false;
}
return true;
}
void _recomputeProfiles() {
_profiles = mergeLocalWithPlexHome(
locals: _localProfiles,
+4
View File
@@ -101,6 +101,10 @@ class PlexHomeService {
for (final conn in current.whereType<PlexAccountConnection>()) {
final cached = _readCache(conn.id);
if (cached == null) continue;
final previous = _byConnection[conn.id];
if (previous != null && encodePlexHomeUsersCacheJson(previous) == encodePlexHomeUsersCacheJson(cached)) {
continue;
}
_byConnection[conn.id] = cached;
changed = true;
}
+41 -32
View File
@@ -61,6 +61,7 @@ typedef _MetadataHydrationResult = ({MediaItem? metadata, bool networkFilled, bo
/// Provider for managing download state and operations.
class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin {
int _batchDeletionDepth = 0;
final DownloadManagerService _downloadManager;
final AppDatabase _database;
final SyncRuleExecutor _syncRuleExecutor;
@@ -413,16 +414,15 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
void _onProgressUpdate(DownloadProgress progress) {
appLogger.d('Progress update received: ${progress.globalKey} - ${progress.status} - ${progress.progress}%');
final ownedByActiveProfile = _ownsDownloadKey(progress.globalKey);
_downloads[progress.globalKey] = progress;
// Sync artwork paths when they are available
// Sync artwork paths when they are available.
if (progress.hasArtworkPaths) {
_artworkPaths[progress.globalKey] = DownloadedArtwork(thumbPath: progress.thumbPath);
}
appLogger.d('Notifying listeners for ${progress.globalKey}');
safeNotifyListeners();
if (ownedByActiveProfile) safeNotifyListeners();
}
@override
@@ -1315,8 +1315,10 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
}
}
/// Delete a downloaded item
Future<void> deleteDownload(String globalKey) async {
/// Delete a downloaded item.
Future<void> deleteDownload(String globalKey) => _deleteDownload(globalKey, notify: true);
Future<void> _deleteDownload(String globalKey, {required bool notify}) async {
try {
final meta = _metadata[globalKey];
if (meta != null &&
@@ -1329,40 +1331,38 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
final released = await _releaseDownloadForActiveProfile(globalKey);
final hasOtherOwners = await _database.hasDownloadOwner(globalKey);
if (hasOtherOwners) {
if (meta != null) {
if (notify && meta != null) {
DeletionNotifier().notifyDeletedItem(item: meta, isDownloadOnly: true);
}
if (released) safeNotifyListeners();
if (notify && released) safeNotifyListeners();
return;
}
// Start deletion (progress will be tracked via stream)
await _downloadManager.deleteDownload(globalKey);
// Remove from local state
_downloads.remove(globalKey);
_metadata.remove(globalKey);
_artworkPaths.remove(globalKey);
// Notify any open screens so they can drop the item from their lists
// immediately instead of waiting for an exit/re-enter.
if (meta != null) {
if (notify && meta != null) {
DeletionNotifier().notifyDeletedItem(item: meta, isDownloadOnly: true);
}
safeNotifyListeners();
if (notify) safeNotifyListeners();
} catch (e) {
// Remove from deletion tracking on error
_deletionProgress.remove(globalKey);
safeNotifyListeners();
if (notify) safeNotifyListeners();
rethrow;
}
}
Future<void> _deleteOwnedContainerDownloads(String globalKey, MediaItem container) async {
final descendants = _ownedDescendantEntries(container).toList();
for (final entry in descendants) {
await deleteDownload(entry.key);
_batchDeletionDepth++;
try {
for (final entry in descendants) {
await _deleteDownload(entry.key, notify: false);
}
} finally {
_batchDeletionDepth--;
}
DeletionNotifier().notifyDeletedItem(item: container, isDownloadOnly: true);
@@ -1383,16 +1383,16 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
});
}
/// Handle deletion progress updates
/// Handle deletion progress updates.
void _onDeletionProgressUpdate(DeletionProgress progress) {
if (progress.isComplete) {
// Deletion complete - remove from tracking
_deletionProgress.remove(progress.globalKey);
} else {
// Update progress
_deletionProgress[progress.globalKey] = progress;
}
safeNotifyListeners();
if (_batchDeletionDepth == 0 && _ownsDownloadKey(progress.globalKey)) {
safeNotifyListeners();
}
}
/// Get deletion progress for an item
@@ -1719,15 +1719,18 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
Future<void> _loadSyncRules() async {
try {
_syncRules.clear();
final profileId = _activeProfileId;
if (profileId == null || profileId.isEmpty) return;
if (profileId == null || profileId.isEmpty) {
_syncRules.clear();
return;
}
await _database.adoptLegacySyncRulesForProfile(profileId);
if (_activeProfileId != profileId) return;
final rules = await _database.getSyncRules(profileId: profileId);
for (final rule in rules) {
_syncRules[rule.globalKey] = rule;
}
if (_activeProfileId != profileId) return;
_syncRules
..clear()
..addEntries(rules.map((rule) => MapEntry(rule.globalKey, rule)));
} catch (e) {
appLogger.w('Failed to load sync rules', error: e);
}
@@ -1735,12 +1738,18 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
Future<void> _loadDownloadOwners() async {
try {
_ownedDownloadKeys.clear();
final profileId = _activeProfileId;
if (profileId == null || profileId.isEmpty) return;
if (profileId == null || profileId.isEmpty) {
_ownedDownloadKeys.clear();
return;
}
await _database.adoptLegacyDownloadsForProfile(profileId);
if (_activeProfileId != profileId) return;
_ownedDownloadKeys.addAll(await _database.getDownloadOwnerKeysForProfile(profileId));
final ownedKeys = await _database.getDownloadOwnerKeysForProfile(profileId);
if (_activeProfileId != profileId) return;
_ownedDownloadKeys
..clear()
..addAll(ownedKeys);
} catch (e) {
appLogger.w('Failed to load download ownership', error: e);
}
+57 -48
View File
@@ -91,7 +91,6 @@ class _NowPlayingScreenState extends State<NowPlayingScreen>
final FocusNode _lyricsPaneFocusNode = FocusNode(debugLabel: 'now_playing_lyrics_pane');
final GlobalKey<FocusableActionBarState> _utilityBarKey = GlobalKey<FocusableActionBarState>();
bool _overflowFocused = false;
bool _poppedForIdle = false;
@override
@@ -535,35 +534,41 @@ class _NowPlayingScreenState extends State<NowPlayingScreen>
/// sessions. Mono styling matches the seek bar: text-colored active track
/// on outline.
Widget _buildVolumeCluster(MusicPlaybackService service) {
final tk = tokens(context);
final icon = service.volume <= 0
? Symbols.volume_off_rounded
: service.volume < 50
? Symbols.volume_down_rounded
: Symbols.volume_up_rounded;
return Row(
mainAxisSize: .min,
children: [
AppIcon(icon, fill: 1, size: 20, color: tk.textMuted),
SizedBox(
width: 140,
child: SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: 3,
activeTrackColor: tk.text,
inactiveTrackColor: tk.outline,
thumbColor: tk.text,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6),
overlayShape: const RoundSliderOverlayShape(overlayRadius: 12),
return ValueListenableBuilder<double>(
valueListenable: service.volumeListenable,
builder: (context, volume, _) {
final tk = tokens(context);
final icon = volume <= 0
? Symbols.volume_off_rounded
: volume < 50
? Symbols.volume_down_rounded
: Symbols.volume_up_rounded;
return Row(
mainAxisSize: .min,
children: [
AppIcon(icon, fill: 1, size: 20, color: tk.textMuted),
SizedBox(
width: 140,
child: SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: 3,
activeTrackColor: tk.text,
inactiveTrackColor: tk.outline,
thumbColor: tk.text,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6),
overlayShape: const RoundSliderOverlayShape(overlayRadius: 12),
),
child: Slider(
value: volume.clamp(0.0, 100.0),
max: 100,
onChanged: (value) => unawaited(service.setVolume(value, persist: false)),
onChangeEnd: (value) => unawaited(service.setVolume(value)),
),
),
),
child: Slider(
value: service.volume.clamp(0.0, 100.0),
max: 100,
onChanged: (value) => unawaited(service.setVolume(value)),
),
),
),
],
],
);
},
);
}
@@ -578,26 +583,30 @@ class _NowPlayingScreenState extends State<NowPlayingScreen>
Widget child = button;
if (focusable) {
final showFocus = _overflowFocused && InputModeTracker.isKeyboardMode(context);
child = Focus(
focusNode: _overflowFocusNode,
descendantsAreFocusable: false,
onFocusChange: (hasFocus) => setState(() => _overflowFocused = hasFocus),
onKeyEvent: (node, event) {
final backResult = handleBackKeyAction(event, _pop);
if (backResult != KeyEventResult.ignored) return backResult;
return dpadKeyHandler(
onSelect: () => contextMenuKey.currentState?.showContextMenu(context),
onDown: _seekFocusNode.requestFocus,
onUp: () {}, // top of the chain — trap
trapHorizontalEdges: true,
)(node, event);
child = ListenableBuilder(
listenable: _overflowFocusNode,
builder: (context, _) {
final showFocus = _overflowFocusNode.hasFocus && InputModeTracker.isKeyboardMode(context);
return Focus(
focusNode: _overflowFocusNode,
descendantsAreFocusable: false,
onKeyEvent: (node, event) {
final backResult = handleBackKeyAction(event, _pop);
if (backResult != KeyEventResult.ignored) return backResult;
return dpadKeyHandler(
onSelect: () => contextMenuKey.currentState?.showContextMenu(context),
onDown: _seekFocusNode.requestFocus,
onUp: () {},
trapHorizontalEdges: true,
)(node, event);
},
child: AnimatedContainer(
duration: FocusTheme.getAnimationDuration(context),
decoration: FocusTheme.textFillFocusDecoration(context, isFocused: showFocus, borderRadius: 20),
child: button,
),
);
},
child: AnimatedContainer(
duration: FocusTheme.getAnimationDuration(context),
decoration: FocusTheme.textFillFocusDecoration(context, isFocused: showFocus, borderRadius: 20),
child: button,
),
);
}
+13 -4
View File
@@ -91,10 +91,11 @@ abstract class MusicPlaybackService extends ChangeNotifier {
Future<void> seek(Duration position);
/// Music playback volume, 0100. Persisted across sessions and applied to
/// every audio player instance; independent of the video player volume.
/// Music playback volume, 0100. Preview updates are exposed separately so
/// a slider does not notify every service consumer on each drag event.
double get volume;
Future<void> setVolume(double volume);
ValueListenable<double> get volumeListenable;
Future<void> setVolume(double volume, {bool persist = true});
void setRepeatMode(MusicRepeatMode mode);
void toggleShuffle();
@@ -144,6 +145,7 @@ abstract class MusicPlaybackService extends ChangeNotifier {
/// on platforms where it failed to initialize). Keeps every UI consumer
/// null-safe without per-call-site feature checks.
class StubMusicPlaybackService extends MusicPlaybackService {
final ValueNotifier<double> _volumeNotifier = ValueNotifier<double>(100);
@override
bool get isAvailable => false;
@@ -211,9 +213,11 @@ class StubMusicPlaybackService extends MusicPlaybackService {
@override
double get volume => 100;
@override
ValueListenable<double> get volumeListenable => _volumeNotifier;
@override
Future<void> setVolume(double volume) async {}
Future<void> setVolume(double volume, {bool persist = true}) async {}
@override
void setRepeatMode(MusicRepeatMode mode) {}
@@ -259,4 +263,9 @@ class StubMusicPlaybackService extends MusicPlaybackService {
@override
Future<Lyrics?> fetchLyrics(MediaItem track) async => null;
@override
void dispose() {
_volumeNotifier.dispose();
super.dispose();
}
}
@@ -1,5 +1,6 @@
import 'dart:async';
import 'package:flutter/foundation.dart' show ValueListenable;
import 'package:flutter/widgets.dart';
import 'package:os_media_controls/os_media_controls.dart';
@@ -102,6 +103,7 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
/// (the core is recreated after video claims playback). Falls back to full
/// volume when settings aren't bootstrapped (tests).
double _volume = SettingsService.instanceOrNull?.read(SettingsService.musicVolume) ?? 100.0;
late final ValueNotifier<double> _volumeNotifier = ValueNotifier<double>(_volume);
Player? _player;
final List<StreamSubscription<Object?>> _playerSubs = [];
@@ -842,16 +844,21 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
@override
double get volume => _volume;
@override
ValueListenable<double> get volumeListenable => _volumeNotifier;
@override
Future<void> setVolume(double volume) async {
Future<void> setVolume(double volume, {bool persist = true}) async {
final clamped = volume.clamp(0.0, 100.0);
if (clamped == _volume) return;
_volume = clamped;
notifyListeners();
final settings = SettingsService.instanceOrNull;
if (settings != null) unawaited(settings.write(SettingsService.musicVolume, clamped));
await _player?.setVolume(clamped);
if (clamped != _volume) {
_volume = clamped;
_volumeNotifier.value = clamped;
await _player?.setVolume(clamped);
}
if (persist) {
final settings = SettingsService.instanceOrNull;
if (settings != null) await settings.write(SettingsService.musicVolume, clamped);
}
}
@override
@@ -1114,6 +1121,7 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
}
unawaited(_positionController.close());
unawaited(_errorsController.close());
_volumeNotifier.dispose();
super.dispose();
}
}
+11 -2
View File
@@ -28,8 +28,17 @@ mixin HierarchicalEventMixin {
this.globalKey == globalKey || parentChain.any((pk) => buildGlobalKey(serverId, pk) == globalKey);
/// Check if this event affects any item in a collection.
bool affectsAnyOf(Iterable<String> itemIds) => itemIds.any(affectsItem);
bool affectsAnyOf(Iterable<String> itemIds) {
if (itemIds.contains(itemId)) return true;
return parentChain.any(itemIds.contains);
}
/// Check if this event affects any item in a global-key collection.
bool affectsAnyGlobalKey(Iterable<String> globalKeys) => globalKeys.any(affectsGlobalKey);
bool affectsAnyGlobalKey(Iterable<String> globalKeys) {
if (globalKeys.contains(globalKey)) return true;
for (final parentId in parentChain) {
if (globalKeys.contains(buildGlobalKey(serverId, parentId))) return true;
}
return false;
}
}
@@ -390,12 +390,15 @@ class WaitingForParticipantsIndicator extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Selector<WatchTogetherProvider, (bool, List<String>)>(
selector: (_, provider) => (provider.isWaitingForPeers, provider.waitingOnNames),
return Selector<WatchTogetherProvider, (bool, String)>(
selector: (_, provider) {
final waiting = provider.isWaitingForPeers;
return (waiting, waiting ? _label(provider.waitingOnNames) : '');
},
builder: (context, value, child) {
final (isWaiting, names) = value;
final (isWaiting, label) = value;
if (!isWaiting) return const SizedBox.shrink();
return _StatusPill(tvIcon: Symbols.hourglass_empty_rounded, label: _label(names));
return _StatusPill(tvIcon: Symbols.hourglass_empty_rounded, label: label);
},
);
}
+20
View File
@@ -76,6 +76,26 @@ void main() {
expect(service.current[acct.id]!.firstWhere((u) => u.admin).uuid, 'admin-uuid');
});
test('identical refreshes do not emit a second cache snapshot', () async {
service = PlexHomeService(
connections: connections,
profileConnections: profileConnections,
storage: storage,
plexHomeUserFetcher: (_) async => [_user('same-user')],
);
final acct = _account('plex.same');
await connections.upsert(acct);
final emissions = <Map<String, List<PlexHomeUser>>>[];
final subscription = service.stream.listen(emissions.add);
addTearDown(subscription.cancel);
await service.refresh(acct);
await service.refresh(acct);
await Future<void>.delayed(Duration.zero);
expect(emissions, hasLength(2));
expect(emissions.last[acct.id]!.single.uuid, 'same-user');
});
test('refresh persists users to SharedPreferences', () async {
service = PlexHomeService(
connections: connections,
@@ -630,6 +630,52 @@ void main() {
p.dispose();
});
test('deleting an album emits one provider notification for all tracks', () async {
MediaItem track(String id) => testMediaItem(
id: id,
backend: MediaBackend.plex,
kind: MediaKind.track,
title: id,
parentId: 'album-1',
serverId: ServerId('srv'),
);
final album = testMediaItem(
id: 'album-1',
backend: MediaBackend.plex,
kind: MediaKind.album,
title: 'Album',
serverId: ServerId('srv'),
);
for (final id in ['t1', 't2']) {
await db.insertDownload(
serverId: ServerId('srv'),
ratingKey: id,
globalKey: 'srv:$id',
type: 'track',
status: DownloadStatus.completed.index,
);
await db.addDownloadOwner(profileId: 'test-profile', globalKey: 'srv:$id');
}
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await p.ensureInitialized();
p.debugSeedState(
downloads: {
'srv:t1': const DownloadProgress(globalKey: 'srv:t1', status: DownloadStatus.completed),
'srv:t2': const DownloadProgress(globalKey: 'srv:t2', status: DownloadStatus.completed),
},
metadata: {'srv:album-1': album, 'srv:t1': track('t1'), 'srv:t2': track('t2')},
ownedDownloadKeys: {'srv:t1', 'srv:t2'},
);
var notifications = 0;
p.addListener(() => notifications++);
await p.deleteDownload(album.globalKey);
expect(notifications, 1);
expect(p.downloads, isEmpty);
p.dispose();
});
test('album aggregates, downloadedAlbums, and per-album track order come from track downloads', () async {
MediaItem track(String id, {required int disc, required int number}) => testMediaItem(
id: id,
@@ -88,6 +88,7 @@ class FakePlayer implements Player {
final List<String> openedUris = [];
final List<Media?> setNextCalls = [];
final List<Duration> seeks = [];
final List<double> volumes = [];
/// Arming these URIs throws, simulating a native setNext failure.
final Set<String> failingSetNextUris = {};
@@ -249,7 +250,7 @@ class FakePlayer implements Player {
Future<void> addSubtitleTrack({required String uri, String? title, String? language, bool select = false}) async {}
@override
Future<void> setVolume(double volume) async {}
Future<void> setVolume(double volume) async => volumes.add(volume);
@override
Future<void> setRate(double rate) async {}
@@ -553,6 +554,21 @@ void main() {
h.controls.closeControllers();
});
test('volume updates notify only the dedicated volume listenable', () async {
await h.playTracks([t1]);
var serviceNotifications = 0;
var volumeNotifications = 0;
h.service.addListener(() => serviceNotifications++);
h.service.volumeListenable.addListener(() => volumeNotifications++);
await h.service.setVolume(42, persist: false);
expect(h.service.volume, 42);
expect(h.player.volumes, [42]);
expect(volumeNotifications, 1);
expect(serviceNotifications, 0);
});
test('playFromList opens the first track and arms the second', () async {
await h.playTracks([t1, t2, t3]);