Gilt erst für die nächste Aufnahme – während einer laufenden Aufnahme nicht änderbar.
+
+
+
+
+
+
+
+
+
+
+
+
+
Audioquellen wählen
+
+
+
+
Mehrfachauswahl möglich – alle angehakten Quellen werden zu einer Tonspur gemischt, Lautstärke je Quelle einstellbar.
+
+
+
+
+
+
+
+
+
Kein laufender Minecraft-Prozess erkannt – bitte über den Launcher starten.
+
+
Systemton ist immer der komplette Mix (Spiel, Discord, Musik, Benachrichtigungen usw.). "Nur Minecraft" nimmt ausschließlich den Ton des Spiels auf (z. B. wenn nebenbei Musik läuft, die nicht mit rein soll) – beides lässt sich auch kombiniert mit einem Mikrofon anhaken.
+
+
+
+
+
+
+
+
@@ -856,7 +979,7 @@
-
+
@@ -899,19 +1022,19 @@
Aktuell
-
+
wird geladen …
Neu
-
+
noch keine Datei gewählt
- Die Vorschau zeigt die Figur so, wie sie im Spiel aussieht. Die Armform wirkt sich auf die Darstellung aus.
+ Mit der Maus ziehen dreht die Figur, Mausrad zoomt. Die Armform wirkt sich auf die Darstellung aus.
@@ -1275,6 +1398,7 @@
+
diff --git a/src/main.js b/src/main.js
index e19ac98..8b0bccc 100644
--- a/src/main.js
+++ b/src/main.js
@@ -6,7 +6,7 @@
* (Vanilla-Start, Microsoft-Login und Mod-Loader folgen in späteren Phasen.)
*/
-const { app, BrowserWindow, ipcMain, dialog, shell, safeStorage, Notification, Tray, Menu, nativeImage, clipboard } = require('electron');
+const { app, BrowserWindow, ipcMain, dialog, shell, safeStorage, Notification, Tray, Menu, nativeImage, clipboard, desktopCapturer, session, screen } = require('electron');
const path = require('path');
const fs = require('fs');
const os = require('os');
@@ -85,6 +85,274 @@ function applyProxy(s) {
let mainWindow = null;
+// ---------------------------------------------------------------------------
+// Popup-Fenster: alle großen Dialoge als eigene, frei verschiebbare Fenster
+// (native Titelleiste ersetzt durch den eigenen dunklen Kopfbereich) statt
+// als Modal im Hauptfenster. Laden dieselbe index.html mit z. B.
+// "?popout=mods&instanceId=..." – der Renderer blendet dann nur diesen einen
+// Dialog vollflächig ein.
+// ---------------------------------------------------------------------------
+
+const popoutWindows = new Map(); // modalId -> BrowserWindow
+
+const POPOUT_SPEC = {
+ settings: { title: 'AeroMC – Einstellungen', width: 980, height: 720, minWidth: 700, minHeight: 500 },
+ console: { title: 'AeroMC – Konsole / Log', width: 860, height: 680, minWidth: 640, minHeight: 480 },
+ mods: { title: 'AeroMC – Mods', width: 900, height: 700, minWidth: 640, minHeight: 480 },
+ crash: { title: 'AeroMC – Absturzbericht', width: 860, height: 680, minWidth: 640, minHeight: 480 },
+ logs: { title: 'AeroMC – Logdateien', width: 860, height: 680, minWidth: 640, minHeight: 480 },
+ content: { title: 'AeroMC – Welten & Pakete', width: 860, height: 680, minWidth: 640, minHeight: 480 },
+ screens: { title: 'AeroMC – Bildschirmfotos', width: 860, height: 680, minWidth: 640, minHeight: 480 },
+ servers: { title: 'AeroMC – Server', width: 860, height: 680, minWidth: 640, minHeight: 480 },
+ skin: { title: 'AeroMC – Skin', width: 860, height: 680, minWidth: 640, minHeight: 480 },
+ bedrock: { title: 'AeroMC – Bedrock', width: 860, height: 680, minWidth: 640, minHeight: 480 },
+ login: { title: 'AeroMC – Konten', width: 460, height: 460, minWidth: 420, minHeight: 380 },
+ import: { title: 'AeroMC – Import', width: 860, height: 680, minWidth: 640, minHeight: 480 },
+ 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 },
+};
+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
+
+// an Hauptfenster UND alle offenen Popup-Fenster senden (Start-Fortschritt
+// muss z. B. auch im herausgelösten Konsolen-Fenster ankommen)
+function broadcast(channel, payload) {
+ for (const w of [mainWindow, ...popoutWindows.values()]) {
+ if (w && !w.isDestroyed()) w.webContents.send(channel, payload);
+ }
+}
+
+function openPopout(modalId, params) {
+ let win = popoutWindows.get(modalId);
+ if (win && !win.isDestroyed()) {
+ win.show();
+ win.focus();
+ // erneut mit (evtl. neuen) Parametern anzeigen, ohne die Seite neu zu
+ // laden – sonst ginge z. B. der bisher gesammelte Konsolentext verloren
+ win.webContents.send('popout:reopen', params || {});
+ return;
+ }
+ const query = { popout: modalId };
+ for (const [k, v] of Object.entries(params || {})) if (v !== undefined && v !== null) query[k] = String(v);
+ const spec = POPOUT_SPEC[modalId] || POPOUT_DEFAULT;
+ win = new BrowserWindow({
+ width: spec.width,
+ height: spec.height,
+ minWidth: spec.minWidth,
+ minHeight: spec.minHeight,
+ title: spec.title || POPOUT_DEFAULT.title,
+ backgroundColor: '#1b1e24',
+ icon: windowIcon(),
+ frame: false, // eigener dunkler Kopfbereich (Titel + ✕) ersetzt die native Titelleiste
+ show: false,
+ webPreferences: {
+ preload: path.join(__dirname, 'preload.js'),
+ contextIsolation: true,
+ nodeIntegration: false,
+ sandbox: false,
+ },
+ });
+ popoutWindows.set(modalId, win);
+ win.loadFile(path.join(__dirname, 'index.html'), { query });
+ win.once('ready-to-show', () => win.show());
+ win.webContents.on('did-fail-load', (_e, code, desc) => {
+ console.error('Popup-Fenster "' + modalId + '" konnte nicht laden:', code, desc);
+ });
+ win.on('close', (e) => {
+ if (appIsQuitting) return;
+ e.preventDefault();
+ // Läuft gerade eine Aufzeichnung, darf ein natives Schließen (Alt+F4,
+ // Taskleiste) sie nicht stillschweigend verstecken -> der Renderer
+ // entscheidet selbst (Stoppen+Speichern oder Abbrechen)
+ if (modalId === 'record' && recordingGuardActive) {
+ win.webContents.send('record:closeAttempt');
+ return;
+ }
+ win.hide();
+ });
+ win.on('closed', () => { popoutWindows.delete(modalId); });
+}
+
+let recordingGuardActive = false;
+ipcMain.on('recording:guard', (_e, active) => { recordingGuardActive = !!active; });
+
+ipcMain.on('debug:rendererError', (_e, msg) => console.error('[Renderer-Fehler]', msg));
+ipcMain.on('popout:open', (_e, modalId, params) => openPopout(modalId, params));
+ipcMain.on('popout:hide', (e) => {
+ const w = BrowserWindow.fromWebContents(e.sender);
+ if (w && !w.isDestroyed()) w.hide();
+});
+ipcMain.handle('update:getPending', () => pendingUpdateInfo);
+
+// ---------------------------------------------------------------------------
+// Spielaufzeichnung: Fenster-Erkennung per Fenstertitel (kein PID-Tracking
+// nötig dafür - funktioniert unabhängig von Konto/settings.wrapperCommand).
+// Video kommt vom Fenster, Systemton separat vom Bildschirm (Chromium erlaubt
+// keine Pro-Fenster-Tonaufnahme) - siehe renderer.js für den Aufnahme-Teil.
+// Die PID wird NUR für die optionale isolierte "Nur Minecraft"-Tonspur
+// gebraucht (per-Prozess-Aufnahme via loopback-capture, siehe unten).
+// ---------------------------------------------------------------------------
+
+const letzteQuickPlayServer = new Map(); // instanceId -> Server-Adresse
+// Minecraft schreibt diese Zeile bei JEDER Verbindung zu einem Server ins Log
+// - egal ob per Quick Play oder von Hand im Spiel verbunden, und egal ob das
+// Aufzeichnungs-Fenster dabei schon offen war. Hier (statt im Renderer)
+// verfolgt, weil das hier zentral für alle Fenster gilt, auch für ein erst
+// SPÄTER geöffnetes Aufzeichnungs-Popup, das die Zeile sonst verpasst hätte.
+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
+ipcMain.handle('recording:getPid', (_e, id) => laufendePid.get(id) || null);
+
+// Welche Bildschirmquelle als Nächstes über getDisplayMedia erfasst werden
+// soll - vom Renderer kurz vor dem eigentlichen Aufruf gesetzt (siehe
+// setDisplayMediaRequestHandler weiter unten). Für den cursorfreien
+// Erfassungsversuch: ganzer Bildschirm ohne Mauszeiger, der Zuschnitt aufs
+// Minecraft-Fenster passiert danach per Canvas im Renderer (siehe
+// recording:getWindowBounds). Schlägt das fehl, läuft die Erfassung über die
+// alte, zuverlässige chromeMediaSource-Fenster-Methode (Mauszeiger sichtbar).
+let ausstehendeErfassungsQuelle = null;
+ipcMain.handle('recording:setCaptureTarget', (_e, sourceId) => { ausstehendeErfassungsQuelle = sourceId; });
+
+// Sucht das Fenster des laufenden Minecraft-Prozesses (über die PID aus
+// laufendePid, robuster als ein Titel-Abgleich) und ermittelt daraus:
+// - die aktuellen Fenstergrenzen in Bildschirmkoordinaten
+// - den Bildschirm, auf dem das Fenster gerade sitzt (Mehrschirm-fähig)
+// - die zu diesem Bildschirm gehörende Erfassungsquelle für getDisplayMedia
+// Liefert null, wenn das Fenster nicht gefunden wird (z. B. Minecraft nicht
+// über den Launcher gestartet, oder bereits beendet) - der Renderer fällt in
+// dem Fall auf die alte Fenster-Erfassung zurück statt abzustürzen.
+async function findeFensterUndBildschirm(pid, fensterTitel) {
+ let windowManager;
+ try { ({ windowManager } = require('node-window-manager')); }
+ catch (err) { console.log('[Aufzeichnung] node-window-manager Fehler:', err.message); return null; }
+ let alleFenster;
+ try { alleFenster = windowManager.getWindows(); }
+ catch (err) { console.log('[Aufzeichnung] getWindows Fehler:', err.message); return null; }
+
+ // Die PID kennt der Launcher nur für Prozesse, die er in DIESER laufenden
+ // Sitzung selbst gestartet hat - nach einem Neustart des Launchers (z. B.
+ // während Minecraft weiterläuft) ist laufendePid leer, obwohl das Spiel
+ // noch läuft. Dann über den EXAKTEN Fenstertitel suchen, den der Renderer
+ // schon über recording:listWindows (dort per /Minecraft/i gefiltert und
+ // vom Nutzer im Suchdialog bestätigt) positiv identifiziert hat - NICHT
+ // erneut breit nach /Minecraft/i suchen, das kann auf einem Rechner mit
+ // vielen offenen Fenstern (Editor, Browser-Tabs usw.) ein völlig falsches,
+ // unbeteiligtes Fenster treffen und dessen Inhalt zeigen/aufnehmen.
+ let fenster = pid ? alleFenster.find((w) => w.processId === pid) : null;
+ if (!fenster && fensterTitel) {
+ fenster = alleFenster.find((w) => { try { return w.getTitle() === fensterTitel; } catch { return false; } });
+ }
+ if (!fenster) { console.log('[Aufzeichnung] kein Minecraft-Fenster gefunden (PID', pid, ', Titel', fensterTitel, ')'); return null; }
+ const fensterBounds = fenster.getBounds();
+ if (!fensterBounds || !fensterBounds.width || !fensterBounds.height) {
+ console.log('[Aufzeichnung] ungültige Fenstergrenzen:', JSON.stringify(fensterBounds));
+ return null;
+ }
+
+ const anzeige = screen.getDisplayMatching(fensterBounds);
+ const screenSources = await desktopCapturer.getSources({ types: ['screen'] });
+ const quelle = screenSources.find((s) => String(s.display_id) === String(anzeige.id)) || screenSources[0];
+ if (!quelle) { console.log('[Aufzeichnung] keine Bildschirmquelle gefunden'); return null; }
+
+ console.log('[Aufzeichnung] gefunden:', JSON.stringify({ fensterBounds, bildschirmBounds: anzeige.bounds, screenSourceId: quelle.id }));
+ return {
+ fensterBounds,
+ bildschirmBounds: anzeige.bounds,
+ screenSourceId: quelle.id,
+ };
+}
+
+ipcMain.handle('recording:getWindowBounds', async (_e, instanceId, fensterTitel) => {
+ const pid = laufendePid.get(instanceId);
+ return findeFensterUndBildschirm(pid, fensterTitel);
+});
+
+// Isolierte Ton-Aufnahme nur eines Prozesses (inkl. Kindprozesse) über
+// Windows' Process-Loopback-API. Nur eine Aufnahme gleichzeitig aktiv.
+let aktiveSpielAudioCapture = null;
+
+ipcMain.handle('recording:startGameAudio', (event, pid) => {
+ if (aktiveSpielAudioCapture) return { ok: false, message: 'Läuft bereits.' };
+ let loopback;
+ try {
+ loopback = require('loopback-capture');
+ } catch (err) {
+ return { ok: false, message: 'Spielton-Isolierung nicht verfügbar: ' + err.message };
+ }
+ const win = BrowserWindow.fromWebContents(event.sender);
+ try {
+ const capture = new loopback.LoopbackCapture();
+ capture.start(pid, true, (chunk) => {
+ if (win && !win.isDestroyed()) win.webContents.send('recording:gameAudioChunk', new Uint8Array(chunk));
+ });
+ aktiveSpielAudioCapture = { capture };
+ return { ok: true };
+ } catch (err) {
+ return { ok: false, message: 'Spielton-Aufnahme fehlgeschlagen (Windows 10 Version 2004+ erforderlich, oder Spiel bereits beendet): ' + err.message };
+ }
+});
+
+ipcMain.handle('recording:stopGameAudio', () => {
+ if (aktiveSpielAudioCapture) {
+ try { aktiveSpielAudioCapture.capture.stop(); } catch { /* egal, wird verworfen */ }
+ aktiveSpielAudioCapture = null;
+ }
+ return true;
+});
+
+ipcMain.handle('recording:listWindows', async () => {
+ const sources = await desktopCapturer.getSources({
+ types: ['window'],
+ thumbnailSize: { width: 200, height: 120 },
+ });
+ return sources
+ .filter((s) => /Minecraft/i.test(s.name))
+ .map((s) => ({
+ id: s.id,
+ name: s.name,
+ thumbnail: s.thumbnail && !s.thumbnail.isEmpty() ? s.thumbnail.toDataURL() : null,
+ }));
+});
+
+// Für den Systemton reicht irgendeine Bildschirmquelle als Pflichtfeld für
+// getUserMedia -- der Ton ist ohnehin nicht pro Bildschirm getrennt.
+ipcMain.handle('recording:getAudioSourceId', async () => {
+ const sources = await desktopCapturer.getSources({ types: ['screen'] });
+ return (sources[0] && sources[0].id) || null;
+});
+
+function aufzeichnungsOrdner(instanceName) {
+ return path.join(app.getPath('videos'), 'AeroMC Launcher',
+ String(instanceName || 'Instanz').replace(/[^\w.-]+/g, '_'));
+}
+
+ipcMain.handle('recording:save', async (_e, instanceName, buffer, serverLabel) => {
+ 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 n = 2;
+ while (fs.existsSync(zielDatei)) { zielDatei = path.join(zielOrdner, `${serverTeil}${zeitstempel}_${n}.webm`); n++; }
+ fs.writeFileSync(zielDatei, Buffer.from(buffer));
+ notify('Aufzeichnung gespeichert', `${instanceName}: ${path.basename(zielDatei)}`);
+ return { ok: true, path: zielDatei };
+ } catch (err) {
+ return { ok: false, message: 'Speichern fehlgeschlagen: ' + err.message };
+ }
+});
+
+ipcMain.handle('recording:openFolder', (_e, instanceName) => {
+ const dir = aufzeichnungsOrdner(instanceName);
+ fs.mkdirSync(dir, { recursive: true });
+ shell.openPath(dir);
+ return true;
+});
+
// ---------------------------------------------------------------------------
// Verknüpfungen: "AeroMC.exe --launch " startet eine Instanz direkt
// ---------------------------------------------------------------------------
@@ -217,7 +485,12 @@ function createWindow(startHidden) {
// Infobereich, statt AeroMC wirklich zu beenden – sonst wäre der
// Autostart witzlos, weil man ihn beim ersten Wegklicken gleich wieder los ist.
mainWindow.on('close', (e) => {
- if (appIsQuitting || !store.getSettings().autostart) return;
+ if (appIsQuitting || !store.getSettings().autostart) {
+ // Hauptfenster schließt wirklich -> keine nur versteckten Popup-Fenster
+ // zurücklassen (die sonst window-all-closed nie erreichen ließen)
+ for (const w of popoutWindows.values()) if (!w.isDestroyed()) w.destroy();
+ return;
+ }
e.preventDefault();
mainWindow.hide();
});
@@ -306,6 +579,17 @@ app.whenReady().then(() => {
const startHidden = autostartAn && process.argv.includes('--hidden');
createWindow(startHidden);
+ // Fenstererfassung für die Spielaufzeichnung ohne nativen Auswahldialog:
+ // das vom Renderer per recording:setCaptureTarget gewählte Fenster wird
+ // hier direkt zurückgegeben, statt den Nutzer nochmal wählen zu lassen
+ session.defaultSession.setDisplayMediaRequestHandler((_request, callback) => {
+ desktopCapturer.getSources({ types: ['screen'] }).then((sources) => {
+ const quelle = sources.find((s) => s.id === ausstehendeErfassungsQuelle) || sources[0];
+ console.log('[Aufzeichnung] setDisplayMediaRequestHandler: angefordert=', ausstehendeErfassungsQuelle, 'gewählt=', quelle && quelle.id);
+ callback(quelle ? { video: quelle } : {});
+ });
+ }, { useSystemPicker: false });
+
// Java-Suche im Hintergrund vorbereiten -> erster Spielstart ohne Verzögerung
setTimeout(() => { try { services.scanJava(); } catch { /* egal */ } }, 800);
@@ -329,17 +613,14 @@ app.whenReady().then(() => {
setTimeout(async () => {
try {
const info = await updater.checkForUpdate(app.getVersion());
- if (mainWindow && !mainWindow.isDestroyed()) {
- // Ergebnis immer melden, damit die Info-Seite auch "aktuell" anzeigen
- // kann – nicht erst wenn der Nutzer manuell auf "Suchen" klickt
- mainWindow.webContents.send('update:checked', info);
- if (info.available) mainWindow.webContents.send('update:available', info);
- }
+ pendingUpdateInfo = info;
+ // Ergebnis immer melden, damit die Info-Seite auch "aktuell" anzeigen
+ // kann – nicht erst wenn der Nutzer manuell auf "Suchen" klickt
+ broadcast('update:checked', info);
+ if (info.available) broadcast('update:available', info);
} catch (err) {
// offline o. Ä. – kein Absturz, aber die Info-Seite darf den Fehler zeigen
- if (mainWindow && !mainWindow.isDestroyed()) {
- mainWindow.webContents.send('update:checked', { error: (err && err.message) || String(err) });
- }
+ broadcast('update:checked', { error: (err && err.message) || String(err) });
}
}, 4000);
@@ -351,7 +632,7 @@ app.whenReady().then(() => {
const cid = store.getSettings().azureClientId || auth.DEFAULT_CLIENT_ID;
auth.getValidAccount(cid)
.then((acc) => {
- if (acc && mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('auth:refreshed');
+ if (acc) broadcast('auth:refreshed');
})
.catch(() => { /* offline o. Ä. – Konto bleibt gespeichert */ });
@@ -393,6 +674,15 @@ 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);
+ // 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
+ // (die speichert bei jedem Klick auf eine Instanz mit) – sonst baut das
+ // Hauptfenster bei jedem Klick seine ganze Liste neu auf und die flackert.
+ const nurLetzteInstanz = patch && Object.keys(patch).every((k) => k === 'lastSelectedId');
+ if (!nurLetzteInstanz && mainWindow && !mainWindow.isDestroyed()) {
+ mainWindow.webContents.send('settings:changed', s);
+ }
return s;
});
@@ -522,14 +812,14 @@ ipcMain.handle('mods:install', async (_e, id, project, art) => {
* per Hand zugeschickt). Die Datei taucht danach ganz normal in der
* "Installiert"-Liste auf, weil listMods() den Ordner ohnehin mitscannt.
*/
-ipcMain.handle('mods:addFile', async (_e, id, art) => {
+ipcMain.handle('mods:addFile', async (e, id, art) => {
const inst = store.getInstance(id);
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
const typ = services.inhaltsArt(art || 'mod');
const filters = (art || 'mod') === 'mod'
? [{ name: 'Mod-Datei', extensions: ['jar', 'zip'] }]
: [{ name: typ.label || 'Datei', extensions: ['zip'] }];
- const res = await dialog.showOpenDialog(mainWindow, {
+ const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
title: 'Datei hinzufügen',
properties: ['openFile', 'multiSelections'],
filters: filters.concat([{ name: 'Alle Dateien', extensions: ['*'] }]),
@@ -629,7 +919,7 @@ ipcMain.handle('optifine:setup', async (_e, id) => {
let prep;
try {
prep = await launcher.prepareAndLaunch(opts, (evt) => {
- if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('launch:progress', Object.assign({ id }, evt));
+ broadcast('launch:progress', Object.assign({ id }, evt));
});
} catch (err) {
return { ok: false, message: 'Vorbereitung fehlgeschlagen: ' + err.message };
@@ -796,7 +1086,7 @@ ipcMain.handle('mods:checkUpdates', async (_e, id, art) => {
if (!gameVersion) return { ok: false, message: 'Instanz hat keine Minecraft-Version gesetzt.' };
try {
const res = await services.modrinthFindUpdates(store.listMods(id, art || 'mod'), gameVersion, loader, (p) => {
- if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('mods:updateProgress', p);
+ broadcast('mods:updateProgress', p);
}, art || 'mod');
return { ok: true, ...res };
} catch (err) {
@@ -864,8 +1154,8 @@ ipcMain.handle('skin:profile', async () => {
catch (err) { return { ok: false, message: 'Profil konnte nicht geladen werden: ' + err.message }; }
});
-ipcMain.handle('skin:choose', async () => {
- const res = await dialog.showOpenDialog(mainWindow, {
+ipcMain.handle('skin:choose', async (e) => {
+ const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
title: 'Skin auswählen',
properties: ['openFile'],
filters: [{ name: 'PNG-Bild', extensions: ['png'] }],
@@ -886,14 +1176,22 @@ ipcMain.handle('skin:choose', async () => {
ipcMain.handle('skin:apply', async (_e, dateiPfad, variante) => {
const token = await aktivesToken();
if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' };
- try { return await skins.setzeSkin(token, dateiPfad, variante); }
+ try {
+ const res = await skins.setzeSkin(token, dateiPfad, variante);
+ if (res && res.ok) broadcast('skin:changed');
+ return res;
+ }
catch (err) { return { ok: false, message: 'Skin konnte nicht gesetzt werden: ' + err.message }; }
});
ipcMain.handle('skin:reset', async () => {
const token = await aktivesToken();
if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' };
- try { return await skins.setzeSkinZurueck(token); }
+ try {
+ const res = await skins.setzeSkinZurueck(token);
+ if (res && res.ok) broadcast('skin:changed');
+ return res;
+ }
catch (err) { return { ok: false, message: 'Zurücksetzen fehlgeschlagen: ' + err.message }; }
});
ipcMain.handle('auth:remove', (_e, id) => auth.removeAccount(id));
@@ -905,7 +1203,7 @@ ipcMain.handle('auth:login', async () => {
const account = await auth.login(
clientId,
(code) => {
- if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('auth:code', code);
+ broadcast('auth:code', code);
},
() => {},
);
@@ -923,9 +1221,17 @@ ipcMain.handle('instances:list', () => store.listInstances());
ipcMain.handle('instances:get', (_e, id) => store.getInstance(id));
-ipcMain.handle('instances:create', (_e, data) => store.createInstance(data));
+ipcMain.handle('instances:create', (_e, data) => {
+ const res = store.createInstance(data);
+ broadcast('instances:changed');
+ return res;
+});
-ipcMain.handle('instances:update', (_e, id, patch) => store.updateInstance(id, patch));
+ipcMain.handle('instances:update', (_e, id, patch) => {
+ const res = store.updateInstance(id, patch);
+ broadcast('instances:changed');
+ return res;
+});
ipcMain.handle('instances:delete', (_e, id) => store.deleteInstance(id));
@@ -1121,9 +1427,7 @@ async function spieleMrpackEin(buf, zip, index, gameDir, { schonePlayerDaten })
if (geschuetzt(rel)) continue;
const dest = path.join(gameDir, rel.replace(/\//g, path.sep));
if (!inside(dest)) continue;
- if (mainWindow && !mainWindow.isDestroyed()) {
- mainWindow.webContents.send('launch:progress', { phase: 'modpack', current: i, total: files.length, detail: 'Modpack-Dateien' });
- }
+ broadcast('launch:progress', { phase: 'modpack', current: i, total: files.length, detail: 'Modpack-Dateien' });
try {
const res = await fetch(url, { headers: { 'User-Agent': 'AeroMC-Launcher' } });
if (!res.ok) continue;
@@ -1231,9 +1535,7 @@ ipcMain.handle('modpack:update', async (_e, id, versionId) => {
const datei = (version.files || []).find((f) => f.primary) || (version.files || [])[0];
if (!datei || !datei.url) return { ok: false, message: 'Diese Fassung enthält keine Paketdatei.' };
- if (mainWindow && !mainWindow.isDestroyed()) {
- mainWindow.webContents.send('launch:progress', { id, phase: 'modpack', detail: 'Neue Fassung wird geladen …' });
- }
+ broadcast('launch:progress', { id, phase: 'modpack', detail: 'Neue Fassung wird geladen …' });
const dl = await fetch(datei.url, { headers: { 'User-Agent': 'AeroMC-Launcher' } });
if (!dl.ok) return { ok: false, message: 'Download fehlgeschlagen (HTTP ' + dl.status + ').' };
const buf = Buffer.from(await dl.arrayBuffer());
@@ -1276,6 +1578,7 @@ ipcMain.handle('modpack:update', async (_e, id, versionId) => {
}),
});
+ broadcast('instances:changed');
notify('Modpack aktualisiert', `${inst.name} ist auf Version ${version.version_number} aktualisiert.`);
return {
ok: true,
@@ -1317,9 +1620,7 @@ async function importCurseForge(buf, zip, zugang) {
const gameDir = store.gameDir(created.id);
const inside = (dest) => dest.startsWith(gameDir + path.sep);
const melde = (detail, current, total) => {
- if (mainWindow && !mainWindow.isDestroyed()) {
- mainWindow.webContents.send('launch:progress', { phase: 'modpack', current, total, detail });
- }
+ broadcast('launch:progress', { phase: 'modpack', current, total, detail });
};
// 1) Dateiliste über die CurseForge-API auflösen
@@ -1388,11 +1689,7 @@ ipcMain.handle('modpack:installFromModrinth', async (_e, project) => {
const projectId = project && (project.projectId || project.id);
if (!projectId) return { ok: false, message: 'Kein Modpack gewählt.' };
const gameVersion = (project && project.gameVersion) || '';
- if (mainWindow && !mainWindow.isDestroyed()) {
- mainWindow.webContents.send('launch:progress', {
- phase: 'modpack', detail: 'Modpack-Version wird ermittelt …',
- });
- }
+ broadcast('launch:progress', { phase: 'modpack', detail: 'Modpack-Version wird ermittelt …' });
const file = await services.modrinthResolveModpack({ projectId, gameVersion });
if (!file) {
return {
@@ -1402,12 +1699,7 @@ ipcMain.handle('modpack:installFromModrinth', async (_e, project) => {
: 'Keine Modpack-Version gefunden.',
};
}
- if (mainWindow && !mainWindow.isDestroyed()) {
- mainWindow.webContents.send('launch:progress', {
- phase: 'modpack',
- detail: 'Lade ' + (file.filename || file.name || 'Modpack') + ' …',
- });
- }
+ broadcast('launch:progress', { phase: 'modpack', detail: 'Lade ' + (file.filename || file.name || 'Modpack') + ' …' });
const buf = await services.downloadBuffer(file.url);
const zip = forge.readZipIndex(buf);
if (!zip['modrinth.index.json']) {
@@ -1427,6 +1719,7 @@ ipcMain.handle('modpack:installFromModrinth', async (_e, project) => {
result.instance = store.getInstance(inst.id);
}
if (result && result.ok && result.instance) {
+ broadcast('instances:changed');
notify('Modpack installiert', `${result.instance.name} ist einsatzbereit.`);
}
return result;
@@ -1446,11 +1739,7 @@ ipcMain.handle('modpack:installFromCurseForge', async (_e, project) => {
const modId = Number(rawId);
if (!modId) return { ok: false, message: 'Kein Modpack gewählt.' };
const gameVersion = (project && project.gameVersion) || '';
- if (mainWindow && !mainWindow.isDestroyed()) {
- mainWindow.webContents.send('launch:progress', {
- phase: 'modpack', detail: 'Modpack-Version wird ermittelt …',
- });
- }
+ broadcast('launch:progress', { phase: 'modpack', detail: 'Modpack-Version wird ermittelt …' });
const file = await services.curseforgeResolveModpackFile(zugang, modId, gameVersion);
if (!file || !file.url) {
return {
@@ -1460,11 +1749,7 @@ ipcMain.handle('modpack:installFromCurseForge', async (_e, project) => {
: 'Keine passende Modpack-Datei gefunden.',
};
}
- if (mainWindow && !mainWindow.isDestroyed()) {
- mainWindow.webContents.send('launch:progress', {
- phase: 'modpack', detail: 'Lade ' + file.filename + ' …',
- });
- }
+ broadcast('launch:progress', { phase: 'modpack', detail: 'Lade ' + file.filename + ' …' });
const dlUrl = zugang.downloadAdresse ? zugang.downloadAdresse(file.url) : file.url;
const buf = await services.downloadBuffer(dlUrl, zugang.downloadKopfzeilen);
const zip = forge.readZipIndex(buf);
@@ -1473,6 +1758,7 @@ ipcMain.handle('modpack:installFromCurseForge', async (_e, project) => {
}
const result = await importCurseForge(buf, zip, zugang);
if (result && result.ok && result.instance) {
+ broadcast('instances:changed');
notify('Modpack installiert', `${result.instance.name} ist einsatzbereit.`);
}
return result;
@@ -1505,6 +1791,7 @@ async function importModpackFromPath(filePath) {
return { ok: false, notModpack: true, message: 'Unbekanntes Modpack-Format (keine modrinth.index.json gefunden).' };
}
if (result && result.ok && result.instance) {
+ broadcast('instances:changed');
notify('Modpack installiert', `${result.instance.name} ist einsatzbereit.`);
}
return result;
@@ -1513,8 +1800,8 @@ async function importModpackFromPath(filePath) {
}
}
-ipcMain.handle('modpack:import', async () => {
- const res = await dialog.showOpenDialog(mainWindow, {
+ipcMain.handle('modpack:import', async (e) => {
+ const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
title: 'Modpack importieren',
properties: ['openFile'],
filters: [{ name: 'Modpacks', extensions: ['mrpack', 'zip'] }],
@@ -1643,14 +1930,16 @@ async function importInstanceZipFromPath(filePath) {
}
}
-ipcMain.handle('instances:import', async () => {
- const res = await dialog.showOpenDialog(mainWindow, {
+ipcMain.handle('instances:import', async (e) => {
+ const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
title: 'Instanz importieren',
properties: ['openFile'],
filters: [{ name: 'ZIP-Archiv', extensions: ['zip'] }],
});
if (res.canceled) return { ok: false, canceled: true };
- return importInstanceZipFromPath(res.filePaths[0]);
+ const result = await importInstanceZipFromPath(res.filePaths[0]);
+ if (result && result.ok) broadcast('instances:changed');
+ return result;
});
/*
@@ -1696,6 +1985,7 @@ ipcMain.handle('files:drop', async (_e, filePath, targetId, targetArt) => {
if (!modpack.notModpack || ext === '.mrpack') return Object.assign({ kind: 'modpack' }, modpack);
// .zip ohne erkanntes Modpack-Format -> als Instanz-Export/-Import versuchen
const instanceRes = await importInstanceZipFromPath(filePath);
+ if (instanceRes && instanceRes.ok) broadcast('instances:changed');
return Object.assign({ kind: 'instance' }, instanceRes);
}
@@ -1710,7 +2000,7 @@ ipcMain.handle('files:drop', async (_e, filePath, targetId, targetArt) => {
function notify(title, body) {
try {
if (store.getSettings().desktopNotifications === false) return;
- if (mainWindow && !mainWindow.isDestroyed() && mainWindow.isFocused()) return;
+ if (BrowserWindow.getAllWindows().some((w) => !w.isDestroyed() && w.isFocused())) return;
if (!Notification.isSupported()) return;
const n = new Notification({ title, body, silent: false });
n.on('click', () => {
@@ -1844,7 +2134,7 @@ ipcMain.handle('backup:restore', async (_e, sourcePath) => {
const spielendeKonten = new Map();
function meldeKontenAenderung() {
- if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('accounts:changed');
+ broadcast('accounts:changed');
}
function kontoSpieltJetzt(kontoId) {
@@ -1862,6 +2152,12 @@ function kontoSpieltNichtMehr(kontoId) {
}
ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
+ // für die Spielaufzeichnung merken, mit welchem Server (Quick Play) diese
+ // Instanz zuletzt gestartet wurde -- muss im Hauptprozess liegen, weil
+ // Serverliste und Aufzeichnung zwei getrennte Popup-Fenster mit jeweils
+ // eigenem Renderer-Speicher sind
+ if (launchOpts && launchOpts.quickPlayServer) letzteQuickPlayServer.set(id, launchOpts.quickPlayServer);
+ else letzteQuickPlayServer.delete(id);
const inst = store.getInstance(id);
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
if (!inst.minecraft.version) {
@@ -1884,9 +2180,7 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
// die nebenher läuft, während Minecraft dieselben Dateien anfasst, wäre
// nicht verlässlich.
if (settings.autoBackupEnabled && settings.autoBackupBeforeLaunch) {
- if (mainWindow && !mainWindow.isDestroyed()) {
- mainWindow.webContents.send('launch:progress', { id, phase: 'backup', detail: 'Sicherung vor dem Start …' });
- }
+ broadcast('launch:progress', { id, phase: 'backup', detail: 'Sicherung vor dem Start …' });
await backupNachZeitplan('vor-dem-start').catch(() => {});
}
@@ -1947,6 +2241,7 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
if (evt.phase === 'exit') {
if (gezaehlt) { gezaehlt = false; kontoSpieltNichtMehr(kontoId); }
discordpresence.clearActivity();
+ laufendePid.delete(id); // für die "Nur Minecraft"-Tonaufnahme: Prozess ist weg
if (launchStart) {
const secs = Math.max(0, Math.round((Date.now() - launchStart) / 1000));
launchStart = 0; // Spielzeit nur einmal gutschreiben
@@ -1954,14 +2249,23 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
if (cur) store.updateInstance(id, { totalPlaySeconds: (cur.totalPlaySeconds || 0) + secs });
}
}
- if (mainWindow && !mainWindow.isDestroyed()) {
- mainWindow.webContents.send('launch:progress', Object.assign({ id }, evt));
+ if (evt.phase === 'log') {
+ const m = VERBINDUNGS_ZEILE.exec(evt.line);
+ if (m) {
+ const server = m[2] === '25565' ? m[1] : `${m[1]}:${m[2]}`;
+ letzteQuickPlayServer.set(id, server);
+ // damit ein schon offenes Aufzeichnungs-Fenster den Servernamen im
+ // Titel nachträgt, auch wenn es VOR dem Verbinden geöffnet wurde
+ broadcast('recording:serverDetected', { id, server });
+ }
}
+ broadcast('launch:progress', Object.assign({ id }, evt));
});
// 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) store.updateInstance(id, { lastPlayed: new Date().toISOString() });
+ if (res.ok && res.pid) laufendePid.set(id, res.pid);
return res;
} catch (err) {
if (gezaehlt) { gezaehlt = false; kontoSpieltNichtMehr(kontoId); discordpresence.clearActivity(); }
@@ -1973,8 +2277,8 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
// IPC – Dialoge
// ---------------------------------------------------------------------------
-ipcMain.handle('dialog:pickFolder', async (_e, title) => {
- const res = await dialog.showOpenDialog(mainWindow, {
+ipcMain.handle('dialog:pickFolder', async (e, title) => {
+ const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
title: title || 'Ordner wählen',
properties: ['openDirectory', 'createDirectory'],
});
@@ -2010,8 +2314,8 @@ ipcMain.handle('file:dataUri', (_e, filePath) => {
} catch { return null; }
});
-ipcMain.handle('dialog:pickJava', async () => {
- const res = await dialog.showOpenDialog(mainWindow, {
+ipcMain.handle('dialog:pickJava', async (e) => {
+ const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
title: 'java.exe wählen',
properties: ['openFile'],
filters: [{ name: 'Java', extensions: ['exe'] }],
@@ -2028,8 +2332,8 @@ ipcMain.handle('app:openExternal', (_e, url) => {
// IPC – Instanzen aus MultiMC/Prism übernehmen
// ---------------------------------------------------------------------------
-ipcMain.handle('mmc:pickFolder', async () => {
- const res = await dialog.showOpenDialog(mainWindow, {
+ipcMain.handle('mmc:pickFolder', async (e) => {
+ const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
title: 'MultiMC-Ordner wählen (der Ordner mit MultiMC.exe)',
properties: ['openDirectory'],
});
@@ -2050,15 +2354,14 @@ ipcMain.handle('mmc:import', (_e, entries, copyData) => {
done.push(imported);
}
catch (err) { failed.push({ name: entry.name, message: err.message }); }
- if (mainWindow && !mainWindow.isDestroyed()) {
- mainWindow.webContents.send('mmc:progress', { current: done.length + failed.length, total: entries.length });
- }
+ broadcast('mmc:progress', { current: done.length + failed.length, total: entries.length });
}
+ if (done.length) broadcast('instances:changed');
return { ok: true, imported: done, failed };
});
-ipcMain.handle('cf:pickFolder', async () => {
- const res = await dialog.showOpenDialog(mainWindow, {
+ipcMain.handle('cf:pickFolder', async (e) => {
+ const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
title: 'CurseForge-Ordner wählen (oder direkt den Instances-Ordner)',
properties: ['openDirectory'],
});
@@ -2079,15 +2382,14 @@ ipcMain.handle('cf:import', (_e, entries, copyData) => {
done.push(imported);
}
catch (err) { failed.push({ name: entry.name, message: err.message }); }
- if (mainWindow && !mainWindow.isDestroyed()) {
- mainWindow.webContents.send('cf:progress', { current: done.length + failed.length, total: entries.length });
- }
+ broadcast('cf:progress', { current: done.length + failed.length, total: entries.length });
}
+ if (done.length) broadcast('instances:changed');
return { ok: true, imported: done, failed };
});
-ipcMain.handle('mc:pickFolder', async () => {
- const res = await dialog.showOpenDialog(mainWindow, {
+ipcMain.handle('mc:pickFolder', async (e) => {
+ const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
title: 'Minecraft-Launcher-Ordner wählen (meist .minecraft)',
properties: ['openDirectory'],
});
@@ -2108,15 +2410,14 @@ ipcMain.handle('mc:import', (_e, entries, copyData) => {
done.push(imported);
}
catch (err) { failed.push({ name: entry.name, message: err.message }); }
- if (mainWindow && !mainWindow.isDestroyed()) {
- mainWindow.webContents.send('mc:progress', { current: done.length + failed.length, total: entries.length });
- }
+ broadcast('mc:progress', { current: done.length + failed.length, total: entries.length });
}
+ if (done.length) broadcast('instances:changed');
return { ok: true, imported: done, failed };
});
-ipcMain.handle('technic:pickFolder', async () => {
- const res = await dialog.showOpenDialog(mainWindow, {
+ipcMain.handle('technic:pickFolder', async (e) => {
+ const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
title: 'Technic-Ordner wählen (oder direkt den modpacks-Ordner)',
properties: ['openDirectory'],
});
@@ -2137,10 +2438,9 @@ ipcMain.handle('technic:import', (_e, entries, copyData) => {
done.push(imported);
}
catch (err) { failed.push({ name: entry.name, message: err.message }); }
- if (mainWindow && !mainWindow.isDestroyed()) {
- mainWindow.webContents.send('technic:progress', { current: done.length + failed.length, total: entries.length });
- }
+ broadcast('technic:progress', { current: done.length + failed.length, total: entries.length });
}
+ if (done.length) broadcast('instances:changed');
return { ok: true, imported: done, failed };
});
@@ -2154,9 +2454,12 @@ ipcMain.handle('technicPlatform:search', async (_e, query) => {
ipcMain.handle('technicPlatform:install', async (_e, slug) => {
try {
const res = await technicdiscover.installFromPlatform(store, slug, (p) => {
- if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('technicPlatform:progress', p);
+ broadcast('technicPlatform:progress', p);
});
- if (res.ok && res.instance) notify('Modpack installiert', `${res.instance.name} ist einsatzbereit.`);
+ if (res.ok && res.instance) {
+ broadcast('instances:changed');
+ notify('Modpack installiert', `${res.instance.name} ist einsatzbereit.`);
+ }
return res;
} catch (err) {
return { ok: false, message: err.message };
@@ -2179,8 +2482,8 @@ ipcMain.handle('bedrock:openFolder', (_e, kind) => {
return true;
});
-ipcMain.handle('bedrock:installAddon', async () => {
- const res = await dialog.showOpenDialog(mainWindow, {
+ipcMain.handle('bedrock:installAddon', async (e) => {
+ const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
title: 'Add-on oder Welt importieren',
properties: ['openFile'],
filters: [{ name: 'Bedrock-Pakete', extensions: ['mcpack', 'mcaddon', 'mcworld', 'mctemplate'] }],
@@ -2229,15 +2532,18 @@ ipcMain.handle('icons:extract', () => {
// ---------------------------------------------------------------------------
ipcMain.handle('update:check', async () => {
- try { return await updater.checkForUpdate(app.getVersion()); }
- catch (err) { return { available: false, error: err.message }; }
+ try {
+ const info = await updater.checkForUpdate(app.getVersion());
+ pendingUpdateInfo = info;
+ return info;
+ } catch (err) { return { available: false, error: err.message }; }
});
// Ein Klick: herunterladen, prüfen, Update anwenden und Launcher beenden
ipcMain.handle('update:install', async (_e, asset) => {
try {
const file = await updater.downloadAndInstall(asset, (p) => {
- if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('update:progress', p);
+ broadcast('update:progress', p);
});
const res = updater.applyUpdate(file);
// Portable: der Tausch klappt erst, wenn der Launcher die EXE freigibt
diff --git a/src/pcm-worklet.js b/src/pcm-worklet.js
new file mode 100644
index 0000000..0c4a3c6
--- /dev/null
+++ b/src/pcm-worklet.js
@@ -0,0 +1,50 @@
+// AudioWorklet-Prozessor für die isolierte "Nur Minecraft"-Tonaufnahme.
+//
+// loopback-capture liefert PCM-Häppchen unregelmäßig getaktet über IPC (mal
+// etwas früher, mal etwas später). Würde man sie direkt nacheinander als
+// einzelne AudioBufferSourceNode-Aufrufe abspielen, würde jede kleine
+// Verzögerung als hörbares Knacksen/Aussetzer auffallen. Stattdessen landen
+// ankommende Häppchen hier in einem Ringpuffer; der Audio-Rendering-Thread
+// liest in gleichmäßigem Takt (process() läuft alle 128 Samples) daraus.
+// Kleine Zeitschwankungen bei der Anlieferung werden so unhörbar
+// abgefedert. Läuft der Puffer doch mal leer, gibt es kurz Stille statt
+// eines Knacksens.
+class PcmRingBufferProcessor extends AudioWorkletProcessor {
+ constructor() {
+ super();
+ this.capacity = sampleRate * 2; // 2 Sekunden Puffer je Kanal
+ this.ring = [new Float32Array(this.capacity), new Float32Array(this.capacity)];
+ this.writeIdx = 0;
+ this.readIdx = 0;
+ this.available = 0; // gepufferte Frames (nicht Samples)
+ this.port.onmessage = (e) => this._enqueue(e.data);
+ }
+
+ _enqueue([left, right]) {
+ const n = left.length;
+ for (let i = 0; i < n; i++) {
+ const idx = (this.writeIdx + i) % this.capacity;
+ this.ring[0][idx] = left[i];
+ this.ring[1][idx] = right[i];
+ }
+ this.writeIdx = (this.writeIdx + n) % this.capacity;
+ this.available = Math.min(this.capacity, this.available + n);
+ }
+
+ process(_inputs, outputs) {
+ const output = outputs[0];
+ const frames = output[0].length;
+ const playable = Math.min(frames, this.available);
+ for (let i = 0; i < playable; i++) {
+ const idx = (this.readIdx + i) % this.capacity;
+ output[0][i] = this.ring[0][idx];
+ output[1][i] = this.ring[1][idx];
+ }
+ for (let i = playable; i < frames; i++) { output[0][i] = 0; output[1][i] = 0; }
+ this.readIdx = (this.readIdx + playable) % this.capacity;
+ this.available -= playable;
+ return true; // Prozessor am Leben halten, auch wenn gerade nichts ankommt
+ }
+}
+
+registerProcessor('pcm-player', PcmRingBufferProcessor);
diff --git a/src/preload.js b/src/preload.js
index aded893..5b65b2f 100644
--- a/src/preload.js
+++ b/src/preload.js
@@ -7,6 +7,15 @@
const { contextBridge, ipcRenderer } = require('electron');
+// Debug-Hilfe: JS-Fehler im Renderer landen im Terminal des Hauptprozesses,
+// damit man sie auch ohne offene DevTools sieht
+window.addEventListener('error', (e) => {
+ ipcRenderer.send('debug:rendererError', (e.error && e.error.stack) || e.message);
+});
+window.addEventListener('unhandledrejection', (e) => {
+ ipcRenderer.send('debug:rendererError', 'unhandledrejection: ' + ((e.reason && e.reason.stack) || e.reason));
+});
+
contextBridge.exposeInMainWorld('api', {
// Einstellungen
getSettings: () => ipcRenderer.invoke('settings:get'),
@@ -241,4 +250,57 @@ contextBridge.exposeInMainWorld('api', {
pickJava: () => ipcRenderer.invoke('dialog:pickJava'),
appInfo: () => ipcRenderer.invoke('app:info'),
appLicense: () => ipcRenderer.invoke('app:license'),
+
+ // Einstellungen/Konsole als eigenes, frei verschiebbares Fenster
+ popoutOpen: (modalId, params) => ipcRenderer.send('popout:open', modalId, params),
+ popoutHide: () => ipcRenderer.send('popout:hide'),
+ onPopoutReopen: (cb) => {
+ const l = (_e, params) => cb(params);
+ ipcRenderer.on('popout:reopen', l);
+ return () => ipcRenderer.removeListener('popout:reopen', l);
+ },
+ onSettingsChanged: (cb) => {
+ const l = (_e, s) => cb(s);
+ ipcRenderer.on('settings:changed', l);
+ return () => ipcRenderer.removeListener('settings:changed', l);
+ },
+ onInstancesChanged: (cb) => {
+ const l = () => cb();
+ ipcRenderer.on('instances:changed', l);
+ return () => ipcRenderer.removeListener('instances:changed', l);
+ },
+ onSkinChanged: (cb) => {
+ const l = () => cb();
+ ipcRenderer.on('skin:changed', l);
+ return () => ipcRenderer.removeListener('skin:changed', l);
+ },
+ updateGetPending: () => ipcRenderer.invoke('update:getPending'),
+
+ // Spielaufzeichnung
+ recordingListWindows: () => ipcRenderer.invoke('recording:listWindows'),
+ recordingGetAudioSourceId: () => ipcRenderer.invoke('recording:getAudioSourceId'),
+ recordingSave: (instanceName, buffer, serverLabel) => ipcRenderer.invoke('recording:save', instanceName, buffer, serverLabel),
+ recordingOpenFolder: (instanceName) => ipcRenderer.invoke('recording:openFolder', instanceName),
+ recordingGetServer: (instanceId) => ipcRenderer.invoke('recording:getServer', instanceId),
+ onRecordingServerDetected: (cb) => {
+ const l = (_e, payload) => cb(payload);
+ ipcRenderer.on('recording:serverDetected', l);
+ return () => ipcRenderer.removeListener('recording:serverDetected', l);
+ },
+ recordingGetPid: (instanceId) => ipcRenderer.invoke('recording:getPid', instanceId),
+ recordingSetCaptureTarget: (sourceId) => ipcRenderer.invoke('recording:setCaptureTarget', sourceId),
+ recordingGetWindowBounds: (instanceId, fensterTitel) => ipcRenderer.invoke('recording:getWindowBounds', instanceId, fensterTitel),
+ recordingStartGameAudio: (pid) => ipcRenderer.invoke('recording:startGameAudio', pid),
+ recordingStopGameAudio: () => ipcRenderer.invoke('recording:stopGameAudio'),
+ onGameAudioChunk: (cb) => {
+ const l = (_e, chunk) => cb(chunk);
+ ipcRenderer.on('recording:gameAudioChunk', l);
+ return () => ipcRenderer.removeListener('recording:gameAudioChunk', l);
+ },
+ recordingSetGuard: (active) => ipcRenderer.send('recording:guard', active),
+ onRecordCloseAttempt: (cb) => {
+ const l = () => cb();
+ ipcRenderer.on('record:closeAttempt', l);
+ return () => ipcRenderer.removeListener('record:closeAttempt', l);
+ },
});
diff --git a/src/renderer.js b/src/renderer.js
index 7624062..00456d0 100644
--- a/src/renderer.js
+++ b/src/renderer.js
@@ -496,6 +496,12 @@ const collapsedGroups = new Set(); // eingeklappte Gruppen
let consoleBuffer = ''; // gesammelte Log-Ausgabe (roh, für Kopieren)
let consolePending = ''; // noch nicht abgeschlossene Zeile
+// ---------- Popup-Fenster ----------
+// In einem eigenen Popup-Fenster (aktuell nur Einstellungen) steht in der URL
+// "?popout=settings" – dann blendet die Oberfläche die Haupt-UI aus und zeigt
+// nur dieses eine Modal vollflächig (siehe styles.css ".is-popout"/".popout-host").
+const POPOUT = new URLSearchParams(location.search).get('popout');
+
// ---------- DOM-Helfer ----------
const el = (id) => document.getElementById(id);
function show(id) { el(id).classList.remove('hidden'); }
@@ -568,6 +574,7 @@ function setImportKind(kind) {
}
function openImportDialog(kind = 'zip') {
+ if (POPOUT !== 'import') { window.api.popoutOpen('import', { kind }); return; }
mcFound = [];
el('mc-result').classList.add('hidden');
el('mc-do').disabled = true;
@@ -1102,6 +1109,7 @@ async function applyBedrockVisibility() {
}
async function openBedrock() {
+ if (POPOUT !== 'bedrock') { window.api.popoutOpen('bedrock'); return; }
show('modal-bedrock');
el('bd-sub').textContent = 'Wird geprüft …';
const info = await window.api.bedrockDetect();
@@ -1214,6 +1222,8 @@ let pendingUpdate = null;
const CHANNEL_LABEL = { portable: 'Portable', setup: 'Setup' };
function showUpdateDialog(info) {
+ if (POPOUT !== 'update') { window.api.popoutOpen('update'); return; }
+ if (!info) return; // Popup geöffnet, aber (aktuell) kein Update-Ergebnis vorhanden
pendingUpdate = info;
const channel = CHANNEL_LABEL[info.installKind] || null;
el('upd-current').textContent = formatVersionLabel(info.currentVersion);
@@ -1923,6 +1933,7 @@ function resetIconControls() {
}
async function openCreate() {
+ if (POPOUT !== 'instance') { window.api.popoutOpen('instance'); return; }
editingId = null;
el('instance-modal-title').textContent = 'Neue Instanz';
el('f-name').value = '';
@@ -1945,6 +1956,7 @@ async function openCreate() {
}
async function openEdit(inst) {
+ if (POPOUT !== 'instance') { window.api.popoutOpen('instance', { instanceId: inst.id }); return; }
editingId = inst.id;
el('instance-modal-title').textContent = 'Instanz bearbeiten';
el('f-name').value = inst.name;
@@ -2163,6 +2175,7 @@ let zuUebertragenderServer = null;
async function openServers() {
if (!currentDetailId) return;
+ if (POPOUT !== 'servers') { window.api.popoutOpen('servers', { instanceId: currentDetailId }); return; }
const inst = await window.api.getInstance(currentDetailId);
el('servers-title').textContent = 'Server – ' + (inst ? inst.name : '');
el('srv-name').value = '';
@@ -2333,60 +2346,92 @@ async function doServerCopy() {
let gewaehlterSkin = null;
-/*
- * Bild nur zeigen, wenn es wirklich geladen wurde – ein ohne Quelle
- * stellt sonst das Platzhalter-Symbol des Browsers dar.
- */
-function zeigeSkinBild(bildId, leerId, quelle, leerText) {
- const bild = el(bildId);
- const leer = el(leerId);
- leer.textContent = leerText;
- if (!quelle) {
- bild.classList.add('hidden');
- bild.removeAttribute('src');
- leer.classList.remove('hidden');
- return;
- }
- bild.onload = () => { bild.classList.remove('hidden'); leer.classList.add('hidden'); };
- bild.onerror = () => {
- bild.classList.add('hidden');
- bild.removeAttribute('src');
- leer.textContent = 'Vorschau nicht ladbar';
- leer.classList.remove('hidden');
- };
- bild.src = quelle;
-}
-
// Textur einer Seite merken, damit bei einem Wechsel der Armform beide
// Figuren neu gerendert werden können
let skinTexturAktuell = '';
let skinTexturNeu = '';
-// Skin-Textur als Figur rendern; klappt das nicht, wird die Textur gezeigt
+// laufende Viewer je Canvas -- vor einem Neuaufbau erst abräumen, sonst
+// stapeln sich bei jedem Wechsel (Datei, Armform, ...) weitere WebGL-Kontexte
+const skinViewers = {};
+function schliesseSkinViewer(bildId) {
+ if (skinViewers[bildId]) {
+ try { skinViewers[bildId].dispose(); } catch { /* egal */ }
+ delete skinViewers[bildId];
+ }
+}
+
+/*
+ * Skin-Textur als frei mit der Maus drehbare 3D-Figur zeigen (Ziehen dreht,
+ * Mausrad zoomt -- kommt von skinview3d selbst). Klappt das Rendern nicht,
+ * wird stattdessen ein Hinweistext gezeigt.
+ */
async function zeigeSkinFigur(bildId, leerId, textur, leerText) {
- if (!textur) { zeigeSkinBild(bildId, leerId, '', leerText); return; }
- el(leerId).textContent = 'wird gerendert …';
+ schliesseSkinViewer(bildId);
+ const canvas = el(bildId);
+ const leer = el(leerId);
+ canvas.classList.add('hidden');
+ if (!textur) { leer.textContent = leerText; leer.classList.remove('hidden'); return; }
+ if (typeof skinview3d === 'undefined') {
+ leer.textContent = 'Vorschau nicht verfügbar';
+ leer.classList.remove('hidden');
+ return;
+ }
+ leer.textContent = 'wird gerendert …';
+ leer.classList.remove('hidden');
const modell = el('skin-variante').value === 'slim' ? 'slim' : 'default';
- const figur = await renderSkin3D(textur, 'standing', { modell, breite: 260, hoehe: 420 });
- zeigeSkinBild(bildId, leerId, figur || textur, leerText);
+ try {
+ const viewer = new skinview3d.SkinViewer({
+ canvas,
+ width: canvas.clientWidth || 260,
+ height: canvas.clientHeight || 288,
+ skin: textur,
+ model: modell,
+ });
+ // Auf das Laden der Skin-Textur warten, bevor Pose/Kamera gesetzt werden
+ await new Promise((resolve, reject) => {
+ const t = setTimeout(reject, 8000);
+ const check = () => {
+ if (viewer.playerObject && viewer.playerObject.skin && viewer.playerObject.skin.map) { clearTimeout(t); resolve(); }
+ else setTimeout(check, 60);
+ };
+ check();
+ });
+ const pose = SKIN_POSES.standing;
+ viewer.fov = pose.fov;
+ viewer.zoom = pose.zoom;
+ viewer.autoRotate = false;
+ viewer.animation = null;
+ pose.apply(viewer.playerObject);
+ viewer.controls.enableRotate = true;
+ viewer.controls.enableZoom = true;
+ viewer.controls.enablePan = false;
+ skinViewers[bildId] = viewer;
+ canvas.classList.remove('hidden');
+ leer.classList.add('hidden');
+ } catch {
+ leer.textContent = 'Vorschau nicht ladbar';
+ leer.classList.remove('hidden');
+ }
}
async function openSkin() {
+ if (POPOUT !== 'skin') { window.api.popoutOpen('skin'); return; }
gewaehlterSkin = null;
skinTexturAktuell = '';
skinTexturNeu = '';
el('skin-pfad').value = '';
el('skin-apply').disabled = true;
el('skin-status').textContent = '';
- zeigeSkinBild('skin-neu', 'skin-neu-leer', '', 'noch keine Datei gewählt');
- zeigeSkinBild('skin-aktuell', 'skin-aktuell-leer', '', 'wird geladen …');
+ await zeigeSkinFigur('skin-neu', 'skin-neu-leer', '', 'noch keine Datei gewählt');
+ await zeigeSkinFigur('skin-aktuell', 'skin-aktuell-leer', '', 'wird geladen …');
el('skin-konto').textContent = 'Profil wird geladen …';
show('modal-skin');
const p = await window.api.skinProfile();
if (!p.ok) {
el('skin-konto').textContent = p.message || 'Profil konnte nicht geladen werden.';
- zeigeSkinBild('skin-aktuell', 'skin-aktuell-leer', '', 'kein Profil geladen');
+ await zeigeSkinFigur('skin-aktuell', 'skin-aktuell-leer', '', 'kein Profil geladen');
return;
}
el('skin-konto').textContent = `Angemeldet als ${p.name}. Die Änderung gilt für dieses Konto.`;
@@ -2454,6 +2499,7 @@ let aktuellesLog = '';
async function openLogs() {
if (!currentDetailId) return;
+ if (POPOUT !== 'logs') { window.api.popoutOpen('logs', { instanceId: currentDetailId }); return; }
const inst = await window.api.getInstance(currentDetailId);
el('logs-title').textContent = 'Logdateien – ' + (inst ? inst.name : '');
const auswahl = el('logs-select');
@@ -2507,6 +2553,7 @@ let aktuellerCrash = null;
async function zeigeAbsturzbericht(instanzId) {
const id = instanzId || currentDetailId;
if (!id) return;
+ if (POPOUT !== 'crash') { window.api.popoutOpen('crash', { instanceId: id }); return; }
// Minecraft schreibt den Bericht erst kurz nach dem Ende
await new Promise((r) => setTimeout(r, 900));
@@ -2538,7 +2585,10 @@ async function zeigeAbsturzbericht(instanzId) {
// Fortschrittsmeldungen der Download-/Start-Pipeline
function onLaunchEvt(evt) {
const labels = { version: 'Version', client: 'Client', libraries: 'Libraries', assets: 'Assets', loader: 'Loader', java: 'Java', launch: 'Start', modpack: 'Modpack', backup: 'Sicherung' };
- if (evt.phase === 'log') { appendConsole(evt.line); return; }
+ if (evt.phase === 'log') {
+ appendConsole(evt.line);
+ return;
+ }
if (evt.phase === 'exit') {
status('Minecraft beendet (Code ' + evt.code + ')');
el('status-right').textContent = '';
@@ -2554,13 +2604,17 @@ function onLaunchEvt(evt) {
+ 'internen Shutdown-Watchdog hart terminiert (z. B. durch einen Mod, dessen Threads '
+ 'nicht rechtzeitig beenden – etwa JourneyMap WebMap). Kein Absturz während des Spiels.\n');
}
- // Absturzbericht suchen, bevor über die Konsole entschieden wird –
- // ein Bericht sagt mehr als der Java-Auswurf
- if (crashed) zeigeAbsturzbericht(evt.id);
- window.api.getSettings().then((s) => {
- if (crashed && s.showConsoleOnCrash !== false) { openConsole(); return; }
- if (s.autoCloseConsole) hide('modal-console');
- }).catch(() => {});
+ // Absturzbericht suchen, bevor über die Konsole entschieden wird – ein
+ // Bericht sagt mehr als der Java-Auswurf. Das entscheidet nur das
+ // Hauptfenster – sonst würde z. B. das Konsolen-Popup-Fenster bei einem
+ // Absturz zusätzlich einen eigenen Absturzbericht aufmachen.
+ if (!POPOUT) {
+ if (crashed) zeigeAbsturzbericht(evt.id);
+ window.api.getSettings().then((s) => {
+ if (crashed && s.showConsoleOnCrash !== false) { openConsole(); return; }
+ if (s.autoCloseConsole) hide('modal-console');
+ }).catch(() => {});
+ }
return;
}
// Startzeitpunkt merken: nur Berichte ab hier gehören zu diesem Lauf
@@ -2603,6 +2657,7 @@ function appendConsole(text) {
}
}
function openConsole() {
+ if (POPOUT !== 'console') { window.api.popoutOpen('console'); return; }
const out = el('console-out');
out.innerHTML = '';
const lines = (consoleBuffer || '(noch keine Ausgabe)').split('\n');
@@ -2614,6 +2669,7 @@ function openConsole() {
// ---------- Welten & Ressourcenpakete ----------
async function openContent() {
+ if (POPOUT !== 'content') { window.api.popoutOpen('content', { instanceId: currentDetailId }); return; }
const inst = await window.api.getInstance(currentDetailId);
el('content-title').textContent = 'Welten, Ressourcen- & Datenpakete – ' + inst.name;
await renderContentLists();
@@ -2645,7 +2701,637 @@ function updateScreensSelectionUi() {
});
}
+// ---------- Spielaufzeichnung ----------
+let mcVideoStream = null;
+let mcSearchTimer = null;
+let mcSearchElapsed = 0;
+let mcCurrentSource = null; // zuletzt erfasste Fensterquelle - für ein Neu-Erfassen bei Auflösungswechsel
+
+// Auflösung/Qualität/Bildrate der Aufnahme (bleiben über mehrere Aufnahmen in
+// diesem Fenster erhalten, wie schon bei den Audioeinstellungen)
+let mcResolution = 'native';
+let mcQuality = 'medium';
+let mcFramerate = 30;
+let mcActualSize = null; // {width,height} der zuletzt tatsächlich erfassten Auflösung
+let mcMicSelectedIds = []; // gespeicherte Mikrofon-Auswahl (deviceIds) - für den Aufbau der Mikrofonliste
+let mcCursorAusgeblendet = false; // true, wenn die zuletzt erfasste Vorschau ohne Mauszeiger läuft
+let mcRohStream = null; // native, unskalierte Bildschirm-Erfassung, falls mcVideoStream ein daraus erzeugter Canvas-Stream ist
+let mcCanvasRAF = null; // requestAnimationFrame-Kennung der Zuschnitt-/Skalierungs-Zeichenschleife
+let mcCanvasVideoEl = null; // verstecktes