Files
plezy/lib/profiles/profiles_view.dart
T
edde746 860ce1e11a 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
2026-08-04 02:22:44 +02:00

171 lines
5.2 KiB
Dart

import 'dart:async';
import '../connection/connection.dart';
import '../connection/connection_registry.dart';
import '../models/plex/plex_home_user.dart';
import '../services/storage_service.dart';
import 'plex_home_service.dart';
import 'profile.dart';
import 'profile_avatar_source.dart';
import 'profile_connection.dart';
import 'profile_connection_registry.dart';
import 'profile_merge.dart';
import 'profile_registry.dart';
/// Snapshot for picker / manage-profiles UIs: every visible profile
/// (local rows from [ProfileRegistry] + virtual Plex Home profiles built
/// from [PlexHomeService]'s live cache) plus the data needed to render
/// per-profile connection chips.
class ProfilesView {
final List<Profile> profiles;
/// Per-profile borrowed connections. Does **not** include the Plex Home
/// parent — that's implicit via [Profile.parentConnectionId]. Plex Home
/// profiles can have entries here too (e.g. borrowed Jellyfin servers).
final Map<String, List<ProfileConnection>> connectionsByProfile;
final Map<String, Connection> connectionsById;
/// Picture URL per profile id; null means render initials. See [resolveProfileAvatarUrls].
final Map<String, String?> avatarUrlByProfile;
const ProfilesView({
required this.profiles,
required this.connectionsByProfile,
required this.connectionsById,
required this.avatarUrlByProfile,
});
static const empty = ProfilesView(
profiles: [],
connectionsByProfile: {},
connectionsById: {},
avatarUrlByProfile: {},
);
}
/// Join-table rows that should be shown as explicit, user-manageable
/// connections for [profile].
///
/// Plex Home profiles own their parent account implicitly through
/// [Profile.parentConnectionId]. A parent [ProfileConnection] row may still
/// exist as a token cache, but UI should not render it as a removable
/// borrowed connection.
List<ProfileConnection> visibleProfileConnections(Profile profile, List<ProfileConnection> pcs) {
final parentId = profile.parentConnectionId;
if (!profile.isPlexHome || parentId == null) return pcs;
return pcs.where((pc) => pc.connectionId != parentId).toList();
}
/// Combine [ProfileRegistry], [ProfileConnectionRegistry],
/// [ConnectionRegistry], and [PlexHomeService] into a single stream.
/// Plex Home profiles are constructed on the fly from the live cache; they
/// are never persisted as Profile rows.
Stream<ProfilesView> watchProfilesView({
required ProfileRegistry profiles,
required ProfileConnectionRegistry profileConnections,
required ConnectionRegistry connections,
required PlexHomeService plexHome,
StorageService? storage,
}) {
return _combineLatest4<
List<Profile>,
List<ProfileConnection>,
List<Connection>,
Map<String, List<PlexHomeUser>>,
ProfilesView
>(
profiles.watchProfiles(),
profileConnections.watchAll(),
connections.watchConnections(),
plexHome.stream,
(locals, pcs, conns, homes) => _build(locals: locals, pcs: pcs, conns: conns, homes: homes, storage: storage),
);
}
ProfilesView _build({
required List<Profile> locals,
required List<ProfileConnection> pcs,
required List<Connection> conns,
required Map<String, List<PlexHomeUser>> homes,
required StorageService? storage,
}) {
final connectionsById = {for (final c in conns) c.id: c};
final connectionsByProfile = groupConnectionsByProfile(pcs);
final all = mergeLocalWithPlexHome(
locals: locals,
plexHomeByConnectionId: homes,
connectionsById: connectionsById,
storage: storage,
);
return ProfilesView(
profiles: all,
connectionsByProfile: connectionsByProfile,
connectionsById: connectionsById,
avatarUrlByProfile: resolveProfileAvatarUrls(
profiles: all,
connectionsByProfile: connectionsByProfile,
connectionsById: connectionsById,
plexHomeByConnectionId: homes,
),
);
}
/// Lightweight `combineLatest4` — emits the combined value once each input
/// has produced a value, then on every subsequent tick from any input.
Stream<R> _combineLatest4<A, B, C, D, R>(
Stream<A> a,
Stream<B> b,
Stream<C> c,
Stream<D> d,
R Function(A, B, C, D) combine,
) {
late StreamController<R> controller;
StreamSubscription<A>? subA;
StreamSubscription<B>? subB;
StreamSubscription<C>? subC;
StreamSubscription<D>? subD;
A? lastA;
B? lastB;
C? lastC;
D? lastD;
var hasA = false, hasB = false, hasC = false, hasD = false;
void emit() {
if (hasA && hasB && hasC && hasD) controller.add(combine(lastA as A, lastB as B, lastC as C, lastD as D));
}
controller = StreamController<R>(
onListen: () {
subA = a.listen((v) {
lastA = v;
hasA = true;
emit();
}, onError: controller.addError);
subB = b.listen((v) {
lastB = v;
hasB = true;
emit();
}, onError: controller.addError);
subC = c.listen((v) {
lastC = v;
hasC = true;
emit();
}, onError: controller.addError);
subD = d.listen((v) {
lastD = v;
hasD = true;
emit();
}, onError: controller.addError);
},
onCancel: () async {
await subA?.cancel();
await subB?.cancel();
await subC?.cancel();
await subD?.cancel();
await controller.close();
},
);
return controller.stream;
}