25ed765a03
6 Wochen uncommittete Arbeit (Voice-Channels, LiveKit-Manager, Settings-Modal u.v.m.) als ein WIP-Commit gesichert, damit nichts verloren geht und der Pi den aktuellen Stand klonen kann. Thematische Aufarbeitung: siehe ROADMAP M0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CPrAGBxBT6GfPXzeWQ4AXb
206 lines
5.9 KiB
Dart
206 lines
5.9 KiB
Dart
import 'dart:typed_data';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:matrix/matrix.dart';
|
|
import 'package:pyramid/core/media_cache.dart';
|
|
|
|
class MxcAvatar extends StatefulWidget {
|
|
final Uri? mxcUri;
|
|
final Client client;
|
|
final double size;
|
|
final BorderRadius? borderRadius;
|
|
final Widget Function(BuildContext) placeholder;
|
|
|
|
const MxcAvatar({
|
|
super.key,
|
|
required this.mxcUri,
|
|
required this.client,
|
|
required this.size,
|
|
required this.placeholder,
|
|
this.borderRadius,
|
|
});
|
|
|
|
@override
|
|
State<MxcAvatar> createState() => _MxcAvatarState();
|
|
}
|
|
|
|
class _MxcAvatarState extends State<MxcAvatar> {
|
|
late Future<Uint8List?> _future;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_future = _load();
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(MxcAvatar old) {
|
|
super.didUpdateWidget(old);
|
|
if (old.mxcUri != widget.mxcUri) _future = _load();
|
|
}
|
|
|
|
Future<Uint8List?> _load() async {
|
|
final mxc = widget.mxcUri;
|
|
if (mxc == null) return null;
|
|
|
|
final key = 'avatar:${mxc}_${widget.size.round()}';
|
|
// Memory → Disk → Netzwerk. Disk-Treffer machen Avatare nach einem
|
|
// App-Neustart sofort sichtbar statt nachzuladen.
|
|
final hit = await MediaCache.instance.getPersistent(key);
|
|
if (hit != null) return hit;
|
|
|
|
final headers = {'authorization': 'Bearer ${widget.client.accessToken}'};
|
|
|
|
try {
|
|
// Try thumbnail first; fall back to full download if 404 (server
|
|
// doesn't support on-the-fly thumbnailing, e.g. Continuwuity).
|
|
final thumbUri = await mxc.getThumbnailUri(
|
|
widget.client,
|
|
width: widget.size.round(),
|
|
height: widget.size.round(),
|
|
method: ThumbnailMethod.crop,
|
|
);
|
|
final thumbResp = await widget.client.httpClient.get(thumbUri, headers: headers);
|
|
if (thumbResp.statusCode == 200) {
|
|
await MediaCache.instance.putPersistent(key, thumbResp.bodyBytes);
|
|
return thumbResp.bodyBytes;
|
|
}
|
|
|
|
// Thumbnail not available — download full image.
|
|
final dlUri = await mxc.getDownloadUri(widget.client);
|
|
final dlResp = await widget.client.httpClient.get(dlUri, headers: headers);
|
|
if (dlResp.statusCode != 200) return null;
|
|
await MediaCache.instance.putPersistent(key, dlResp.bodyBytes);
|
|
return dlResp.bodyBytes;
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return FutureBuilder<Uint8List?>(
|
|
future: _future,
|
|
builder: (context, snap) {
|
|
if (snap.hasData && snap.data != null) {
|
|
return ClipRRect(
|
|
borderRadius: widget.borderRadius ??
|
|
BorderRadius.circular(widget.size / 2),
|
|
child: Image.memory(
|
|
snap.data!,
|
|
width: widget.size,
|
|
height: widget.size,
|
|
fit: BoxFit.cover,
|
|
errorBuilder: (_, __, ___) => widget.placeholder(context),
|
|
),
|
|
);
|
|
}
|
|
return widget.placeholder(context);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
// Rectangular MXC image (no fixed size, fills parent)
|
|
class MxcImage extends StatefulWidget {
|
|
final String mxcUri;
|
|
final Client client;
|
|
final BoxFit fit;
|
|
final double? width;
|
|
final double? height;
|
|
final Widget? placeholder;
|
|
|
|
const MxcImage({
|
|
super.key,
|
|
required this.mxcUri,
|
|
required this.client,
|
|
this.fit = BoxFit.cover,
|
|
this.width,
|
|
this.height,
|
|
this.placeholder,
|
|
});
|
|
|
|
@override
|
|
State<MxcImage> createState() => _MxcImageState();
|
|
}
|
|
|
|
class _MxcImageState extends State<MxcImage> {
|
|
late Future<Uint8List?> _future;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_future = _load();
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(MxcImage old) {
|
|
super.didUpdateWidget(old);
|
|
if (old.mxcUri != widget.mxcUri) {
|
|
// Block-Body: die Closure darf KEIN Future zurückgeben, sonst wirft
|
|
// Flutter "setState() callback returned a Future".
|
|
setState(() {
|
|
_future = _load();
|
|
});
|
|
}
|
|
}
|
|
|
|
Future<Uint8List?> _load() async {
|
|
final key = 'img:${widget.mxcUri}';
|
|
final hit = await MediaCache.instance.getPersistent(key);
|
|
if (hit != null) return hit;
|
|
try {
|
|
final uri = Uri.parse(widget.mxcUri);
|
|
final headers = {
|
|
'authorization': 'Bearer ${widget.client.accessToken}',
|
|
};
|
|
// Erst Thumbnail versuchen (spart Bandbreite, v.a. mobil) — Fallback
|
|
// auf Volldownload für Server ohne Thumbnail-Support (Continuwuity).
|
|
try {
|
|
final thumbUri = await uri.getThumbnailUri(
|
|
widget.client,
|
|
width: 800,
|
|
height: 600,
|
|
method: ThumbnailMethod.scale,
|
|
);
|
|
final res = await widget.client.httpClient.get(thumbUri, headers: headers);
|
|
if (res.statusCode == 200) {
|
|
await MediaCache.instance.putPersistent(key, res.bodyBytes);
|
|
return res.bodyBytes;
|
|
}
|
|
} catch (_) {}
|
|
final httpUri = await uri.getDownloadUri(widget.client);
|
|
final res = await widget.client.httpClient.get(httpUri, headers: headers);
|
|
if (res.statusCode != 200) return null;
|
|
final bytes = res.bodyBytes;
|
|
await MediaCache.instance.putPersistent(key, bytes);
|
|
return bytes;
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return FutureBuilder<Uint8List?>(
|
|
future: _future,
|
|
builder: (_, snap) {
|
|
if (snap.connectionState == ConnectionState.waiting) {
|
|
return widget.placeholder ?? const SizedBox.shrink();
|
|
}
|
|
if (snap.hasData && snap.data != null) {
|
|
return Image.memory(
|
|
snap.data!,
|
|
fit: widget.fit,
|
|
width: widget.width,
|
|
height: widget.height,
|
|
gaplessPlayback: true,
|
|
errorBuilder: (_, __, ___) =>
|
|
widget.placeholder ?? const SizedBox.shrink(),
|
|
);
|
|
}
|
|
return widget.placeholder ?? const SizedBox.shrink();
|
|
},
|
|
);
|
|
}
|
|
}
|