wip: Sicherungs-Commit aller Änderungen seit April + Arbeitsstruktur (CLAUDE.md, ROADMAP.md, PROGRESS.md, Autopilot)
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
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
import 'dart:collection';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
/// Shared, bounded LRU cache for media bytes (chat images, document previews,
|
||||
/// avatars). Eviction is driven by a total byte budget plus a hard entry cap,
|
||||
/// so the most-recently-viewed media (i.e. your recent messages) stays instantly
|
||||
/// available while older media is dropped and re-fetched on demand.
|
||||
///
|
||||
/// In addition to the in-memory layer there is a small disk layer for
|
||||
/// persistent entries (avatars, banners), so they appear instantly after an
|
||||
/// app restart instead of popping in once re-downloaded.
|
||||
class MediaCache {
|
||||
MediaCache._();
|
||||
static final MediaCache instance = MediaCache._();
|
||||
|
||||
// ~256 MB in memory, capped at 300 entries — comfortably covers the last
|
||||
// ~100 messages of media per active chat without unbounded growth.
|
||||
static const int _maxBytes = 256 * 1024 * 1024;
|
||||
static const int _maxEntries = 300;
|
||||
// Don't cache very large single files (videos etc.) — they'd evict everything.
|
||||
static const int _maxEntryBytes = 32 * 1024 * 1024;
|
||||
|
||||
// Disk layer: only small entries (avatars, banners), bounded total size.
|
||||
static const int _maxDiskEntryBytes = 1024 * 1024; // 1 MB pro Eintrag
|
||||
static const int _maxDiskTotalBytes = 64 * 1024 * 1024; // 64 MB gesamt
|
||||
|
||||
final LinkedHashMap<String, Uint8List> _entries = LinkedHashMap();
|
||||
int _totalBytes = 0;
|
||||
|
||||
Directory? _diskDir;
|
||||
bool _pruneScheduled = false;
|
||||
|
||||
Uint8List? get(String key) {
|
||||
final v = _entries.remove(key);
|
||||
if (v != null) _entries[key] = v; // move to most-recently-used position
|
||||
return v;
|
||||
}
|
||||
|
||||
void put(String key, Uint8List bytes) {
|
||||
if (bytes.lengthInBytes > _maxEntryBytes) return;
|
||||
|
||||
final existing = _entries.remove(key);
|
||||
if (existing != null) _totalBytes -= existing.lengthInBytes;
|
||||
|
||||
_entries[key] = bytes;
|
||||
_totalBytes += bytes.lengthInBytes;
|
||||
|
||||
_evict();
|
||||
}
|
||||
|
||||
/// Memory lookup first, then disk. Disk hits are promoted back into memory.
|
||||
Future<Uint8List?> getPersistent(String key) async {
|
||||
final mem = get(key);
|
||||
if (mem != null) return mem;
|
||||
try {
|
||||
final file = await _diskFile(key);
|
||||
if (await file.exists()) {
|
||||
final bytes = await file.readAsBytes();
|
||||
if (bytes.isNotEmpty) {
|
||||
put(key, bytes);
|
||||
// Touch für LRU-Pruning nach mtime.
|
||||
file.setLastModified(DateTime.now()).catchError((_) {});
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Stores in memory and (for small entries) on disk, surviving restarts.
|
||||
Future<void> putPersistent(String key, Uint8List bytes) async {
|
||||
put(key, bytes);
|
||||
if (bytes.lengthInBytes > _maxDiskEntryBytes) return;
|
||||
try {
|
||||
final file = await _diskFile(key);
|
||||
await file.writeAsBytes(bytes);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<File> _diskFile(String key) async {
|
||||
final dir = _diskDir ??= await _initDiskDir();
|
||||
final name = sha1.convert(utf8.encode(key)).toString();
|
||||
return File('${dir.path}${Platform.pathSeparator}$name.bin');
|
||||
}
|
||||
|
||||
Future<Directory> _initDiskDir() async {
|
||||
final support = await getApplicationSupportDirectory();
|
||||
final dir = Directory(
|
||||
'${support.path}${Platform.pathSeparator}media_cache');
|
||||
await dir.create(recursive: true);
|
||||
_schedulePrune(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
/// Once per session: drop the oldest disk entries when over budget.
|
||||
void _schedulePrune(Directory dir) {
|
||||
if (_pruneScheduled) return;
|
||||
_pruneScheduled = true;
|
||||
Future(() async {
|
||||
try {
|
||||
final files = <File, FileStat>{};
|
||||
var total = 0;
|
||||
await for (final e in dir.list()) {
|
||||
if (e is! File) continue;
|
||||
final stat = await e.stat();
|
||||
files[e] = stat;
|
||||
total += stat.size;
|
||||
}
|
||||
if (total <= _maxDiskTotalBytes) return;
|
||||
final sorted = files.entries.toList()
|
||||
..sort((a, b) => a.value.modified.compareTo(b.value.modified));
|
||||
for (final entry in sorted) {
|
||||
if (total <= _maxDiskTotalBytes) break;
|
||||
total -= entry.value.size;
|
||||
await entry.key.delete().catchError((_) => entry.key);
|
||||
}
|
||||
} catch (_) {}
|
||||
});
|
||||
}
|
||||
|
||||
void _evict() {
|
||||
while ((_totalBytes > _maxBytes || _entries.length > _maxEntries) &&
|
||||
_entries.isNotEmpty) {
|
||||
final oldestKey = _entries.keys.first;
|
||||
final removed = _entries.remove(oldestKey);
|
||||
if (removed != null) _totalBytes -= removed.lengthInBytes;
|
||||
}
|
||||
}
|
||||
|
||||
void clear() {
|
||||
_entries.clear();
|
||||
_totalBytes = 0;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user