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
+182
View File
@@ -0,0 +1,182 @@
import 'dart:ffi';
import 'dart:io';
import 'dart:typed_data';
import 'package:ffi/ffi.dart';
import 'package:image/image.dart' as img;
// ── Windows constants ────────────────────────────────────────────────────────
const _CF_DIB = 8;
const _CF_DIBV4 = 20;
// ── Native function typedefs ─────────────────────────────────────────────────
typedef _OpenClipboardNative = Int32 Function(IntPtr hwnd);
typedef _OpenClipboard = int Function(int hwnd);
typedef _CloseClipboardNative = Int32 Function();
typedef _CloseClipboard = int Function();
typedef _IsFormatAvailableNative = Int32 Function(Uint32 format);
typedef _IsFormatAvailable = int Function(int format);
typedef _GetClipboardDataNative = IntPtr Function(Uint32 format);
typedef _GetClipboardData = int Function(int format);
typedef _GlobalSizeNative = IntPtr Function(IntPtr hMem);
typedef _GlobalSize = int Function(int hMem);
typedef _GlobalLockNative = Pointer<Uint8> Function(IntPtr hMem);
typedef _GlobalLock = Pointer<Uint8> Function(int hMem);
typedef _GlobalUnlockNative = Int32 Function(IntPtr hMem);
typedef _GlobalUnlock = int Function(int hMem);
typedef _RegisterFormatNative = Uint32 Function(Pointer<Utf16> name);
typedef _RegisterFormat = int Function(Pointer<Utf16> name);
// ── Public API ───────────────────────────────────────────────────────────────
/// Returns image bytes (PNG) from the Windows clipboard, or null if there is
/// no image or the platform is not Windows.
///
/// Checks the following formats in order:
/// 1. "PNG" custom registered format used by browsers / screenshot tools
/// 2. CF_DIBV4 / CF_DIB Device-Independent Bitmap (Win+Shift+S, etc.)
Uint8List? getClipboardImageBytes() {
if (!Platform.isWindows) return null;
try {
return _readClipboardImage();
} catch (_) {
return null;
}
}
// ── Implementation ───────────────────────────────────────────────────────────
Uint8List? _readClipboardImage() {
final user32 = DynamicLibrary.open('user32.dll');
final kernel32 = DynamicLibrary.open('kernel32.dll');
final openClipboard =
user32.lookupFunction<_OpenClipboardNative, _OpenClipboard>('OpenClipboard');
final closeClipboard =
user32.lookupFunction<_CloseClipboardNative, _CloseClipboard>('CloseClipboard');
final isFormatAvailable =
user32.lookupFunction<_IsFormatAvailableNative, _IsFormatAvailable>(
'IsClipboardFormatAvailable');
final getClipboardData =
user32.lookupFunction<_GetClipboardDataNative, _GetClipboardData>('GetClipboardData');
final registerFormat =
user32.lookupFunction<_RegisterFormatNative, _RegisterFormat>(
'RegisterClipboardFormatW');
final globalSize =
kernel32.lookupFunction<_GlobalSizeNative, _GlobalSize>('GlobalSize');
final globalLock =
kernel32.lookupFunction<_GlobalLockNative, _GlobalLock>('GlobalLock');
final globalUnlock =
kernel32.lookupFunction<_GlobalUnlockNative, _GlobalUnlock>('GlobalUnlock');
// Register custom "PNG" format (Chrome, Edge, Firefox, Snipping Tool export)
final pngNamePtr = 'PNG'.toNativeUtf16();
final pngFormat = registerFormat(pngNamePtr);
malloc.free(pngNamePtr);
if (openClipboard(0) == 0) return null;
try {
// 1. Try custom PNG format — bytes are ready-to-use PNG
if (pngFormat != 0 && isFormatAvailable(pngFormat) != 0) {
final bytes = _readGlobalBytes(
getClipboardData(pngFormat), globalSize, globalLock, globalUnlock);
if (bytes != null && bytes.isNotEmpty) return bytes;
}
// 2. Try CF_DIBV4 then CF_DIB — convert DIB to PNG via the image package
for (final fmt in [_CF_DIBV4, _CF_DIB]) {
if (isFormatAvailable(fmt) != 0) {
final dibBytes = _readGlobalBytes(
getClipboardData(fmt), globalSize, globalLock, globalUnlock);
if (dibBytes != null && dibBytes.isNotEmpty) {
final png = _dibToPng(dibBytes);
if (png != null) return png;
}
}
}
return null;
} finally {
closeClipboard();
}
}
/// Copy the bytes pointed to by a global memory handle.
Uint8List? _readGlobalBytes(
int handle,
_GlobalSize globalSize,
_GlobalLock globalLock,
_GlobalUnlock globalUnlock,
) {
if (handle == 0) return null;
final size = globalSize(handle);
if (size == 0) return null;
final ptr = globalLock(handle);
if (ptr.address == 0) return null;
try {
return Uint8List.fromList(ptr.asTypedList(size));
} finally {
globalUnlock(handle);
}
}
/// Convert a CF_DIB / CF_DIBV4 block to PNG bytes using the `image` package.
///
/// A DIB is essentially a BMP without the 14-byte file header. We prepend the
/// header and let the `image` package decode it.
Uint8List? _dibToPng(Uint8List dib) {
if (dib.length < 40) return null; // minimum BITMAPINFOHEADER size
final bd = ByteData.sublistView(dib);
final headerSize = bd.getUint32(0, Endian.little); // biSize
final bitCount = bd.getUint16(14, Endian.little); // biBitCount
final compression = bd.getUint32(16, Endian.little); // biCompression
final clrUsed = bd.getUint32(32, Endian.little); // biClrUsed
// Calculate color table size (in bytes, each entry = 4 bytes RGBQUAD)
int colorTableEntries;
if (bitCount <= 8) {
colorTableEntries = clrUsed != 0 ? clrUsed : (1 << bitCount);
} else if (compression == 3 /* BI_BITFIELDS */ || compression == 6 /* BI_ALPHABITFIELDS */) {
colorTableEntries = 0;
} else {
colorTableEntries = clrUsed;
}
final colorTableBytes = colorTableEntries * 4;
// Offset to pixel data from start of FILE (= 14 file header bytes + DIB)
final pixelOffset = 14 + headerSize + colorTableBytes;
// Build the 14-byte BITMAPFILEHEADER
final fileSize = 14 + dib.length;
final fileHeader = Uint8List(14);
final fh = ByteData.sublistView(fileHeader);
fileHeader[0] = 0x42; // 'B'
fileHeader[1] = 0x4D; // 'M'
fh.setUint32(2, fileSize, Endian.little);
fh.setUint32(6, 0, Endian.little);
fh.setUint32(10, pixelOffset, Endian.little);
final bmp = Uint8List(fileHeader.length + dib.length);
bmp.setRange(0, fileHeader.length, fileHeader);
bmp.setRange(fileHeader.length, bmp.length, dib);
try {
final decoded = img.decodeBmp(bmp);
if (decoded == null) return null;
return Uint8List.fromList(img.encodePng(decoded));
} catch (_) {
return null;
}
}
+444
View File
@@ -0,0 +1,444 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:http/http.dart' as http;
const _stickerServerUrl = 'https://stickers.steggi-matrix.work';
class GifFavoriteService {
// ── GIF Favorites cache ─────────────────────────────────────────────────
static final List<Map<String, dynamic>> _cache = [];
static bool _loaded = false;
// ── Sticker Favorites cache ──────────────────────────────────────────────
static final List<Map<String, dynamic>> _stickerCache = [];
static bool _stickerLoaded = false;
static Future<List<Map<String, dynamic>>> load() async {
if (_loaded) return _cache;
try {
final res = await http.get(Uri.parse('$_stickerServerUrl/gif-favorites'));
final data = jsonDecode(res.body) as List;
_cache
..clear()
..addAll(data.cast<Map<String, dynamic>>());
_loaded = true;
} catch (_) {}
return _cache;
}
static List<Map<String, dynamic>> get cache => List.unmodifiable(_cache);
/// True if the given https:// URL is already a favorite.
static bool isFavorite(String url) =>
_cache.any((f) => f['url'] == url);
/// True if a favorite was created from this mxc:// source URL.
static bool isFavoriteByMxc(String mxcUrl) =>
_cache.any((f) => f['mxc_source'] == mxcUrl);
/// Upload raw GIF/WebP bytes to the Pi server.
/// Returns the public https:// URL on success, null on failure.
static Future<String?> uploadToPi(Uint8List bytes, String mimeType) async {
try {
final res = await http.post(
Uri.parse('$_stickerServerUrl/gif-file-upload'),
headers: {'Content-Type': mimeType},
body: bytes,
);
if (res.statusCode == 200) {
final data = jsonDecode(res.body);
return data['url'] as String?;
}
} catch (_) {}
return null;
}
/// Toggle a plain external GIF (Giphy etc.).
static Future<void> toggle(
String url,
String previewUrl,
String title,
) async {
final removing = isFavorite(url);
if (removing) {
_cache.removeWhere((f) => f['url'] == url);
} else {
_cache.add({'url': url, 'preview_url': previewUrl, 'title': title});
}
try {
await http.post(
Uri.parse('$_stickerServerUrl/gif-favorites'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'action': removing ? 'remove' : 'add',
'url': url,
'preview_url': previewUrl,
'title': title,
}),
);
} catch (_) {
// Revert on error
if (removing) {
_cache.add({'url': url, 'preview_url': previewUrl, 'title': title});
} else {
_cache.removeWhere((f) => f['url'] == url);
}
}
}
/// Add a favorite that was uploaded from an mxc:// source.
/// [url] is the Pi-hosted https:// URL; [mxcSource] is the original mxc:// URL.
static Future<void> addWithMxcSource(
String url,
String previewUrl,
String title,
String mxcSource,
) async {
final entry = {
'url': url,
'preview_url': previewUrl,
'title': title,
'mxc_source': mxcSource,
};
_cache.add(entry);
try {
await http.post(
Uri.parse('$_stickerServerUrl/gif-favorites'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'action': 'add',
'url': url,
'preview_url': previewUrl,
'title': title,
'mxc_source': mxcSource,
}),
);
} catch (_) {
_cache.removeWhere((f) => f['mxc_source'] == mxcSource);
}
}
/// Remove a favorite that was originally added from an mxc:// source.
static Future<void> removeByMxc(String mxcSource) async {
_cache.removeWhere((f) => f['mxc_source'] == mxcSource);
try {
await http.post(
Uri.parse('$_stickerServerUrl/gif-favorites'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'action': 'remove',
'url': '',
'mxc_source': mxcSource,
}),
);
} catch (_) {}
}
/// Extracts the URL logic from a matrix event to easily toggle favoriting globally.
static Future<bool> toggleEventFavorites(dynamic event) async {
await load();
String rawUrl = '';
final url = event.content['url'] as String?;
if (url != null && url.isNotEmpty) {
rawUrl = url;
} else {
final fileObj = event.content['file'];
if (fileObj is Map) {
final fileUrl = fileObj['url'];
if (fileUrl is String && fileUrl.isNotEmpty) rawUrl = fileUrl;
}
}
if (rawUrl.isEmpty) return false;
final u = Uri.tryParse(rawUrl);
final isExternal = u != null && (u.scheme == 'https' || u.scheme == 'http');
final title = event.content['body'] as String? ?? 'Sticker';
final isFav = isExternal ? isFavorite(rawUrl) : isFavoriteByMxc(rawUrl);
if (isExternal) {
await toggle(rawUrl, rawUrl, title);
return true;
} else if (rawUrl.startsWith('mxc://')) {
if (isFav) {
await removeByMxc(rawUrl);
return true;
} else {
try {
final matrixFile = await event.downloadAndDecryptAttachment(getThumbnail: false);
final mimeType = event.infoMap['mimetype'] as String? ?? 'image/webp';
final piUrl = await uploadToPi(matrixFile.bytes, mimeType);
if (piUrl != null) {
await addWithMxcSource(piUrl, piUrl, title, rawUrl);
return true;
}
} catch (e) {
return false;
}
}
}
return false;
}
// ── Sticker Favorites ────────────────────────────────────────────────────
/// Load sticker favorites from the server into the sticker cache.
static Future<List<Map<String, dynamic>>> loadStickers() async {
if (_stickerLoaded) return _stickerCache;
try {
final res = await http.get(Uri.parse('$_stickerServerUrl/sticker-favorites'));
final data = jsonDecode(res.body) as List;
_stickerCache
..clear()
..addAll(data.cast<Map<String, dynamic>>());
_stickerLoaded = true;
} catch (_) {}
return _stickerCache;
}
static List<Map<String, dynamic>> get stickerCache => List.unmodifiable(_stickerCache);
/// All user-created stickers (saved from images). Shown in "Meine Sticker".
static List<Map<String, dynamic>> get userStickerItems =>
List.unmodifiable(
_stickerCache.where((s) => s['user_sticker'] == true).toList(),
);
/// Explicitly favorited stickers. Includes:
/// - User stickers with favorited == true
/// - Pack sticker favorites (entries without user_sticker == true)
static List<Map<String, dynamic>> get stickerFavoritesItems =>
List.unmodifiable(
_stickerCache.where((s) {
if (s['user_sticker'] == true) return s['favorited'] == true;
return true;
}).toList(),
);
/// Remove a sticker from the local cache immediately (without a server round-trip).
/// Call this after successfully posting a remove action to the server.
static void removeSticker(String mxcUrl) {
_stickerCache.removeWhere((s) => s['mxc_source'] == mxcUrl);
}
/// True if this mxc:// URL is in the effective favorites list.
static bool isStickerFavorite(String mxcUrl) =>
stickerFavoritesItems.any((s) => s['mxc_source'] == mxcUrl);
/// Toggle the favorited flag on a user sticker. Returns true on success.
static Future<bool> setUserStickerFavorited(String mxcSource, bool favorited) async {
final idx = _stickerCache.indexWhere((s) => s['mxc_source'] == mxcSource);
if (idx == -1) return false;
_stickerCache[idx]['favorited'] = favorited;
try {
await http.post(
Uri.parse('$_stickerServerUrl/sticker-favorites'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'action': 'set_favorited',
'mxc_source': mxcSource,
'favorited': favorited,
}),
);
return true;
} catch (_) {
_stickerCache[idx]['favorited'] = !favorited;
return false;
}
}
/// Add or remove any image/sticker event from sticker favorites.
/// Handles both unencrypted (content['url']) and encrypted (content['file']['url']) events.
/// For encrypted events, re-uploads the decrypted bytes to Matrix so the
/// stored mxc:// URL can be sent as a sticker without decryption keys.
static Future<bool> addImageAsStickerFavorite(dynamic event) async {
await loadStickers();
// Resolve original mxc URL (used as deduplication key)
String rawMxc = event.content['url'] as String? ?? '';
final bool isEncrypted = event.content['file'] != null;
if (rawMxc.isEmpty && isEncrypted) {
final fileObj = event.content['file'];
if (fileObj is Map) rawMxc = fileObj['url'] as String? ?? '';
}
if (rawMxc.isEmpty) return false;
final title = event.content['body'] as String? ?? 'Sticker';
// Check if ALREADY in cache (by mxc_source), regardless of favorited status.
// This prevents duplicates when user taps "Als Sticker speichern" on an already-saved sticker.
final existingIdx = _stickerCache.indexWhere((s) => s['mxc_source'] == rawMxc);
if (existingIdx != -1) {
// Toggle: remove the existing user sticker
final backup = Map<String, dynamic>.from(_stickerCache[existingIdx]);
_stickerCache.removeAt(existingIdx);
try {
await http.post(
Uri.parse('$_stickerServerUrl/sticker-favorites'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'action': 'remove', 'mxc_source': rawMxc}),
);
return true;
} catch (_) {
_stickerCache.insert(existingIdx, backup);
return false;
}
} else {
try {
final matrixFile = await event.downloadAndDecryptAttachment(getThumbnail: false);
final mimeType = (event.infoMap['mimetype'] as String?) ?? 'image/png';
// For encrypted events the original mxc:// points to ciphertext.
// Re-upload the decrypted bytes to get a plain, sendable mxc:// URL.
String stickerMxc;
if (isEncrypted) {
final dynamic uploadedUri = await event.room.client.uploadContent(
matrixFile.bytes,
filename: 'sticker',
contentType: mimeType,
);
stickerMxc = uploadedUri.toString();
} else {
stickerMxc = rawMxc;
}
// Also upload to Pi server for the picker preview thumbnail
final piUrl = await uploadToPi(matrixFile.bytes, mimeType);
if (piUrl == null) return false;
final entry = {
'mxc_source': rawMxc, // deduplication key (original encrypted or plain mxc)
'mxc': stickerMxc, // sendable unencrypted mxc:// URL
'url': piUrl, // Pi preview URL shown in the picker
'title': title,
'mimetype': mimeType,
'user_sticker': true, // created from an image, shown in "Meine Sticker"
'favorited': false, // not in favorites until explicitly hearted
};
_stickerCache.add(entry);
await http.post(
Uri.parse('$_stickerServerUrl/sticker-favorites'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'action': 'add', ...entry}),
);
return true;
} catch (_) {
_stickerCache.removeWhere((s) => s['mxc_source'] == rawMxc);
return false;
}
}
}
/// Add or remove a pack sticker from favorites using its already-accessible
/// thumbnail URL — no download or re-upload needed.
static Future<bool> togglePackStickerFavorite({
required String mxcUrl,
required String thumbUrl,
required String title,
String mimetype = 'image/webp',
}) async {
await loadStickers();
final isFav = isStickerFavorite(mxcUrl);
if (isFav) {
_stickerCache.removeWhere((s) => s['mxc_source'] == mxcUrl);
try {
await http.post(
Uri.parse('$_stickerServerUrl/sticker-favorites'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'action': 'remove', 'mxc_source': mxcUrl}),
);
return true;
} catch (_) {
_stickerCache.add({'mxc_source': mxcUrl, 'mxc': mxcUrl, 'url': thumbUrl, 'title': title});
return false;
}
} else {
final entry = {
'mxc_source': mxcUrl,
'mxc': mxcUrl,
'url': thumbUrl,
'title': title,
'mimetype': mimetype,
'user_sticker': false,
'favorited': false,
};
_stickerCache.add(entry);
try {
await http.post(
Uri.parse('$_stickerServerUrl/sticker-favorites'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'action': 'add', ...entry}),
);
return true;
} catch (_) {
_stickerCache.removeWhere((s) => s['mxc_source'] == mxcUrl);
return false;
}
}
}
/// Toggle a sticker event into/out of the sticker favorites.
/// For user-created stickers, only the favorited flag is toggled.
/// For pack stickers, the sticker is added/removed from the favorites list.
static Future<bool> toggleStickerFavorite(dynamic event) async {
await loadStickers();
final rawMxc = event.content['url'] as String? ?? '';
if (rawMxc.isEmpty) return false;
final title = event.content['body'] as String? ?? 'Sticker';
// If it's a user-created sticker, toggle its favorited flag only
final userIdx = _stickerCache.indexWhere(
(s) => s['mxc_source'] == rawMxc && s['user_sticker'] == true,
);
if (userIdx != -1) {
final currentFav = _stickerCache[userIdx]['favorited'] == true;
return setUserStickerFavorited(rawMxc, !currentFav);
}
final isFav = isStickerFavorite(rawMxc);
if (isFav) {
_stickerCache.removeWhere((s) => s['mxc_source'] == rawMxc);
try {
await http.post(
Uri.parse('$_stickerServerUrl/sticker-favorites'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'action': 'remove', 'mxc_source': rawMxc}),
);
return true;
} catch (_) {
_stickerCache.add({'mxc_source': rawMxc, 'title': title});
return false;
}
} else {
// Upload thumbnail bytes to Pi for preview
try {
final matrixFile = await event.downloadAndDecryptAttachment(getThumbnail: false);
final mimeType = event.infoMap['mimetype'] as String? ?? 'image/webp';
final piUrl = await uploadToPi(matrixFile.bytes, mimeType);
if (piUrl == null) return false;
final entry = {
'mxc_source': rawMxc,
'mxc': rawMxc,
'url': piUrl,
'title': title,
'mimetype': mimeType,
'user_sticker': false,
'favorited': false,
};
_stickerCache.add(entry);
await http.post(
Uri.parse('$_stickerServerUrl/sticker-favorites'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'action': 'add', ...entry}),
);
return true;
} catch (_) {
_stickerCache.removeWhere((s) => s['mxc_source'] == rawMxc);
return false;
}
}
}
}