@@ -285,6 +286,14 @@
Erscheint nur, während das Launcher-Fenster nicht im Vordergrund ist.
+
+
Freundesystem
+
+ Ausgeschaltet: du bist für niemanden sichtbar und kannst von niemandem als Freund gefunden/hinzugefügt werden. Bestehende Freundschaften bleiben erhalten, zeigen dich aber als offline.
+
+
Autostart
-
-
-
-
- Klick auf Vorschaubild zum Auswählen
+
+
+
+
+
+
+
+
+
+
+ Klick auf Vorschaubild zum Auswählen
+
+
+
+
+
+
+
+
+
+ Klick auf Vorschaubild zum Auswählen, Doppelklick zum Abspielen
+
+
-
@@ -947,6 +973,31 @@
+
+
+
+
+
Freunde
+
+
+
+
Das Freundesystem ist ausgeschaltet – aktiviere es in den Einstellungen, um Freunde zu sehen oder hinzuzufügen.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main.js b/src/main.js
index 8b0bccc..b7cbe4f 100644
--- a/src/main.js
+++ b/src/main.js
@@ -6,11 +6,16 @@
* (Vanilla-Start, Microsoft-Login und Mod-Loader folgen in späteren Phasen.)
*/
-const { app, BrowserWindow, ipcMain, dialog, shell, safeStorage, Notification, Tray, Menu, nativeImage, clipboard, desktopCapturer, session, screen } = require('electron');
+const { app, BrowserWindow, ipcMain, dialog, shell, safeStorage, Notification, Tray, Menu, nativeImage, clipboard, desktopCapturer, session, screen, globalShortcut } = require('electron');
const path = require('path');
const fs = require('fs');
const os = require('os');
const { execFile, spawn } = require('child_process');
+// Statisches ffmpeg-Binary für die Umwandlung der Spielaufzeichnung nach MP4
+// (siehe recording:save) - liefert null, falls kein Binary für diese
+// Plattform/Architektur vorliegt (dann bleibt die Aufnahme als .webm erhalten)
+let ffmpegPfad = null;
+try { ffmpegPfad = require('ffmpeg-static'); } catch { /* egal, siehe Rückfall in recording:save */ }
const pkg = require('../package.json');
const store = require('./store');
const services = require('./services');
@@ -33,6 +38,7 @@ const skins = require('./skins');
const servers = require('./servers');
const mcping = require('./mcping');
const discordpresence = require('./discordpresence');
+const friends = require('./friends');
// undici als globales fetch verwenden -> ermöglicht Proxy via setGlobalDispatcher
const undici = require('undici');
@@ -111,6 +117,7 @@ const POPOUT_SPEC = {
instance: { title: 'AeroMC – Instanz', width: 640, height: 760, minWidth: 520, minHeight: 500 },
update: { title: 'AeroMC – Update', width: 460, height: 460, minWidth: 420, minHeight: 380 },
record: { title: 'AeroMC – Spielaufzeichnung', width: 960, height: 800, minWidth: 640, minHeight: 560 },
+ friends: { title: 'AeroMC – Freunde', width: 420, height: 560, minWidth: 360, minHeight: 420 },
};
const POPOUT_DEFAULT = { title: 'AeroMC', width: 860, height: 680, minWidth: 640, minHeight: 480 };
let pendingUpdateInfo = null; // letztes Ergebnis von update:check/checkForUpdate, für Update-Popup
@@ -204,6 +211,10 @@ const VERBINDUNGS_ZEILE = /Connecting to ([^,]+),\s*(\d+)/i;
ipcMain.handle('recording:getServer', (_e, id) => letzteQuickPlayServer.get(id) || null);
const laufendePid = new Map(); // instanceId -> pid des zuletzt gestarteten Minecraft-Prozesses
+const kontoPid = new Map(); // kontoId -> pid, für den Ingame-Toast: der muss auf DEM Fenster
+// des prüfenden Kontos erscheinen, nicht auf irgendeinem laufenden Minecraft-Fenster
+// (sonst sieht z. B. M_Viper eine Meldung über sich selbst, weil zufällig sein
+// Fenster zuerst gefunden wurde, obwohl Electric__Max der Freund war, der geprüft hat)
ipcMain.handle('recording:getPid', (_e, id) => laufendePid.get(id) || null);
// Welche Bildschirmquelle als Nächstes über getDisplayMedia erfasst werden
@@ -329,19 +340,61 @@ function aufzeichnungsOrdner(instanceName) {
String(instanceName || 'Instanz').replace(/[^\w.-]+/g, '_'));
}
-ipcMain.handle('recording:save', async (_e, instanceName, buffer, serverLabel) => {
+// Aufnahme kommt vom MediaRecorder als WebM - für Abspielbarkeit in
+// praktisch jedem Player (Windows Media Player, Smart-TVs, Handys usw.) wird
+// direkt nach dem Speichern per ffmpeg in eine MP4-Datei (H.264 + AAC)
+// umgewandelt, das temporäre WebM danach gelöscht.
+//
+// -r erzwingt eine SAUBERE, konstante Bildrate im Ausgabevideo: die
+// Aufnahme kommt aus einer Canvas-Zeichenschleife (für Zuschnitt/Skalierung,
+// siehe erzeugeSkaliertenStream im Renderer) und deren Bild-Zeitstempel sind
+// nicht perfekt gleichmäßig - ohne -r übernimmt ffmpeg diese unregelmäßigen
+// Zeitstempel unverändert, was zu einer falschen/kaputten Bildrate im
+// Ausgabevideo führt (z. B. fälschlich "1000fps" laut ffprobe) und manche
+// Player die Datei gar nicht erst abspielen lässt.
+function wandleInMp4Um(quellDatei, zielDatei, fps) {
+ return new Promise((resolve, reject) => {
+ execFile(ffmpegPfad, [
+ '-y', '-i', quellDatei,
+ '-r', String(fps || 30), '-vsync', 'cfr',
+ '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-pix_fmt', 'yuv420p',
+ '-c:a', 'aac', '-b:a', '192k',
+ zielDatei,
+ ], (err, _stdout, stderr) => {
+ if (err) reject(new Error(stderr ? String(stderr).split('\n').slice(-5).join(' ') : err.message));
+ else resolve();
+ });
+ });
+}
+
+ipcMain.handle('recording:save', async (_e, instanceName, buffer, serverLabel, fps) => {
+ let tempDatei = null;
try {
const zielOrdner = aufzeichnungsOrdner(instanceName);
fs.mkdirSync(zielOrdner, { recursive: true });
const zeitstempel = new Date().toISOString().replace(/[:T]/g, '-').slice(0, 16);
const serverTeil = serverLabel ? String(serverLabel).replace(/[^\w.-]+/g, '_') + '_' : '';
- let zielDatei = path.join(zielOrdner, `${serverTeil}${zeitstempel}.webm`);
+ let zielDatei = path.join(zielOrdner, `${serverTeil}${zeitstempel}.mp4`);
let n = 2;
- while (fs.existsSync(zielDatei)) { zielDatei = path.join(zielOrdner, `${serverTeil}${zeitstempel}_${n}.webm`); n++; }
- fs.writeFileSync(zielDatei, Buffer.from(buffer));
+ while (fs.existsSync(zielDatei)) { zielDatei = path.join(zielOrdner, `${serverTeil}${zeitstempel}_${n}.mp4`); n++; }
+
+ tempDatei = zielDatei.slice(0, -4) + '.webm.tmp';
+ fs.writeFileSync(tempDatei, Buffer.from(buffer));
+
+ if (ffmpegPfad) {
+ await wandleInMp4Um(tempDatei, zielDatei, fps);
+ fs.rmSync(tempDatei, { force: true });
+ } else {
+ // ffmpeg fehlt (sollte nur in einer kaputten Installation vorkommen) -
+ // wenigstens das unveränderte WebM behalten statt die Aufnahme zu verlieren
+ zielDatei = zielDatei.slice(0, -4) + '.webm';
+ fs.renameSync(tempDatei, zielDatei);
+ }
+
notify('Aufzeichnung gespeichert', `${instanceName}: ${path.basename(zielDatei)}`);
return { ok: true, path: zielDatei };
} catch (err) {
+ if (tempDatei) fs.rmSync(tempDatei, { force: true });
return { ok: false, message: 'Speichern fehlgeschlagen: ' + err.message };
}
});
@@ -353,6 +406,57 @@ ipcMain.handle('recording:openFolder', (_e, instanceName) => {
return true;
});
+// ---------- Aufzeichnungen-Galerie ----------
+
+ipcMain.handle('recording:list', (_e, instanceName) => {
+ const ordner = aufzeichnungsOrdner(instanceName);
+ try {
+ return fs.readdirSync(ordner)
+ .filter((name) => name.toLowerCase().endsWith('.mp4'))
+ .map((name) => {
+ const stat = fs.statSync(path.join(ordner, name));
+ return { name, size: stat.size, mtimeMs: stat.mtimeMs };
+ })
+ .sort((a, b) => b.mtimeMs - a.mtimeMs);
+ } catch { return []; }
+});
+
+ipcMain.handle('recording:thumbnail', async (_e, instanceName, dateiname) => {
+ if (!ffmpegPfad) return null;
+ const ordner = aufzeichnungsOrdner(instanceName);
+ const quelle = path.join(ordner, dateiname);
+ if (!fs.existsSync(quelle)) return null;
+ const cacheOrdner = path.join(ordner, '.thumbnails');
+ const cacheDatei = path.join(cacheOrdner, dateiname + '.jpg');
+ if (!fs.existsSync(cacheDatei)) {
+ fs.mkdirSync(cacheOrdner, { recursive: true });
+ await new Promise((resolve) => {
+ // Erster Versuch bei 1s (überspringt oft ein schwarzes/leeres Startbild),
+ // Fehler wird ignoriert - dann bleibt einfach kein Vorschaubild
+ execFile(ffmpegPfad, ['-y', '-ss', '00:00:01', '-i', quelle, '-frames:v', '1', '-vf', 'scale=240:-1', cacheDatei], () => resolve());
+ });
+ }
+ try {
+ if (!fs.existsSync(cacheDatei)) return null;
+ return 'data:image/jpeg;base64,' + fs.readFileSync(cacheDatei).toString('base64');
+ } catch { return null; }
+});
+
+ipcMain.handle('recording:delete', (_e, instanceName, dateiname) => {
+ const ordner = aufzeichnungsOrdner(instanceName);
+ try {
+ fs.rmSync(path.join(ordner, dateiname), { force: true });
+ fs.rmSync(path.join(ordner, '.thumbnails', dateiname + '.jpg'), { force: true });
+ return true;
+ } catch { return false; }
+});
+
+ipcMain.handle('recording:open', (_e, instanceName, dateiname) => {
+ const ziel = path.join(aufzeichnungsOrdner(instanceName), dateiname);
+ if (fs.existsSync(ziel)) shell.openPath(ziel);
+ return true;
+});
+
// ---------------------------------------------------------------------------
// Verknüpfungen: "AeroMC.exe --launch " startet eine Instanz direkt
// ---------------------------------------------------------------------------
@@ -609,6 +713,16 @@ app.whenReady().then(() => {
setTimeout(() => { backupNachZeitplan('zeitplan').catch(() => {}); }, 60000);
setInterval(() => { backupNachZeitplan('zeitplan').catch(() => {}); }, 3600000);
+ // Freundesystem: alle 60s im Hintergrund prüfen, ob ein Freund neu online
+ // gegangen ist -> Windows-Meldung. Der erste Durchlauf legt nur die
+ // Vergleichsbasis an (noch keine Meldung, sonst würde jeder schon online
+ // befindliche Freund beim Start fälschlich als "gerade online gegangen" gelten).
+ setTimeout(() => { pruefeFreundeOnline().catch(() => {}); }, 15000);
+ setInterval(() => { pruefeFreundeOnline().catch(() => {}); }, 60000);
+ // Zahl offener Freundschaftsanfragen für den "Freunde"-Knopf
+ setTimeout(() => { aktualisiereAnfragenZahl().catch(() => {}); }, 3000);
+ setInterval(() => { aktualisiereAnfragenZahl().catch(() => {}); }, 60000);
+
// automatisch auf Updates prüfen (kurz nach dem Start)
setTimeout(async () => {
try {
@@ -646,6 +760,7 @@ app.on('window-all-closed', () => {
});
app.on('before-quit', () => { appIsQuitting = true; discordpresence.shutdown(); });
+app.on('will-quit', () => { globalShortcut.unregisterAll(); });
// ---------------------------------------------------------------------------
// Java-Erkennung (für Anzeige in den Einstellungen)
@@ -674,6 +789,10 @@ ipcMain.handle('settings:save', (_e, patch) => {
applyProxy(s);
if (patch && Object.prototype.hasOwnProperty.call(patch, 'autostart')) applyAutostart(s.autostart);
if (patch && Object.prototype.hasOwnProperty.call(patch, 'discordClientId')) discordpresence.configure(s);
+ if (patch && Object.prototype.hasOwnProperty.call(patch, 'friendsEnabled')) {
+ aktualisiereFreundeTastenkuerzel();
+ aktualisiereAnfragenZahl().catch(() => {});
+ }
// Einstellungen können jetzt auch aus dem Popup-Fenster gespeichert werden –
// das Hauptfenster muss Theme/Sortierung/Bedrock-Sichtbarkeit/Skin-Hintergrund
// dann selbst nachziehen. NICHT bei der beiläufigen "letzte Instanz"-Notiz
@@ -1136,7 +1255,18 @@ ipcMain.handle('auth:recheck', () => checkAuthServices());
// "playing" kommt aus den tatsächlich laufenden Spielen, nicht aus dem Aktiv-Merkmal
ipcMain.handle('auth:accounts', () => auth.listAccounts()
.map((a) => Object.assign({}, a, { playing: spielendeKonten.has(a.id) })));
-ipcMain.handle('auth:setActive', (_e, id) => auth.setActive(id));
+ipcMain.handle('auth:setActive', (_e, id) => {
+ const res = auth.setActive(id);
+ // Ohne das hier bleibt z. B. ein separates Konten- oder Aufzeichnungs-
+ // Fenster (eigener Renderer-Prozess, siehe cross-window state) auf dem
+ // alten Konto stehen, bis irgendein anderes Ereignis zufällig ein Neuladen
+ // auslöst - der Listener auf 'accounts:changed' existiert im Renderer
+ // schon, wurde von hier nur nie ausgelöst.
+ broadcast('accounts:changed');
+ // Anfragen-Zahl am "Freunde"-Knopf gilt für das aktive Konto
+ aktualisiereAnfragenZahl().catch(() => {});
+ return res;
+});
// ---- Skin des aktiven Kontos ----
@@ -1197,6 +1327,294 @@ ipcMain.handle('skin:reset', async () => {
ipcMain.handle('auth:remove', (_e, id) => auth.removeAccount(id));
ipcMain.handle('auth:logout', () => { auth.clearAccount(); return true; });
+// ---------- Freundesystem ----------
+// Nutzt immer das aktive Konto (gleiche Quelle wie discordpresence/die
+// "spielt gerade"-Anzeige: auth.currentAccount().profile.{id,name}) - keine
+// eigene Anmeldung nötig.
+
+// Findet DAS Minecraft-Fenster des angegebenen Prozesses (pid) - wichtig,
+// damit der Ingame-Toast auf dem Bildschirm des prüfenden Kontos erscheint,
+// nicht zufällig auf dem eines anderen laufenden Kontos (bei zwei Konten
+// über denselben Launcher gäbe es sonst z. B. eine Meldung über M_Viper
+// selbst, angezeigt auf M_Vipers eigenem Fenster).
+//
+// node-window-manager erkennt ein gerade laufendes Fenster manchmal nicht
+// beim allerersten Abfragen (z. B. Spiel lädt noch, kurz minimiert) - deshalb
+// mehrere Versuche mit kurzer Pause. Wird das Fenster DIESER PID trotzdem
+// nicht gefunden, wird bewusst auf kein anderes Fenster ausgewichen - lieber
+// keine Meldung als eine beim falschen Spieler.
+async function findeMinecraftFenster(pid) {
+ if (!pid) return null;
+ let windowManager;
+ try { ({ windowManager } = require('node-window-manager')); } catch { return null; }
+ for (let versuch = 1; versuch <= 4; versuch++) {
+ let alleFenster;
+ try { alleFenster = windowManager.getWindows(); } catch { alleFenster = []; }
+ const treffer = alleFenster.find((w) => w.processId === pid);
+ if (treffer) return treffer;
+ if (versuch < 4) await new Promise((r) => setTimeout(r, 300));
+ }
+ return null;
+}
+
+// pid -> Anzahl gerade sichtbarer Toasts auf diesem Fenster (zum Stapeln,
+// falls mehrere Freunde im selben Durchlauf gemeldet werden)
+const toastsProFenster = new Map();
+
+// 3D-Spielerkopf (isometrischer Würfel, gleiche Quelle wie im Freunde-Dialog)
+// als data:-URI - vorab im Hauptprozess geladen und eingebettet, damit der
+// Kopf schon beim Erscheinen des Toasts da ist statt nachzuladen. Pro uuid
+// zwischengespeichert; bei Fehler/Zeitüberschreitung einfach ohne Kopf.
+const spielerKopfCache = new Map();
+async function spielerKopf(uuid) {
+ if (!uuid) return null;
+ if (spielerKopfCache.has(uuid)) return spielerKopfCache.get(uuid);
+ const abbruch = new AbortController();
+ const zeitgeber = setTimeout(() => abbruch.abort(), 3000);
+ try {
+ const res = await fetch(`https://crafthead.net/cube/${encodeURIComponent(uuid)}/64`, { signal: abbruch.signal });
+ if (!res.ok) return null;
+ const uri = 'data:image/png;base64,' + Buffer.from(await res.arrayBuffer()).toString('base64');
+ spielerKopfCache.set(uuid, uri);
+ return uri;
+ } catch {
+ return null;
+ } finally {
+ clearTimeout(zeitgeber);
+ }
+}
+
+// Kleiner, durchsichtiger Toast oben rechts auf dem Minecraft-Fenster (wie
+// Discords Ingame-Overlay), bewusst OHNE Windows-Meldung als Rückfall -
+// stiehlt nie den Fokus (showInactive + focusable:false), damit das Spiel
+// nicht gestört wird. Jeder Toast ist ein eigenes Fenster, damit zwei
+// gleichzeitig laufende Konten jeweils ihre eigene Meldung bekommen.
+// Liefert true, wenn der Toast angezeigt wurde. `text` ist eine Zeile oder
+// eine Liste von Zeilen; eine Zeile ist entweder Text oder { uuid, text } -
+// dann steht der 3D-Spielerkopf davor.
+async function zeigeIngameOverlay(titel, text, pid, dauerMs = 6000) {
+ const fenster = await findeMinecraftFenster(pid);
+ if (!fenster) return false;
+ const bounds = fenster.getBounds();
+
+ const zeilen = await Promise.all((Array.isArray(text) ? text : [text]).map(async (z) => {
+ if (typeof z === 'string') return { text: z, kopf: null };
+ return { text: z.text, kopf: await spielerKopf(z.uuid) };
+ }));
+ const breite = 320;
+ const hoehe = 44 + zeilen.reduce((summe, z) => summe + (z.kopf ? 24 : 18), 0);
+ const stapel = toastsProFenster.get(pid) || 0;
+ toastsProFenster.set(pid, stapel + 1);
+ const overlayFenster = new BrowserWindow({
+ x: Math.round(bounds.x + bounds.width - breite - 24),
+ y: Math.round(bounds.y + 24 + stapel * (hoehe + 8)),
+ width: breite,
+ height: hoehe,
+ frame: false,
+ transparent: true,
+ alwaysOnTop: true,
+ skipTaskbar: true,
+ focusable: false,
+ resizable: false,
+ hasShadow: false,
+ show: false,
+ webPreferences: { contextIsolation: true, sandbox: true },
+ });
+ overlayFenster.setIgnoreMouseEvents(true);
+ overlayFenster.setAlwaysOnTop(true, 'screen-saver');
+
+ const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+ // charset=utf-8 nicht vergessen: ohne das interpretiert Chromium die
+ // data:-URL mit einer anderen Kodierung, wodurch z. B. "·" als "·"
+ // dargestellt wird (klassisches UTF-8/Latin-1-Mojibake)
+ const html = `data:text/html;charset=utf-8,${encodeURIComponent(`
+
+
${esc(titel)}
+ ${zeilen.map((z) => `
+ ${z.kopf ? `` : ''}
+ ${esc(z.text)}
+
`).join('')}
+
+ `)}`;
+ overlayFenster.loadURL(html);
+ overlayFenster.once('ready-to-show', () => { if (!overlayFenster.isDestroyed()) overlayFenster.showInactive(); });
+ setTimeout(() => {
+ if (!overlayFenster.isDestroyed()) overlayFenster.destroy();
+ toastsProFenster.set(pid, Math.max(0, (toastsProFenster.get(pid) || 1) - 1));
+ }, dauerMs);
+ return true;
+}
+
+// ---------- Tastenkürzel: Online-Freunde ingame anzeigen ----------
+// Nur registriert, solange über diesen Launcher ein Spiel läuft - ein globales
+// Tastenkürzel fängt die Kombination systemweit ab, das soll andere Programme
+// außerhalb des Spielens nicht stören.
+const FREUNDE_TASTENKUERZEL = 'Control+Alt+F';
+let freundeTastenkuerzelAktiv = false;
+
+function aktualisiereFreundeTastenkuerzel() {
+ const soll = kontoPid.size > 0 && store.getSettings().friendsEnabled !== false;
+ if (soll && !freundeTastenkuerzelAktiv) {
+ freundeTastenkuerzelAktiv = globalShortcut.register(FREUNDE_TASTENKUERZEL, () => {
+ zeigeOnlineFreundeIngame().catch(() => {});
+ });
+ } else if (!soll && freundeTastenkuerzelAktiv) {
+ globalShortcut.unregister(FREUNDE_TASTENKUERZEL);
+ freundeTastenkuerzelAktiv = false;
+ }
+}
+
+// Zeigt die gerade online befindlichen Freunde des Kontos, dessen
+// Minecraft-Fenster gerade im Vordergrund ist, als Toast auf genau diesem
+// Fenster. Läuft nur ein Spiel, wird dieses genommen, auch wenn es nicht
+// im Vordergrund ist.
+async function zeigeOnlineFreundeIngame() {
+ let aktivePid = null;
+ try { aktivePid = require('node-window-manager').windowManager.getActiveWindow().processId; } catch { /* egal */ }
+ let eintrag = [...kontoPid].find(([, pid]) => pid === aktivePid);
+ if (!eintrag && kontoPid.size === 1) eintrag = [...kontoPid][0];
+ if (!eintrag) return;
+ const [kontoUuid, pid] = eintrag;
+
+ const res = await friends.getFriends(kontoUuid);
+ if (!res.ok) { await zeigeIngameOverlay('Freunde', 'Freundesystem nicht erreichbar', pid, 4000); return; }
+ const spitznamen = store.getSettings().friendNicknames || {};
+ const online = (res.data || []).filter((f) => f.online);
+ const MAX_ZEILEN = 10;
+ const zeilen = online.slice(0, MAX_ZEILEN).map((f) => ({
+ uuid: f.uuid,
+ text: (spitznamen[f.uuid] || f.username) + (f.server ? ` · ${f.server}` : ''),
+ }));
+ if (online.length > MAX_ZEILEN) zeilen.push(`… und ${online.length - MAX_ZEILEN} weitere`);
+ await zeigeIngameOverlay(
+ online.length ? `Freunde online (${online.length})` : 'Freunde online',
+ zeilen.length ? zeilen : ['Gerade ist niemand online'],
+ pid,
+ 8000,
+ );
+}
+
+// ---------- Offene Freundschaftsanfragen (Zahl am "Freunde"-Knopf) ----------
+let letzteAnfragenZahl = 0;
+async function aktualisiereAnfragenZahl() {
+ let zahl = 0;
+ const acc = auth.currentAccount();
+ if (store.getSettings().friendsEnabled !== false && acc && acc.profile) {
+ const res = await friends.getRequests(acc.profile.id);
+ if (!res.ok) return; // nicht erreichbar -> letzten bekannten Stand behalten
+ zahl = (res.data || []).length;
+ }
+ letzteAnfragenZahl = zahl;
+ broadcast('friends:requestCount', zahl);
+}
+ipcMain.handle('friends:requestCount', () => letzteAnfragenZahl);
+
+// eigene uuid -> (freund-uuid -> war beim letzten Durchlauf online). Pro
+// Konto getrennt, damit zwei gleichzeitig spielende Konten sich die
+// Vergleichsbasis nicht gegenseitig überschreiben.
+const freundeOnlineBaseline = new Map();
+
+// Prüft für JEDES gerade über diesen Launcher spielende Konto (kontoPid) dessen
+// eigene Freundesliste und zeigt die Meldung auf DESSEN Fenster - nicht nur für
+// das gerade "aktive" Konto, sonst bekäme bei zwei laufenden Konten immer nur
+// eines die Meldungen. Ohne laufendes Spiel gibt es kein Fenster für den Toast,
+// daher werden nur spielende Konten geprüft.
+async function pruefeFreundeOnline() {
+ if (store.getSettings().friendsEnabled === false) return;
+ for (const [kontoUuid, pid] of kontoPid) {
+ const res = await friends.getFriends(kontoUuid);
+ if (!res.ok) continue;
+ const bisherige = freundeOnlineBaseline.get(kontoUuid);
+ const neu = new Map();
+ for (const f of res.data || []) {
+ let bekanntOnline = f.online;
+ // Meldet auch beim allerersten Check, wenn ein Freund schon online ist -
+ // nur ausgelassen, wenn er beim vorherigen Check schon als online
+ // gemeldet war (sonst käme jede Minute dieselbe Meldung)
+ if (f.online && (!bisherige || bisherige.get(f.uuid) !== true)) {
+ const gezeigt = await zeigeIngameOverlay(
+ 'Freund online',
+ { uuid: f.uuid, text: `${f.username} ist jetzt online` },
+ pid,
+ );
+ // Fenster (noch) nicht gefunden, z. B. weil das Spiel noch lädt ->
+ // nicht als "schon gemeldet" merken, nächster Durchlauf versucht es erneut
+ if (!gezeigt) bekanntOnline = false;
+ }
+ neu.set(f.uuid, bekanntOnline);
+ }
+ freundeOnlineBaseline.set(kontoUuid, neu);
+ }
+}
+
+function aktivesFreundeKonto() {
+ const acc = auth.currentAccount();
+ return acc && acc.profile ? { uuid: acc.profile.id, username: acc.profile.name } : null;
+}
+
+ipcMain.handle('friends:enabled', () => store.getSettings().friendsEnabled !== false);
+
+ipcMain.handle('friends:list', async () => {
+ const konto = aktivesFreundeKonto();
+ if (!konto) return { ok: false, message: 'Kein Konto angemeldet.' };
+ const res = await friends.getFriends(konto.uuid);
+ return res.ok ? { ok: true, list: res.data || [] } : res;
+});
+
+ipcMain.handle('friends:requests', async () => {
+ const konto = aktivesFreundeKonto();
+ if (!konto) return { ok: false, message: 'Kein Konto angemeldet.' };
+ const res = await friends.getRequests(konto.uuid);
+ return res.ok ? { ok: true, list: res.data || [] } : res;
+});
+
+ipcMain.handle('friends:add', async (_e, targetUsername) => {
+ const konto = aktivesFreundeKonto();
+ if (!konto) return { ok: false, message: 'Kein Konto angemeldet.' };
+ const res = await friends.sendRequest(konto.uuid, konto.username, targetUsername);
+ return res.ok ? { ok: true } : res;
+});
+
+ipcMain.handle('friends:sentRequests', async () => {
+ const konto = aktivesFreundeKonto();
+ if (!konto) return { ok: false, message: 'Kein Konto angemeldet.' };
+ const res = await friends.getSentRequests(konto.uuid);
+ return res.ok ? { ok: true, list: res.data || [] } : res;
+});
+
+ipcMain.handle('friends:cancel', async (_e, otherUuid) => {
+ const konto = aktivesFreundeKonto();
+ if (!konto) return { ok: false, message: 'Kein Konto angemeldet.' };
+ const res = await friends.cancelRequest(konto.uuid, otherUuid);
+ return res.ok ? { ok: true } : res;
+});
+
+ipcMain.handle('friends:respond', async (_e, otherUuid, accept) => {
+ const konto = aktivesFreundeKonto();
+ if (!konto) return { ok: false, message: 'Kein Konto angemeldet.' };
+ const res = await friends.respond(konto.uuid, otherUuid, accept);
+ aktualisiereAnfragenZahl().catch(() => {});
+ return res.ok ? { ok: true } : res;
+});
+
+ipcMain.handle('friends:remove', async (_e, otherUuid) => {
+ const konto = aktivesFreundeKonto();
+ if (!konto) return { ok: false, message: 'Kein Konto angemeldet.' };
+ const res = await friends.removeFriend(konto.uuid, otherUuid);
+ return res.ok ? { ok: true } : res;
+});
+
+// Spitznamen sind rein lokal (nur meine eigene Ansicht) - kein Grund, dafür
+// die WordPress-Datenbank zu bemühen, landen einfach in settings.json
+ipcMain.handle('friends:setNickname', (_e, uuid, nickname) => {
+ const aktuelle = store.getSettings().friendNicknames || {};
+ const neu = Object.assign({}, aktuelle);
+ if (nickname) neu[uuid] = nickname; else delete neu[uuid];
+ store.saveSettings({ friendNicknames: neu });
+ return true;
+});
+
ipcMain.handle('auth:login', async () => {
const clientId = store.getSettings().azureClientId || auth.DEFAULT_CLIENT_ID;
try {
@@ -2225,6 +2643,7 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
// gewechselt worden sein, während dieses Spiel noch läuft.
const kontoId = account && account.profile ? account.profile.id : null;
let gezaehlt = false;
+ let freundeHerzschlag = null;
try {
let launchStart = 0;
@@ -2237,11 +2656,31 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
state: `${inst.name} · ${inst.minecraft.version}`,
startTimestamp: launchStart,
});
+ // Freundesystem: eigene Online-Meldung, nur wenn nicht in den
+ // Einstellungen ausgeschaltet (siehe friendsEnabled) - sonst bleibt
+ // man für niemanden auffindbar/sichtbar
+ if (account && account.profile && store.getSettings().friendsEnabled !== false) {
+ const sendeHerzschlag = () => {
+ friends.heartbeat({
+ uuid: account.profile.id,
+ username: account.profile.name,
+ instance: inst.name,
+ server: letzteQuickPlayServer.get(id) || '',
+ }).catch(() => {});
+ };
+ sendeHerzschlag();
+ freundeHerzschlag = setInterval(sendeHerzschlag, 60000);
+ }
}
if (evt.phase === 'exit') {
if (gezaehlt) { gezaehlt = false; kontoSpieltNichtMehr(kontoId); }
discordpresence.clearActivity();
+ if (freundeHerzschlag) { clearInterval(freundeHerzschlag); freundeHerzschlag = null; }
laufendePid.delete(id); // für die "Nur Minecraft"-Tonaufnahme: Prozess ist weg
+ // beim nächsten Start dieses Kontos sollen bereits online befindliche
+ // Freunde wieder gemeldet werden
+ if (kontoId) { kontoPid.delete(kontoId); freundeOnlineBaseline.delete(kontoId); }
+ aktualisiereFreundeTastenkuerzel();
if (launchStart) {
const secs = Math.max(0, Math.round((Date.now() - launchStart) / 1000));
launchStart = 0; // Spielzeit nur einmal gutschreiben
@@ -2264,11 +2703,14 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
// Start fehlgeschlagen, obwohl schon gezählt -> Zählung zurücknehmen,
// sonst bliebe das Konto für immer als "spielt" stehen
if (!res.ok && gezaehlt) { gezaehlt = false; kontoSpieltNichtMehr(kontoId); discordpresence.clearActivity(); }
+ if (!res.ok && freundeHerzschlag) { clearInterval(freundeHerzschlag); freundeHerzschlag = null; }
if (res.ok) store.updateInstance(id, { lastPlayed: new Date().toISOString() });
if (res.ok && res.pid) laufendePid.set(id, res.pid);
+ if (res.ok && res.pid && kontoId) { kontoPid.set(kontoId, res.pid); aktualisiereFreundeTastenkuerzel(); }
return res;
} catch (err) {
if (gezaehlt) { gezaehlt = false; kontoSpieltNichtMehr(kontoId); discordpresence.clearActivity(); }
+ if (freundeHerzschlag) { clearInterval(freundeHerzschlag); freundeHerzschlag = null; }
return { ok: false, message: 'Start fehlgeschlagen: ' + err.message };
}
});
diff --git a/src/preload.js b/src/preload.js
index 5b65b2f..8ce0d58 100644
--- a/src/preload.js
+++ b/src/preload.js
@@ -279,8 +279,29 @@ contextBridge.exposeInMainWorld('api', {
// Spielaufzeichnung
recordingListWindows: () => ipcRenderer.invoke('recording:listWindows'),
recordingGetAudioSourceId: () => ipcRenderer.invoke('recording:getAudioSourceId'),
- recordingSave: (instanceName, buffer, serverLabel) => ipcRenderer.invoke('recording:save', instanceName, buffer, serverLabel),
+ recordingSave: (instanceName, buffer, serverLabel, fps) => ipcRenderer.invoke('recording:save', instanceName, buffer, serverLabel, fps),
recordingOpenFolder: (instanceName) => ipcRenderer.invoke('recording:openFolder', instanceName),
+ recordingList: (instanceName) => ipcRenderer.invoke('recording:list', instanceName),
+ recordingThumbnail: (instanceName, dateiname) => ipcRenderer.invoke('recording:thumbnail', instanceName, dateiname),
+ recordingDelete: (instanceName, dateiname) => ipcRenderer.invoke('recording:delete', instanceName, dateiname),
+ recordingOpen: (instanceName, dateiname) => ipcRenderer.invoke('recording:open', instanceName, dateiname),
+
+ // Freundesystem
+ friendsEnabled: () => ipcRenderer.invoke('friends:enabled'),
+ friendsList: () => ipcRenderer.invoke('friends:list'),
+ friendsRequests: () => ipcRenderer.invoke('friends:requests'),
+ friendsAdd: (targetUsername) => ipcRenderer.invoke('friends:add', targetUsername),
+ friendsRespond: (otherUuid, accept) => ipcRenderer.invoke('friends:respond', otherUuid, accept),
+ friendsRemove: (otherUuid) => ipcRenderer.invoke('friends:remove', otherUuid),
+ friendsSentRequests: () => ipcRenderer.invoke('friends:sentRequests'),
+ friendsCancel: (otherUuid) => ipcRenderer.invoke('friends:cancel', otherUuid),
+ friendsSetNickname: (uuid, nickname) => ipcRenderer.invoke('friends:setNickname', uuid, nickname),
+ friendsRequestCount: () => ipcRenderer.invoke('friends:requestCount'),
+ onFriendsRequestCount: (cb) => {
+ const l = (_e, zahl) => cb(zahl);
+ ipcRenderer.on('friends:requestCount', l);
+ return () => ipcRenderer.removeListener('friends:requestCount', l);
+ },
recordingGetServer: (instanceId) => ipcRenderer.invoke('recording:getServer', instanceId),
onRecordingServerDetected: (cb) => {
const l = (_e, payload) => cb(payload);
diff --git a/src/renderer.js b/src/renderer.js
index 00456d0..3e4d5b3 100644
--- a/src/renderer.js
+++ b/src/renderer.js
@@ -1507,6 +1507,7 @@ const ICONS_SVG = {
folder: '',
terminal: '',
user: '',
+ users: '',
settings: '',
play: '',
blocks: '',
@@ -2974,26 +2975,43 @@ function erzeugeSkaliertenStream(quellStream, zielBreite, zielHoehe, fps) {
// unsichtbare Rahmen für die Größenänderung per Maus) - sonst wäre am Rand
// ein dünner Streifen Desktop/Nachbarfenster im Zuschnitt zu sehen.
const UNSICHTBARER_RAND = 8;
- const zeichnen = () => {
- if (video.readyState >= 2) {
- if (mcFensterBounds && mcBildschirmBounds && video.videoWidth && mcBildschirmBounds.width) {
- const skalX = video.videoWidth / mcBildschirmBounds.width;
- const skalY = video.videoHeight / mcBildschirmBounds.height;
- const randX = UNSICHTBARER_RAND * skalX;
- const randY = UNSICHTBARER_RAND * skalY;
- const sx = (mcFensterBounds.x - mcBildschirmBounds.x) * skalX + randX;
- const sy = (mcFensterBounds.y - mcBildschirmBounds.y) * skalY + randY;
- const sw = Math.max(1, mcFensterBounds.width * skalX - randX * 2);
- const sh = Math.max(1, mcFensterBounds.height * skalY - randY * 2);
- ctx.drawImage(video, sx, sy, sw, sh, 0, 0, zielBreite, zielHoehe);
- } else {
- ctx.drawImage(video, 0, 0, zielBreite, zielHoehe); // Übergang, bis Fenstergrenzen bekannt sind
+
+ // captureStream(fps) allein reicht nicht: requestAnimationFrame läuft mit
+ // der Bildwiederholrate des Monitors (z. B. 144Hz), nicht mit der
+ // gewünschten Aufnahme-Bildrate - ungebremst wurden dadurch weit mehr
+ // Bilder gezeichnet/geliefert als angefragt (führte zu einer kaputten
+ // Zeitachse im aufgenommenen Video und sichtbarem Ruckeln). Deshalb
+ // captureStream(0) (kein automatisches Intervall) und Bilder nur exakt im
+ // Takt der Ziel-Bildrate selbst per requestFrame() ausliefern.
+ const canvasStream = canvas.captureStream(0);
+ const spurAusgabe = canvasStream.getVideoTracks()[0];
+ const intervallMs = 1000 / fps;
+ let letzteZeichnung = 0;
+
+ const zeichnen = (jetzt) => {
+ if (!letzteZeichnung || jetzt - letzteZeichnung >= intervallMs) {
+ letzteZeichnung = jetzt;
+ if (video.readyState >= 2) {
+ if (mcFensterBounds && mcBildschirmBounds && video.videoWidth && mcBildschirmBounds.width) {
+ const skalX = video.videoWidth / mcBildschirmBounds.width;
+ const skalY = video.videoHeight / mcBildschirmBounds.height;
+ const randX = UNSICHTBARER_RAND * skalX;
+ const randY = UNSICHTBARER_RAND * skalY;
+ const sx = (mcFensterBounds.x - mcBildschirmBounds.x) * skalX + randX;
+ const sy = (mcFensterBounds.y - mcBildschirmBounds.y) * skalY + randY;
+ const sw = Math.max(1, mcFensterBounds.width * skalX - randX * 2);
+ const sh = Math.max(1, mcFensterBounds.height * skalY - randY * 2);
+ ctx.drawImage(video, sx, sy, sw, sh, 0, 0, zielBreite, zielHoehe);
+ } else {
+ ctx.drawImage(video, 0, 0, zielBreite, zielHoehe); // Übergang, bis Fenstergrenzen bekannt sind
+ }
+ spurAusgabe.requestFrame();
}
}
mcCanvasRAF = requestAnimationFrame(zeichnen);
};
- zeichnen();
- return canvas.captureStream(fps);
+ mcCanvasRAF = requestAnimationFrame(zeichnen);
+ return canvasStream;
}
async function erfasseFensterVideoMitCursor(source, grenzen) {
@@ -3039,9 +3057,8 @@ async function wendeVideoEinstellungenAn() {
function updateVideoSummary() {
const groesse = mcActualSize ? ` (${mcActualSize.width}×${mcActualSize.height})` : '';
- const cursorHinweis = mcVideoStream ? (mcCursorAusgeblendet ? ' · ohne Mauszeiger' : ' · mit Mauszeiger') : '';
el('record-video-summary').textContent =
- RESOLUTION_LABEL[mcResolution] + groesse + ' · ' + QUALITAET_LABEL[mcQuality] + ' · ' + mcFramerate + ' fps' + cursorHinweis;
+ RESOLUTION_LABEL[mcResolution] + groesse + ' · ' + QUALITAET_LABEL[mcQuality] + ' · ' + mcFramerate + ' fps';
const aktiv = recordingActive();
el('record-resolution').disabled = aktiv;
el('record-quality').disabled = aktiv;
@@ -3290,7 +3307,6 @@ async function onMcRecordingStopped() {
clearInterval(mcTimerInterval);
window.api.recordingSetGuard(false);
el('record-live-badge').classList.add('hidden');
- el('record-toggle').textContent = 'Aufnahme starten';
el('record-timer').textContent = '00:00';
updateVideoSummary();
cleanupAudioMix();
@@ -3301,7 +3317,16 @@ async function onMcRecordingStopped() {
const buffer = await blob.arrayBuffer();
const inst = await window.api.getInstance(currentDetailId);
mcLastSavedInstanceName = inst ? inst.name : 'Instanz';
- const res = await window.api.recordingSave(mcLastSavedInstanceName, buffer, await aktuellerAufnahmeServer());
+
+ // Die Umwandlung nach MP4 (für Abspielbarkeit in jedem Player) läuft nicht
+ // sofort durch - kurz sichtbar machen, damit der Knopf währenddessen nicht
+ // wie eingefroren wirkt
+ const knopf = el('record-toggle');
+ knopf.disabled = true;
+ knopf.textContent = 'Wird verarbeitet…';
+ const res = await window.api.recordingSave(mcLastSavedInstanceName, buffer, await aktuellerAufnahmeServer(), mcFramerate);
+ knopf.disabled = false;
+ knopf.textContent = 'Aufnahme starten';
if (res.ok) {
toast('Aufzeichnung gespeichert.');
} else {
@@ -3338,6 +3363,8 @@ async function openScreens() {
grid.innerHTML = '
Lade …
';
screenSelected = new Set();
updateScreensSelectionUi();
+ recordsGeladen = false;
+ switchScreensTab('photos');
show('modal-screens');
const shots = await window.api.screenshots(currentDetailId);
@@ -3402,6 +3429,251 @@ async function deleteSelectedScreenshots() {
});
}
+// ---------- Aufzeichnungen-Galerie (im selben Dialog, zweiter Tab) ----------
+let screensAktiverTab = 'photos';
+let recordSelected = new Set();
+let recordsGeladen = false; // nur beim ersten Wechsel auf den Tab laden, nicht bei jedem Öffnen
+
+function switchScreensTab(which) {
+ screensAktiverTab = which;
+ const photos = which === 'photos';
+ el('screens-tab-photos').classList.toggle('on', photos);
+ el('screens-tab-records').classList.toggle('on', !photos);
+ el('screens-pane-photos').classList.toggle('hidden', !photos);
+ el('screens-pane-records').classList.toggle('hidden', photos);
+ if (!photos && !recordsGeladen) { recordsGeladen = true; openRecordsList(); }
+}
+
+function updateRecordsSelectionUi() {
+ const n = recordSelected.size;
+ const btn = el('records-delete-sel');
+ if (btn) {
+ btn.disabled = n === 0;
+ btn.textContent = n ? (n + ' löschen') : 'Auswahl löschen';
+ }
+ const hint = el('records-sel-hint');
+ if (hint) hint.textContent = n ? (n + ' ausgewählt – Doppelklick spielt ab') : 'Klick auf Vorschaubild zum Auswählen, Doppelklick zum Abspielen';
+ document.querySelectorAll('#records-grid .screen-thumb').forEach((t) => {
+ const name = t.dataset.name;
+ t.classList.toggle('selected', !!(name && recordSelected.has(name)));
+ const cb = t.querySelector('.screen-check');
+ if (cb) cb.checked = !!(name && recordSelected.has(name));
+ });
+}
+
+function formatDateigroesse(bytes) {
+ if (bytes >= 1024 * 1024 * 1024) return (bytes / (1024 * 1024 * 1024)).toFixed(1) + ' GB';
+ if (bytes >= 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
+ return Math.round(bytes / 1024) + ' KB';
+}
+
+async function openRecordsList() {
+ const grid = el('records-grid');
+ grid.innerHTML = '