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
171 lines
5.2 KiB
Dart
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;
|
|
}
|