feat(profiles): show the first linked connection's user picture
A local profile had no picture of its own and always fell back to
initials. It now borrows the user picture of the connection it was
linked to first — oldest Connection.createdAt, ties broken by
connection id, since the join table carries no creation time.
Jellyfin links resolve to /Users/{id}/Images/Primary, keyed by the
PrimaryImageTag now captured at authentication and refreshed from the
/Users/Me body checkHealth already fetches. That endpoint is anonymous
on every Jellyfin release, so the URL carries no api_key and the access
token stays out of the image cache key. Plex links resolve the Home
user the link points at against PlexHomeService's live cache, so no
account-level lookup is needed and the picture tracks Plex's own
refresh.
The picture is derived per snapshot and never written back onto a
Profile: ProfileDetailScreen upserts the model it holds, so a
persisted URL would go stale and outlive the connection it came from.
Plex Home profiles are untouched, including one whose Plex avatar is
unset — it keeps its initials rather than borrowing a lent connection's
picture.
close #1667
This commit is contained in:
@@ -104,6 +104,7 @@ void main() {
|
||||
registry: profileRegistry,
|
||||
plexHome: plexHome,
|
||||
connections: connectionRegistry,
|
||||
profileConnections: profileConnectionRegistry,
|
||||
storage: storage,
|
||||
);
|
||||
final discoverProvider = DiscoverProvider(
|
||||
@@ -295,6 +296,7 @@ void main() {
|
||||
registry: profileRegistry,
|
||||
plexHome: plexHome,
|
||||
connections: connectionRegistry,
|
||||
profileConnections: profileConnectionRegistry,
|
||||
storage: storage,
|
||||
);
|
||||
final discoverProvider = DiscoverProvider(
|
||||
@@ -406,6 +408,7 @@ void main() {
|
||||
registry: profileRegistry,
|
||||
plexHome: plexHome,
|
||||
connections: connectionRegistry,
|
||||
profileConnections: profileConnectionRegistry,
|
||||
storage: storage,
|
||||
);
|
||||
final discoverProvider = DiscoverProvider(
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:plezy/connection/connection_registry.dart';
|
||||
import 'package:plezy/database/app_database.dart';
|
||||
import 'package:plezy/focus/input_mode_tracker.dart';
|
||||
import 'package:plezy/i18n/strings.g.dart';
|
||||
import 'package:plezy/profiles/active_profile_provider.dart';
|
||||
import 'package:plezy/profiles/plex_home_service.dart';
|
||||
import 'package:plezy/profiles/profile.dart';
|
||||
import 'package:plezy/profiles/profile_connection.dart';
|
||||
@@ -49,7 +50,19 @@ void main() {
|
||||
storage: storage,
|
||||
plexHomeUserFetcher: (_) async => const [],
|
||||
);
|
||||
// The screen reads its avatar through this provider, exactly as it does
|
||||
// under the app-lifetime scope in main.dart. Left uninitialized: this test
|
||||
// is about TV back handling, and an idle provider yields no avatar without
|
||||
// opening stream subscriptions the test would have to settle.
|
||||
final activeProfile = ActiveProfileProvider(
|
||||
registry: profiles,
|
||||
plexHome: plexHome,
|
||||
connections: connections,
|
||||
profileConnections: profileConnections,
|
||||
storage: storage,
|
||||
);
|
||||
addTearDown(() async {
|
||||
activeProfile.dispose();
|
||||
await plexHome.dispose();
|
||||
await db.close();
|
||||
});
|
||||
@@ -62,6 +75,7 @@ void main() {
|
||||
Provider<ProfileConnectionRegistry>.value(value: profileConnections),
|
||||
Provider<ConnectionRegistry>.value(value: connections),
|
||||
Provider<PlexHomeService>.value(value: plexHome),
|
||||
ChangeNotifierProvider<ActiveProfileProvider>.value(value: activeProfile),
|
||||
],
|
||||
child: InputModeTracker(
|
||||
child: MaterialApp(
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'package:plezy/i18n/strings.g.dart';
|
||||
import 'package:plezy/profiles/active_profile_provider.dart';
|
||||
import 'package:plezy/profiles/plex_home_service.dart';
|
||||
import 'package:plezy/profiles/profile.dart';
|
||||
import 'package:plezy/profiles/profile_avatar.dart';
|
||||
import 'package:plezy/profiles/profile_connection.dart';
|
||||
import 'package:plezy/profiles/profile_connection_registry.dart';
|
||||
import 'package:plezy/profiles/profile_registry.dart';
|
||||
@@ -44,6 +45,7 @@ void main() {
|
||||
registry: profiles,
|
||||
plexHome: plexHome,
|
||||
connections: connections,
|
||||
profileConnections: profileConnections,
|
||||
storage: storage,
|
||||
);
|
||||
addTearDown(() async {
|
||||
@@ -106,6 +108,7 @@ void main() {
|
||||
registry: profiles,
|
||||
plexHome: plexHome,
|
||||
connections: connections,
|
||||
profileConnections: profileConnections,
|
||||
storage: storage,
|
||||
);
|
||||
addTearDown(() async {
|
||||
@@ -132,6 +135,87 @@ void main() {
|
||||
|
||||
expect(tester.getTopLeft(find.text('Kids')).dy, lessThan(tester.getTopLeft(find.text('Owner')).dy));
|
||||
});
|
||||
|
||||
testWidgets('passes derived Jellyfin avatar URLs only to linked profile tiles', (tester) async {
|
||||
final db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||
final linkedProfile = Profile.local(id: 'local-linked', displayName: 'Linked', createdAt: DateTime(2026, 1, 1));
|
||||
final unlinkedProfile = Profile.local(
|
||||
id: 'local-unlinked',
|
||||
displayName: 'Unlinked',
|
||||
createdAt: DateTime(2026, 1, 2),
|
||||
);
|
||||
final jellyfin = JellyfinConnection(
|
||||
id: 'jf-machine/jf-user',
|
||||
baseUrl: 'https://jellyfin.example',
|
||||
serverName: 'Jellyfin',
|
||||
serverMachineId: 'jf-machine',
|
||||
userId: 'jf-user',
|
||||
userName: 'Linked',
|
||||
accessToken: 'secret-token',
|
||||
deviceId: 'device-1',
|
||||
primaryImageTag: 'primary-tag',
|
||||
createdAt: DateTime(2025, 12, 1),
|
||||
);
|
||||
final link = ProfileConnection(
|
||||
profileId: linkedProfile.id,
|
||||
connectionId: jellyfin.id,
|
||||
userIdentifier: jellyfin.userId,
|
||||
);
|
||||
final profiles = _FakeProfileRegistry(db, [linkedProfile, unlinkedProfile]);
|
||||
final connections = _FakeConnectionRegistry(db, [jellyfin]);
|
||||
final profileConnections = _FakeProfileConnectionRegistry(db, [link]);
|
||||
final storage = await StorageService.getInstance();
|
||||
final plexHome = PlexHomeService(
|
||||
connections: connections,
|
||||
profileConnections: profileConnections,
|
||||
storage: storage,
|
||||
plexHomeUserFetcher: (_) async => const [],
|
||||
);
|
||||
final activeProfile = ActiveProfileProvider(
|
||||
registry: profiles,
|
||||
plexHome: plexHome,
|
||||
connections: connections,
|
||||
profileConnections: profileConnections,
|
||||
storage: storage,
|
||||
);
|
||||
addTearDown(() async {
|
||||
activeProfile.dispose();
|
||||
await plexHome.dispose();
|
||||
await db.close();
|
||||
});
|
||||
|
||||
await tester.pumpWidget(
|
||||
TranslationProvider(
|
||||
child: MultiProvider(
|
||||
providers: [
|
||||
Provider<ProfileRegistry>.value(value: profiles),
|
||||
Provider<ProfileConnectionRegistry>.value(value: profileConnections),
|
||||
Provider<ConnectionRegistry>.value(value: connections),
|
||||
Provider<PlexHomeService>.value(value: plexHome),
|
||||
ChangeNotifierProvider<ActiveProfileProvider>.value(value: activeProfile),
|
||||
],
|
||||
child: MaterialApp(theme: monoTheme(dark: true), home: const ProfileSwitchScreen()),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final linkedAvatar = tester.widget<ProfileAvatar>(
|
||||
find.byWidgetPredicate((widget) => widget is ProfileAvatar && widget.profile?.id == linkedProfile.id),
|
||||
);
|
||||
final unlinkedAvatar = tester.widget<ProfileAvatar>(
|
||||
find.byWidgetPredicate((widget) => widget is ProfileAvatar && widget.profile?.id == unlinkedProfile.id),
|
||||
);
|
||||
expect(linkedAvatar.avatarUrl, isNotNull);
|
||||
expect(
|
||||
linkedAvatar.avatarUrl,
|
||||
'https://jellyfin.example/Users/jf-user/Images/Primary?tag=primary-tag&maxWidth=240&maxHeight=240',
|
||||
);
|
||||
expect(unlinkedAvatar.avatarUrl, isNull);
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
await tester.pump();
|
||||
});
|
||||
}
|
||||
|
||||
class _FakeProfileRegistry extends ProfileRegistry {
|
||||
@@ -147,18 +231,22 @@ class _FakeProfileRegistry extends ProfileRegistry {
|
||||
}
|
||||
|
||||
class _FakeConnectionRegistry extends ConnectionRegistry {
|
||||
_FakeConnectionRegistry(super.db);
|
||||
final List<Connection> _connections;
|
||||
|
||||
_FakeConnectionRegistry(super.db, [this._connections = const []]);
|
||||
|
||||
@override
|
||||
Stream<List<Connection>> watchConnections() => Stream.value(const []);
|
||||
Stream<List<Connection>> watchConnections() => Stream.value(_connections);
|
||||
|
||||
@override
|
||||
Future<List<Connection>> list() async => const [];
|
||||
Future<List<Connection>> list() async => _connections;
|
||||
}
|
||||
|
||||
class _FakeProfileConnectionRegistry extends ProfileConnectionRegistry {
|
||||
_FakeProfileConnectionRegistry(super.db);
|
||||
final List<ProfileConnection> _profileConnections;
|
||||
|
||||
_FakeProfileConnectionRegistry(super.db, [this._profileConnections = const []]);
|
||||
|
||||
@override
|
||||
Stream<List<ProfileConnection>> watchAll() => Stream.value(const []);
|
||||
Stream<List<ProfileConnection>> watchAll() => Stream.value(_profileConnections);
|
||||
}
|
||||
|
||||
@@ -268,6 +268,7 @@ Future<_Harness> _pumpHarness(
|
||||
registry: profileRegistry,
|
||||
plexHome: plexHome,
|
||||
connections: connections,
|
||||
profileConnections: profileConnections,
|
||||
storage: storage,
|
||||
);
|
||||
final profile = Profile.local(id: 'active', displayName: 'Active', createdAt: DateTime(2026, 1, 1));
|
||||
|
||||
@@ -168,6 +168,7 @@ class _NoWatchActiveProfileProvider extends ActiveProfileProvider {
|
||||
required super.registry,
|
||||
required super.plexHome,
|
||||
required super.connections,
|
||||
required super.profileConnections,
|
||||
required super.storage,
|
||||
});
|
||||
|
||||
@@ -242,6 +243,7 @@ class _RouteHarness {
|
||||
registry: profiles,
|
||||
plexHome: plexHome,
|
||||
connections: connections,
|
||||
profileConnections: profileConnections,
|
||||
storage: storage,
|
||||
);
|
||||
final manager = _CountingJellyfinManager();
|
||||
|
||||
@@ -79,6 +79,7 @@ class _RejectingActiveProfileProvider extends ActiveProfileProvider {
|
||||
required super.registry,
|
||||
required super.plexHome,
|
||||
required super.connections,
|
||||
required super.profileConnections,
|
||||
required super.storage,
|
||||
});
|
||||
|
||||
@@ -91,6 +92,7 @@ class _ThrowingActiveProfileProvider extends ActiveProfileProvider {
|
||||
required super.registry,
|
||||
required super.plexHome,
|
||||
required super.connections,
|
||||
required super.profileConnections,
|
||||
required super.storage,
|
||||
});
|
||||
|
||||
@@ -184,7 +186,13 @@ void main() {
|
||||
);
|
||||
activeProfiles =
|
||||
activeFactory?.call(profiles, plexHome, connections, storage) ??
|
||||
ActiveProfileProvider(registry: profiles, plexHome: plexHome, connections: connections, storage: storage);
|
||||
ActiveProfileProvider(
|
||||
registry: profiles,
|
||||
plexHome: plexHome,
|
||||
connections: connections,
|
||||
profileConnections: profileConnections,
|
||||
storage: storage,
|
||||
);
|
||||
if (initializeActive) await tester.runAsync(activeProfiles.initialize);
|
||||
await tester.pumpWidget(
|
||||
MultiProvider(
|
||||
@@ -347,6 +355,7 @@ void main() {
|
||||
registry: profiles,
|
||||
plexHome: plexHome,
|
||||
connections: connections,
|
||||
profileConnections: profileConnections,
|
||||
storage: storage,
|
||||
),
|
||||
);
|
||||
@@ -388,6 +397,7 @@ void main() {
|
||||
registry: profiles,
|
||||
plexHome: plexHome,
|
||||
connections: connections,
|
||||
profileConnections: profileConnections,
|
||||
storage: storage,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -642,7 +642,12 @@ Future<_SettingsHarness> _pumpSettingsScreen(
|
||||
profileConnections: profileConnections,
|
||||
plexHomeUserFetcher: (_) async => const [],
|
||||
);
|
||||
final activeProfile = ActiveProfileProvider(registry: profiles, plexHome: plexHome, connections: connections);
|
||||
final activeProfile = ActiveProfileProvider(
|
||||
registry: profiles,
|
||||
plexHome: plexHome,
|
||||
connections: connections,
|
||||
profileConnections: profileConnections,
|
||||
);
|
||||
final libraries = LibrariesProvider();
|
||||
final hiddenLibraries = HiddenLibrariesProvider(storageService: _FakeHiddenLibrariesStorage());
|
||||
await hiddenLibraries.ensureInitialized();
|
||||
|
||||
Reference in New Issue
Block a user