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:
Bernd Steckmeister
2026-07-03 05:47:18 +02:00
parent d706ace35f
commit 25ed765a03
195 changed files with 47784 additions and 1236 deletions
+139
View File
@@ -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;
}
}