Upload via GUI (90 Dateien)
This commit is contained in:
+109
@@ -0,0 +1,109 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
Alle nennenswerten Änderungen an AeroMC werden hier festgehalten.
|
||||||
|
|
||||||
|
## [1.1.4] - 2026-10-02
|
||||||
|
|
||||||
|
### Neu
|
||||||
|
- Neuer Installer im modernen Look: Schritt-für-Schritt-Assistent mit Lizenz, Ordnerwahl und Verknüpfungen, eigene Deinstallation (Instanzen und Welten bleiben auf Wunsch erhalten) – eine vorhandene Installation wird automatisch übernommen
|
||||||
|
- Optionaler Import im Installer: erkennt Minecraft Launcher, CurseForge, MultiMC, Prism Launcher, PolyMC und Technic und bietet deren Instanzen beim ersten Start zum Übernehmen an
|
||||||
|
- Updates aus dem Launcher heraus laufen jetzt ohne Rückfragen durch und starten AeroMC danach von selbst neu
|
||||||
|
- Freunde: „Beitreten“ startet eine passende Instanz und verbindet direkt mit dem Server, auf dem ein Freund spielt – gibt es keine passende Instanz, wird eine angelegt
|
||||||
|
- Freunde: „Einladen“ schickt einem Freund deinen aktuellen Server – er bekommt die Einladung im Launcher und als Meldung im Spiel (Strg+Alt+J kopiert dort die Adresse)
|
||||||
|
- Freunde: Der eigene Server lässt sich in den Einstellungen vor Freunden verbergen
|
||||||
|
- Clip-Modus in der Spielaufzeichnung: läuft im Hintergrund mit, Alt+F10 speichert die letzten 30, 60 oder 90 Sekunden als MP4
|
||||||
|
- Performance-Paket beim Anlegen einer Fabric-, Quilt- oder NeoForge-Instanz: Sodium, Lithium, FerriteCore, ImmediatelyFast, Entity Culling und ModernFix per Häkchen mitinstallieren
|
||||||
|
- Automatische Mod-Updates vor dem Spielstart (Optionen → Spiel → Mods)
|
||||||
|
- Spielzeit-Statistik: Gesamtspielzeit, Spielstarts, meistgespielte Instanz und Spielzeit pro Instanz auf einen Blick
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
- Die Suche nach Updates startet jetzt sofort beim Öffnen des Launchers, die Info-Seite zeigt das Ergebnis direkt an
|
||||||
|
- Direkt einem Server beitreten funktioniert jetzt auch bei Minecraft-Versionen vor 1.20
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
- Die Info-Seite zeigte „Kein Update-Check ausgeführt“, obwohl der Launcher beim Start bereits gesucht hatte
|
||||||
|
|
||||||
|
## [1.1.3] - 2026-09-29
|
||||||
|
|
||||||
|
### Neu
|
||||||
|
- Freundesystem: Freunde per Minecraft-Name hinzufügen, Anfragen annehmen/ablehnen, gesendete Anfragen zurückziehen, Spitznamen vergeben – mit 3D-Spielerkopf und Online-Status
|
||||||
|
- In-Game-Meldung mit 3D-Spielerkopf, sobald ein Freund online kommt – bei mehreren gleichzeitig laufenden Konten erscheint sie im jeweils richtigen Spielfenster
|
||||||
|
- Tastenkombination Strg+Alt+F zeigt im Spiel an, welche Freunde gerade online sind (inklusive Server)
|
||||||
|
- Zähler für offene Freundschaftsanfragen am "Freunde"-Knopf
|
||||||
|
- Freundesystem in den Einstellungen abschaltbar – man ist dann weder sichtbar noch als Freund hinzufügbar, der "Freunde"-Knopf wird ausgeblendet
|
||||||
|
- Neuer Reiter "Aufzeichnungen" im Screenshot-Fenster mit Vorschaubildern, Abspielen und Löschen
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
- Aufzeichnungen werden jetzt als MP4 gespeichert und laufen damit in jedem gängigen Player
|
||||||
|
- Der Mauszeiger ist in Aufzeichnungen nicht mehr zu sehen
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
- Aufzeichnungen ruckelten bzw. hatten eine falsche Bildrate
|
||||||
|
- Wechsel des aktiven Microsoft-Kontos wurde nicht in allen Fenstern übernommen
|
||||||
|
|
||||||
|
## [1.1.2] - 2026-09-27
|
||||||
|
|
||||||
|
### Neu
|
||||||
|
- Auflösung, Qualität und Bildrate der Spielaufzeichnung einstellbar (Nativ/1080p/720p/480p, Niedrig/Mittel/Hoch, 30/60 fps) – inklusive Anzeige der tatsächlich aufgenommenen Pixelgröße
|
||||||
|
- Lautstärke jeder einzelnen Tonspur der Aufzeichnung (Systemton, Nur Minecraft, jedes Mikrofon einzeln) regelbar, auch während eine Aufnahme bereits läuft
|
||||||
|
- Video- und Audioeinstellungen der Spielaufzeichnung bleiben jetzt über einen Neustart des Launchers hinweg erhalten
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
- Anzeige "Aufnahme läuft" samt Timer sitzt jetzt über dem Start/Stopp-Knopf statt auf dem Videobild
|
||||||
|
- Mikrofonnamen in der Audioquellen-Auswahl zeigen keine technische USB-Kennung mehr (z. B. "(9886:0038)")
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
- Servername in der Spielaufzeichnung fehlte, wenn man sich schon vor dem Öffnen des Aufzeichnungsfensters mit dem Server verbunden hatte – wird jetzt zentral erkannt und trägt sich auch nachträglich in den Titel ein
|
||||||
|
- Vorschaubild der Aufzeichnung blieb beim ersten Öffnen manchmal schwarz – wird jetzt automatisch erkannt und neu erfasst
|
||||||
|
|
||||||
|
## [1.0.9] - 2026-08-09
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
- Update-Installation brach mit "EPERM: operation not permitted, lstat …" ab, wenn vom vorherigen Versuch noch eine gesperrte Installer-Datei im Temp-Ordner lag (z. B. durch einen Virenscanner) – der Download weicht jetzt automatisch auf einen neuen Dateinamen aus
|
||||||
|
- Autostart mit Windows öffnete im Entwicklungsmodus ein leeres Electron-Fenster statt AeroMC (gleiche Ursache wie zuvor bei den Desktop-Verknüpfungen), zusätzlich zeigte der Autostart-Eintrag bei der Portable-Fassung potenziell auf einen flüchtigen Temp-Pfad
|
||||||
|
- Wiederherstellen aus einem ungültigen oder beschädigten Backup-Archiv löschte die aktuellen, unveränderten Instanzen ersatzlos, obwohl der Vorgang mit einer Fehlermeldung abgebrochen wurde – der Rollback greift jetzt nur noch, wenn am Originalbestand überhaupt schon etwas verändert wurde
|
||||||
|
|
||||||
|
## [1.0.8] - 2026-08-08
|
||||||
|
|
||||||
|
### Neu
|
||||||
|
- Datenpakete lassen sich jetzt wie Mods, Ressourcenpakete und Shader direkt aus dem Modrinth-Katalog durchsuchen, installieren, aktivieren/deaktivieren und aktualisieren
|
||||||
|
- Modpacks lassen sich jetzt auch aus dem CurseForge-Katalog durchsuchen und mit einem Klick installieren (bisher nur Modrinth und Technic; CurseForge ging nur per manuellem ZIP-Import) – braucht wie die bestehende CurseForge-Suche einen eigenen API-Key
|
||||||
|
- OptiFine als eigenständige Loader-Option: Installer-Datei auswählen, AeroMC bereitet die passende Minecraft-Version vor und startet den offiziellen OptiFine-Installer sichtbar; nach Abschluss wird die neue Version automatisch übernommen
|
||||||
|
- Von Hand hinzugefügte OptiFine-Dateien im Mods-Dialog werden jetzt erkannt und eigens gekennzeichnet (funktioniert bei jedem Loader, u. a. für Fabric + OptiFabric)
|
||||||
|
- Lizenztext direkt in den Einstellungen unter Info einsehbar
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
- Lizenzangabe von MIT auf die tatsächlich geltenden Nutzungsbedingungen umgestellt (nur privater, nicht-kommerzieller Gebrauch – siehe LICENSE)
|
||||||
|
|
||||||
|
## [1.0.7] - 2026-08-06
|
||||||
|
|
||||||
|
### Neu
|
||||||
|
- Desktop-Verknüpfung pro Server: startet die Instanz und verbindet direkt mit dem hinterlegten Server (Quick Play, ab MC 1.20)
|
||||||
|
- Name der Desktop-Verknüpfung (Instanz oder Server) lässt sich beim Erstellen frei selbst vergeben
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
- Desktop-Verknüpfungen zeigten im Entwicklungsmodus einen leeren Electron-Startbildschirm statt AeroMC zu öffnen
|
||||||
|
- Desktop-Verknüpfungen zeigten im Entwicklungsmodus das Electron-Symbol statt des AeroMC-Logos
|
||||||
|
- Desktop-Verknüpfungen (Instanz und Server) starteten bei kaltem Start unzuverlässig gar keine Instanz – der Startwunsch traf manchmal ein, bevor die Oberfläche bereit war, ihn zu empfangen, und ging dann verloren
|
||||||
|
|
||||||
|
## [1.0.6] - 2026-08-05
|
||||||
|
|
||||||
|
### Neu
|
||||||
|
- Technic-Modpacks direkt durchsuchen und installieren (Katalog), oder eine vorhandene Technic-Installation importieren
|
||||||
|
- Mods lassen sich jetzt auch als eigene Datei hinzufügen, nicht nur über die Suche
|
||||||
|
- Dateien (Mods, Modpacks, Instanzen) per Drag & Drop aufs Fenster ziehen
|
||||||
|
- Tastenkürzel: Strg+F (Suche), Strg+N (neue Instanz), Entf (Instanz löschen)
|
||||||
|
- Windows-Benachrichtigung, wenn ein großer Download oder ein Backup im Hintergrund fertig wird
|
||||||
|
- Serverliste zeigt jetzt Live-Status (Spieleranzahl, MOTD, Version)
|
||||||
|
- Ein-Klick-Knopf für empfohlene GC-Flags bei den JVM-Argumenten
|
||||||
|
- Einzelne Welten lassen sich separat schnellsichern
|
||||||
|
- Warnung, wenn ein Mod entfernt wird, den ein anderer installierter Mod noch braucht
|
||||||
|
- Skin-Hintergrund kann automatisch zwischen mehreren angemeldeten Konten durchwechseln
|
||||||
|
- Optionaler Autostart mit Windows (im Infobereich statt als Fenster)
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
- Update-Suche läuft automatisch beim Start, Anzeige wird grün wenn alles aktuell ist
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
- Bedrock-Start repariert (ging nach einem Windows-Update nicht mehr) und startete zwischenzeitlich doppelt
|
||||||
|
- Absturz beim Schließen des Spiels durch hängende Mod-Threads (z. B. JourneyMap-Webmap) behoben
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "aeromc",
|
"name": "aeromc",
|
||||||
"version": "1.1.3",
|
"version": "1.1.4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "aeromc",
|
"name": "aeromc",
|
||||||
"version": "1.1.3",
|
"version": "1.1.4",
|
||||||
"license": "SEE LICENSE IN LICENSE",
|
"license": "SEE LICENSE IN LICENSE",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@xhayper/discord-rpc": "^1.3.4",
|
"@xhayper/discord-rpc": "^1.3.4",
|
||||||
|
|||||||
+3
-7
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "aeromc",
|
"name": "aeromc",
|
||||||
"productName": "AeroMC",
|
"productName": "AeroMC",
|
||||||
"version": "1.1.3",
|
"version": "1.1.4",
|
||||||
"description": "AeroMC – Instanz-basierter Minecraft-Launcher (à la MultiMC/Prism)",
|
"description": "AeroMC – Instanz-basierter Minecraft-Launcher (à la MultiMC/Prism)",
|
||||||
"homepage": "https://m-viper.de",
|
"homepage": "https://m-viper.de",
|
||||||
"launcherWebsite": "https://aeromc.viper.ipv64.net",
|
"launcherWebsite": "https://aeromc.viper.ipv64.net",
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
"start": "electron .",
|
"start": "electron .",
|
||||||
"prebuild": "node scripts/write-build-config.js",
|
"prebuild": "node scripts/write-build-config.js",
|
||||||
"prebuild:portable": "node scripts/write-build-config.js",
|
"prebuild:portable": "node scripts/write-build-config.js",
|
||||||
"build": "electron-builder --win nsis --x64 && node -e \"const fs=require('fs');const path=require('path');['dist/builder-debug.yml','dist/builder-effective-config.yaml','dist/win-unpacked'].forEach((target)=>fs.rmSync(target,{recursive:true,force:true}));if(fs.existsSync('dist')){const source=path.join('dist','AeroMC.exe');const target=path.join('dist','AeroMC-Setup.exe');if(fs.existsSync(target))fs.rmSync(target,{force:true});if(fs.existsSync(source))fs.renameSync(source,target);for(const name of fs.readdirSync('dist')){if((name.endsWith('.exe')&&name!=='AeroMC-Setup.exe'&&name!=='AeroMC-Portable.exe')||name.endsWith('.blockmap')){fs.rmSync(path.join('dist',name),{force:true})}}}\"",
|
"build": "electron-builder --win dir && node scripts/build-setup.js",
|
||||||
"build:portable": "electron-builder --win portable --x64 && node -e \"const fs=require('fs');const path=require('path');['dist/builder-debug.yml','dist/builder-effective-config.yaml','dist/win-unpacked'].forEach((target)=>fs.rmSync(target,{recursive:true,force:true}));if(fs.existsSync('dist')){const source=path.join('dist','AeroMC.exe');const target=path.join('dist','AeroMC-Portable.exe');if(fs.existsSync(target))fs.rmSync(target,{force:true});if(fs.existsSync(source))fs.renameSync(source,target);for(const name of fs.readdirSync('dist')){if((name.endsWith('.exe')&&name!=='AeroMC-Setup.exe'&&name!=='AeroMC-Portable.exe')||name.endsWith('.blockmap')){fs.rmSync(path.join('dist',name),{force:true})}}}\""
|
"build:portable": "electron-builder --win portable --x64 && node -e \"const fs=require('fs');const path=require('path');['dist/builder-debug.yml','dist/builder-effective-config.yaml','dist/win-unpacked'].forEach((target)=>fs.rmSync(target,{recursive:true,force:true}));if(fs.existsSync('dist')){const source=path.join('dist','AeroMC.exe');const target=path.join('dist','AeroMC-Portable.exe');if(fs.existsSync(target))fs.rmSync(target,{force:true});if(fs.existsSync(source))fs.renameSync(source,target);for(const name of fs.readdirSync('dist')){if((name.endsWith('.exe')&&name!=='AeroMC-Setup.exe'&&name!=='AeroMC-Portable.exe')||name.endsWith('.blockmap')){fs.rmSync(path.join('dist',name),{force:true})}}}\""
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
@@ -32,12 +32,8 @@
|
|||||||
"node_modules/ffmpeg-static/**/*"
|
"node_modules/ffmpeg-static/**/*"
|
||||||
],
|
],
|
||||||
"win": {
|
"win": {
|
||||||
"target": "nsis",
|
"target": "dir",
|
||||||
"icon": "src/assets/icon.ico"
|
"icon": "src/assets/icon.ico"
|
||||||
},
|
|
||||||
"nsis": {
|
|
||||||
"oneClick": false,
|
|
||||||
"allowToChangeInstallationDirectory": true
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
+17
-2
@@ -33,8 +33,8 @@ async function anfrage(pfad, { method = 'GET', body } = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function heartbeat({ uuid, username, instance, server }) {
|
function heartbeat({ uuid, username, instance, server, version }) {
|
||||||
return anfrage('/presence', { method: 'POST', body: { uuid, username, instance, server } });
|
return anfrage('/presence', { method: 'POST', body: { uuid, username, instance, server, version } });
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFriends(uuid) {
|
function getFriends(uuid) {
|
||||||
@@ -65,6 +65,21 @@ function removeFriend(uuid, otherUuid) {
|
|||||||
return anfrage('/friends/remove', { method: 'POST', body: { uuid, otherUuid } });
|
return anfrage('/friends/remove', { method: 'POST', body: { uuid, otherUuid } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Einladung "Komm auf meinen Server" (Adresse + Minecraft-Version des Einladenden)
|
||||||
|
function sendInvite(uuid, targetUuid, server, version) {
|
||||||
|
return anfrage('/friends/invite', { method: 'POST', body: { uuid, targetUuid, server, version } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// an mich gerichtete, noch gültige Einladungen
|
||||||
|
function getInvites(uuid) {
|
||||||
|
return anfrage('/friends/invites', { method: 'GET', body: { uuid } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismissInvite(uuid, id) {
|
||||||
|
return anfrage('/friends/invites/dismiss', { method: 'POST', body: { uuid, id } });
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
heartbeat, getFriends, getRequests, getSentRequests, sendRequest, respond, cancelRequest, removeFriend,
|
heartbeat, getFriends, getRequests, getSentRequests, sendRequest, respond, cancelRequest, removeFriend,
|
||||||
|
sendInvite, getInvites, dismissInvite,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
<button id="btn-bedrock" class="tbtn" data-ic="blocks" title="Minecraft Bedrock (Windows-Ausgabe)">Bedrock</button>
|
<button id="btn-bedrock" class="tbtn" data-ic="blocks" title="Minecraft Bedrock (Windows-Ausgabe)">Bedrock</button>
|
||||||
<button id="btn-console" class="tbtn" data-ic="terminal" title="Konsole / Log">Konsole</button>
|
<button id="btn-console" class="tbtn" data-ic="terminal" title="Konsole / Log">Konsole</button>
|
||||||
<button id="btn-skin" class="tbtn" data-ic="user" title="Skin-Hintergrund ein/aus">Skin</button>
|
<button id="btn-skin" class="tbtn" data-ic="user" title="Skin-Hintergrund ein/aus">Skin</button>
|
||||||
|
<button id="btn-stats" class="tbtn" data-ic="chart" title="Spielzeit-Statistik">Statistik</button>
|
||||||
<button id="btn-friends" class="tbtn" data-ic="users" title="Freunde (ingame: Strg+Alt+F zeigt, wer online ist)">Freunde<span id="friends-badge" class="tb-badge hidden"></span></button>
|
<button id="btn-friends" class="tbtn" data-ic="users" title="Freunde (ingame: Strg+Alt+F zeigt, wer online ist)">Freunde<span id="friends-badge" class="tb-badge hidden"></span></button>
|
||||||
<span class="tbgrow"></span>
|
<span class="tbgrow"></span>
|
||||||
<div class="search-wrap">
|
<div class="search-wrap">
|
||||||
@@ -142,6 +143,13 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- nur beim Anlegen einer Fabric-/Quilt-/NeoForge-Instanz -->
|
||||||
|
<label id="f-perf-row" class="checkbox-field hidden" style="margin:-2px 0 10px">
|
||||||
|
<input id="f-perf" type="checkbox" />
|
||||||
|
<span>Performance-Paket mitinstallieren
|
||||||
|
<small class="hint">Sodium, Lithium, FerriteCore, ImmediatelyFast, Entity Culling, ModernFix – für mehr FPS und kürzere Ladezeiten</small></span>
|
||||||
|
</label>
|
||||||
|
|
||||||
<div id="f-optifine-tools" class="field-row hidden" style="align-items:flex-start">
|
<div id="f-optifine-tools" class="field-row hidden" style="align-items:flex-start">
|
||||||
<div class="field" style="flex:1">
|
<div class="field" style="flex:1">
|
||||||
<span>OptiFine einrichten</span>
|
<span>OptiFine einrichten</span>
|
||||||
@@ -292,6 +300,10 @@
|
|||||||
<input id="s-friends" type="checkbox" /> <span>Freundesystem aktivieren</span>
|
<input id="s-friends" type="checkbox" /> <span>Freundesystem aktivieren</span>
|
||||||
</label>
|
</label>
|
||||||
<small class="hint">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.</small>
|
<small class="hint">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.</small>
|
||||||
|
<label class="checkbox-field" style="margin-top:10px">
|
||||||
|
<input id="s-friends-server" type="checkbox" /> <span>Server, auf dem ich spiele, für Freunde sichtbar</span>
|
||||||
|
</label>
|
||||||
|
<small class="hint">Ausgeschaltet sehen Freunde nur, dass du spielst – nicht wo. „Beitreten“ ist dann bei dir nicht möglich, Einladungen kannst du aber weiterhin verschicken.</small>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="settings-card">
|
<section class="settings-card">
|
||||||
@@ -385,6 +397,14 @@
|
|||||||
<label class="field"><span>Fensterhöhe</span><input id="s-mc-height" type="number" min="240" step="10" /></label>
|
<label class="field"><span>Fensterhöhe</span><input id="s-mc-height" type="number" min="240" step="10" /></label>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="settings-card">
|
||||||
|
<h5>Mods</h5>
|
||||||
|
<label class="checkbox-field">
|
||||||
|
<input id="s-auto-mod-updates" type="checkbox" /> <span>Vor jedem Start nach Mod-Updates suchen und installieren</span>
|
||||||
|
</label>
|
||||||
|
<small class="hint">Gilt für Mods aus Modrinth. Dauert die Suche länger als 20 Sekunden, startet Minecraft ohne Update. Abgeschaltete Mods bleiben abgeschaltet.</small>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -840,6 +860,24 @@
|
|||||||
<span class="hint">Audio: <span id="record-audio-summary">Systemton</span></span>
|
<span class="hint">Audio: <span id="record-audio-summary">Systemton</span></span>
|
||||||
<button id="record-audio-edit" class="btn btn-mini">Audioquellen wählen…</button>
|
<button id="record-audio-edit" class="btn btn-mini">Audioquellen wählen…</button>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Clip-Modus: läuft im Hintergrund mit, Tastenkürzel speichert die letzten Sekunden -->
|
||||||
|
<div id="clip-bar" class="clip-bar">
|
||||||
|
<label class="clip-switch" title="Nimmt im Hintergrund laufend auf und behält nur die letzten Sekunden">
|
||||||
|
<input id="clip-toggle" type="checkbox" />
|
||||||
|
<span class="clip-switch-track" aria-hidden="true"><span class="clip-switch-knob"></span></span>
|
||||||
|
<span class="clip-switch-label">Clip-Modus</span>
|
||||||
|
</label>
|
||||||
|
<label class="clip-length">
|
||||||
|
<span>Länge</span>
|
||||||
|
<select id="clip-length" title="Länge eines Clips">
|
||||||
|
<option value="30">30 Sek.</option>
|
||||||
|
<option value="60" selected>60 Sek.</option>
|
||||||
|
<option value="90">90 Sek.</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<span id="clip-status" class="clip-status">Speichert auf Tastendruck die letzten Sekunden – auch bei geschlossenem Fenster.</span>
|
||||||
|
<button id="clip-save" class="btn btn-mini" disabled>Clip speichern</button>
|
||||||
|
</div>
|
||||||
<div class="record-toolbar">
|
<div class="record-toolbar">
|
||||||
<div id="record-live-badge" class="record-live-badge hidden">
|
<div id="record-live-badge" class="record-live-badge hidden">
|
||||||
<span class="rec-dot"></span>
|
<span class="rec-dot"></span>
|
||||||
@@ -973,6 +1011,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ===================== Dialog: Spielzeit-Statistik ===================== -->
|
||||||
|
<div id="modal-stats" class="modal hidden">
|
||||||
|
<div class="modal-card">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h3>Spielzeit-Statistik</h3>
|
||||||
|
<button class="modal-close" data-close>✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div id="stats-tiles" class="stats-tiles"></div>
|
||||||
|
<div class="friends-section-title">Spielzeit pro Instanz</div>
|
||||||
|
<div id="stats-list" class="stats-list" role="list"></div>
|
||||||
|
<p id="stats-unplayed" class="hint"></p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-foot">
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<button class="btn" data-close>Schließen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ===================== Dialog: Freunde ===================== -->
|
<!-- ===================== Dialog: Freunde ===================== -->
|
||||||
<div id="modal-friends" class="modal hidden">
|
<div id="modal-friends" class="modal hidden">
|
||||||
<div class="modal-card modal-sm">
|
<div class="modal-card modal-sm">
|
||||||
|
|||||||
+17
-2
@@ -357,6 +357,13 @@ function buildArgs(vj, ctx) {
|
|||||||
* auth: null (kein Login) ODER { name, uuid, accessToken, userType }
|
* auth: null (kein Login) ODER { name, uuid, accessToken, userType }
|
||||||
* onProgress: (evt) => void
|
* onProgress: (evt) => void
|
||||||
*/
|
*/
|
||||||
|
// true für Release-Versionen vor 1.20 (kein Quick Play). Snapshots und
|
||||||
|
// Unbekanntes gelten als neu – die meisten heutigen Snapshots sind > 1.20.
|
||||||
|
function vorQuickPlay(versionId) {
|
||||||
|
const m = /^1\.(\d+)(?:\.\d+)?(?:-(?:pre|rc)\d+)?$/.exec(String(versionId || '').trim());
|
||||||
|
return !!m && Number(m[1]) < 20;
|
||||||
|
}
|
||||||
|
|
||||||
async function prepareAndLaunch(opts, onProgress) {
|
async function prepareAndLaunch(opts, onProgress) {
|
||||||
const emit = (phase, extra) => onProgress && onProgress(Object.assign({ phase }, extra));
|
const emit = (phase, extra) => onProgress && onProgress(Object.assign({ phase }, extra));
|
||||||
|
|
||||||
@@ -526,9 +533,17 @@ async function prepareAndLaunch(opts, onProgress) {
|
|||||||
// Fenstergröße bzw. maximiert starten (Minecraft-Argumente)
|
// Fenstergröße bzw. maximiert starten (Minecraft-Argumente)
|
||||||
if (opts.mcMaximized) args.push('--fullscreen');
|
if (opts.mcMaximized) args.push('--fullscreen');
|
||||||
else if (opts.mcWidth && opts.mcHeight) args.push('--width', String(opts.mcWidth), '--height', String(opts.mcHeight));
|
else if (opts.mcWidth && opts.mcHeight) args.push('--width', String(opts.mcWidth), '--height', String(opts.mcHeight));
|
||||||
// Ab 1.20: direkt einem Server beitreten (Quick Play)
|
// Direkt einem Server beitreten: ab 1.20 per Quick Play, davor über die
|
||||||
|
// älteren Startparameter --server/--port (z. B. für "Beitreten" bei Freunden)
|
||||||
if (opts.quickPlayServer) {
|
if (opts.quickPlayServer) {
|
||||||
args.push('--quickPlayMultiplayer', String(opts.quickPlayServer));
|
const adresse = String(opts.quickPlayServer).trim();
|
||||||
|
if (vorQuickPlay(opts.versionId)) {
|
||||||
|
const m = /^(.*?)(?::(\d+))?$/.exec(adresse);
|
||||||
|
args.push('--server', m[1]);
|
||||||
|
if (m[2]) args.push('--port', m[2]);
|
||||||
|
} else {
|
||||||
|
args.push('--quickPlayMultiplayer', adresse);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ensureDir(opts.gameDir);
|
ensureDir(opts.gameDir);
|
||||||
|
|||||||
+368
-32
@@ -118,9 +118,30 @@ const POPOUT_SPEC = {
|
|||||||
update: { title: 'AeroMC – Update', width: 460, height: 460, minWidth: 420, minHeight: 380 },
|
update: { title: 'AeroMC – Update', width: 460, height: 460, minWidth: 420, minHeight: 380 },
|
||||||
record: { title: 'AeroMC – Spielaufzeichnung', width: 960, height: 800, minWidth: 640, minHeight: 560 },
|
record: { title: 'AeroMC – Spielaufzeichnung', width: 960, height: 800, minWidth: 640, minHeight: 560 },
|
||||||
friends: { title: 'AeroMC – Freunde', width: 420, height: 560, minWidth: 360, minHeight: 420 },
|
friends: { title: 'AeroMC – Freunde', width: 420, height: 560, minWidth: 360, minHeight: 420 },
|
||||||
|
stats: { title: 'AeroMC – Spielzeit-Statistik', width: 640, height: 560, minWidth: 520, minHeight: 420 },
|
||||||
};
|
};
|
||||||
const POPOUT_DEFAULT = { title: 'AeroMC', width: 860, height: 680, minWidth: 640, minHeight: 480 };
|
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
|
let pendingUpdateInfo = null; // letztes Ergebnis von update:check/checkForUpdate, für Update-Popup
|
||||||
|
// Ergebnis der letzten Update-Suche (auch "läuft gerade" oder Fehler) – Fenster, die
|
||||||
|
// erst später aufgehen (z. B. Einstellungen → Info), holen es sich hierüber ab
|
||||||
|
let letzterUpdateCheck = { checking: true };
|
||||||
|
// Hauptfenster hat seine Empfänger registriert – erst dann den Update-Hinweis schicken
|
||||||
|
const hauptfensterBereit = new Promise((resolve) => ipcMain.once('renderer:ready', resolve));
|
||||||
|
|
||||||
|
async function pruefeAufUpdates() {
|
||||||
|
letzterUpdateCheck = { checking: true };
|
||||||
|
broadcast('update:checked', letzterUpdateCheck);
|
||||||
|
try {
|
||||||
|
const info = await updater.checkForUpdate(app.getVersion());
|
||||||
|
pendingUpdateInfo = info;
|
||||||
|
letzterUpdateCheck = info;
|
||||||
|
} catch (err) {
|
||||||
|
// offline o. Ä. – kein Absturz, aber die Info-Seite darf den Fehler zeigen
|
||||||
|
letzterUpdateCheck = { available: false, error: (err && err.message) || String(err) };
|
||||||
|
}
|
||||||
|
broadcast('update:checked', letzterUpdateCheck);
|
||||||
|
return letzterUpdateCheck;
|
||||||
|
}
|
||||||
|
|
||||||
// an Hauptfenster UND alle offenen Popup-Fenster senden (Start-Fortschritt
|
// an Hauptfenster UND alle offenen Popup-Fenster senden (Start-Fortschritt
|
||||||
// muss z. B. auch im herausgelösten Konsolen-Fenster ankommen)
|
// muss z. B. auch im herausgelösten Konsolen-Fenster ankommen)
|
||||||
@@ -158,6 +179,9 @@ function openPopout(modalId, params) {
|
|||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
sandbox: false,
|
sandbox: false,
|
||||||
|
// Clip-Modus nimmt auch bei geschlossenem (verstecktem) Fenster weiter auf –
|
||||||
|
// ohne das würde Chromium Zeichenschleife und Zeitgeber dort anhalten
|
||||||
|
backgroundThrottling: modalId !== 'record',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
popoutWindows.set(modalId, win);
|
popoutWindows.set(modalId, win);
|
||||||
@@ -215,6 +239,9 @@ const kontoPid = new Map(); // kontoId -> pid, für den Ingame-Toast: der muss a
|
|||||||
// des prüfenden Kontos erscheinen, nicht auf irgendeinem laufenden Minecraft-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
|
// (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)
|
// Fenster zuerst gefunden wurde, obwohl Electric__Max der Freund war, der geprüft hat)
|
||||||
|
const kontoInstanz = new Map(); // kontoId -> instanceId des gerade laufenden Spiels (für "Einladen": eigener Server)
|
||||||
|
// Herzschläge aller laufenden Spiele – um eine geänderte Sichtbarkeit sofort zu melden
|
||||||
|
const laufendeHerzschlaege = new Set();
|
||||||
ipcMain.handle('recording:getPid', (_e, id) => laufendePid.get(id) || null);
|
ipcMain.handle('recording:getPid', (_e, id) => laufendePid.get(id) || null);
|
||||||
|
|
||||||
// Welche Bildschirmquelle als Nächstes über getDisplayMedia erfasst werden
|
// Welche Bildschirmquelle als Nächstes über getDisplayMedia erfasst werden
|
||||||
@@ -268,7 +295,6 @@ async function findeFensterUndBildschirm(pid, fensterTitel) {
|
|||||||
const quelle = screenSources.find((s) => String(s.display_id) === String(anzeige.id)) || screenSources[0];
|
const quelle = screenSources.find((s) => String(s.display_id) === String(anzeige.id)) || screenSources[0];
|
||||||
if (!quelle) { console.log('[Aufzeichnung] keine Bildschirmquelle gefunden'); return null; }
|
if (!quelle) { console.log('[Aufzeichnung] keine Bildschirmquelle gefunden'); return null; }
|
||||||
|
|
||||||
console.log('[Aufzeichnung] gefunden:', JSON.stringify({ fensterBounds, bildschirmBounds: anzeige.bounds, screenSourceId: quelle.id }));
|
|
||||||
return {
|
return {
|
||||||
fensterBounds,
|
fensterBounds,
|
||||||
bildschirmBounds: anzeige.bounds,
|
bildschirmBounds: anzeige.bounds,
|
||||||
@@ -399,6 +425,77 @@ ipcMain.handle('recording:save', async (_e, instanceName, buffer, serverLabel, f
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------- Clip-Modus ----------
|
||||||
|
// Tastenkürzel wie bei ShadowPlay; ist es schon vergeben (z. B. von ShadowPlay
|
||||||
|
// selbst), weicht AeroMC auf Strg+Alt+C aus.
|
||||||
|
const CLIP_TASTENKUERZEL = ['Alt+F10', 'Control+Alt+C'];
|
||||||
|
let clipKuerzel = null;
|
||||||
|
let clipInstanz = null;
|
||||||
|
|
||||||
|
ipcMain.handle('clip:setActive', (_e, aktiv, instanceId) => {
|
||||||
|
if (clipKuerzel) { globalShortcut.unregister(clipKuerzel); clipKuerzel = null; }
|
||||||
|
clipInstanz = aktiv ? instanceId : null;
|
||||||
|
if (!aktiv) return null;
|
||||||
|
for (const kuerzel of CLIP_TASTENKUERZEL) {
|
||||||
|
const ok = globalShortcut.register(kuerzel, () => {
|
||||||
|
const pid = clipInstanz && laufendePid.get(clipInstanz);
|
||||||
|
if (pid) zeigeIngameOverlay('Clip', 'Wird gespeichert …', pid, 2500).catch(() => {});
|
||||||
|
const win = popoutWindows.get('record');
|
||||||
|
if (win && !win.isDestroyed()) win.webContents.send('clip:hotkey');
|
||||||
|
});
|
||||||
|
if (ok) { clipKuerzel = kuerzel; break; }
|
||||||
|
}
|
||||||
|
return clipKuerzel;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Abschnitte zusammensetzen, vorne auf die Clip-Länge kürzen, als MP4 speichern
|
||||||
|
ipcMain.handle('clip:save', async (_e, daten) => {
|
||||||
|
const d = daten || {};
|
||||||
|
const abschnitte = Array.isArray(d.abschnitte) ? d.abschnitte : [];
|
||||||
|
if (!abschnitte.length) return { ok: false, message: 'Keine Clip-Daten.' };
|
||||||
|
if (!ffmpegPfad) return { ok: false, message: 'ffmpeg fehlt – Clips können nicht gespeichert werden.' };
|
||||||
|
const arbeitsOrdner = fs.mkdtempSync(path.join(app.getPath('temp'), 'aeromc-clip-'));
|
||||||
|
try {
|
||||||
|
const liste = abschnitte.map((buf, i) => {
|
||||||
|
const datei = path.join(arbeitsOrdner, `teil${i}.webm`);
|
||||||
|
fs.writeFileSync(datei, Buffer.from(buf));
|
||||||
|
return `file '${datei.replace(/\\/g, '/').replace(/'/g, "'\\''")}'`;
|
||||||
|
});
|
||||||
|
const listenDatei = path.join(arbeitsOrdner, 'liste.txt');
|
||||||
|
fs.writeFileSync(listenDatei, liste.join('\n'), 'utf8');
|
||||||
|
|
||||||
|
const zielOrdner = aufzeichnungsOrdner(d.instanceName);
|
||||||
|
fs.mkdirSync(zielOrdner, { recursive: true });
|
||||||
|
const zeitstempel = new Date().toISOString().replace(/[:T]/g, '-').slice(0, 19);
|
||||||
|
const serverTeil = d.serverLabel ? String(d.serverLabel).replace(/[^\w.-]+/g, '_') + '_' : '';
|
||||||
|
let zielDatei = path.join(zielOrdner, `Clip_${serverTeil}${zeitstempel}.mp4`);
|
||||||
|
let n = 2;
|
||||||
|
while (fs.existsSync(zielDatei)) { zielDatei = path.join(zielOrdner, `Clip_${serverTeil}${zeitstempel}_${n}.mp4`); n++; }
|
||||||
|
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
execFile(ffmpegPfad, [
|
||||||
|
'-y', '-f', 'concat', '-safe', '0', '-i', listenDatei,
|
||||||
|
'-ss', String(Math.max(0, Number(d.vorneAbschneiden) || 0).toFixed(2)),
|
||||||
|
'-r', String(d.fps || 30), '-vsync', 'cfr',
|
||||||
|
'-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-pix_fmt', 'yuv420p',
|
||||||
|
'-c:a', 'aac', '-b:a', '192k',
|
||||||
|
zielDatei,
|
||||||
|
], { maxBuffer: 16 * 1024 * 1024 }, (err, _stdout, stderr) => {
|
||||||
|
if (err) reject(new Error(stderr ? String(stderr).split('\n').slice(-5).join(' ') : err.message));
|
||||||
|
else resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const pid = d.instanceId && laufendePid.get(d.instanceId);
|
||||||
|
if (pid) zeigeIngameOverlay('Clip gespeichert', `${d.laenge || ''} Sek. · ${path.basename(zielDatei)}`, pid, 5000).catch(() => {});
|
||||||
|
return { ok: true, path: zielDatei };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, message: 'Clip konnte nicht gespeichert werden: ' + err.message };
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(arbeitsOrdner, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
ipcMain.handle('recording:openFolder', (_e, instanceName) => {
|
ipcMain.handle('recording:openFolder', (_e, instanceName) => {
|
||||||
const dir = aufzeichnungsOrdner(instanceName);
|
const dir = aufzeichnungsOrdner(instanceName);
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
@@ -612,6 +709,33 @@ function createWindow(startHidden) {
|
|||||||
if (startId) {
|
if (startId) {
|
||||||
ipcMain.once('renderer:ready', () => starteInstanzImFenster(startId, startServer));
|
ipcMain.once('renderer:ready', () => starteInstanzImFenster(startId, startServer));
|
||||||
}
|
}
|
||||||
|
ipcMain.once('renderer:ready', () => oeffneVorbereitetenImport());
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Das Setup kann optional andere Launcher übernehmen lassen (installer/Installer.cs):
|
||||||
|
* es legt dazu pending-import.json mit den angekreuzten Launchern ab. Beim ersten
|
||||||
|
* sichtbaren Start öffnet sich der Import-Dialog damit schon fertig durchsucht –
|
||||||
|
* übernommen wird erst, was man dort bestätigt. Die Datei gilt nur einmal.
|
||||||
|
*/
|
||||||
|
function oeffneVorbereitetenImport() {
|
||||||
|
const datei = path.join(app.getPath('userData'), 'pending-import.json');
|
||||||
|
if (!fs.existsSync(datei)) return;
|
||||||
|
// im Autostart unsichtbar gestartet: erst anbieten, wenn das Fenster gezeigt wird
|
||||||
|
if (mainWindow && !mainWindow.isVisible()) {
|
||||||
|
mainWindow.once('show', () => oeffneVorbereitetenImport());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let quellen = [];
|
||||||
|
try {
|
||||||
|
const daten = JSON.parse(fs.readFileSync(datei, 'utf8').replace(/^/, ''));
|
||||||
|
quellen = (Array.isArray(daten.sources) ? daten.sources : [])
|
||||||
|
.filter((q) => q && ['mc', 'cf', 'mmc', 'technic'].includes(q.kind))
|
||||||
|
.map((q) => ({ kind: q.kind, path: typeof q.path === 'string' ? q.path : '' }));
|
||||||
|
} catch { /* beschädigt -> verwerfen */ }
|
||||||
|
try { fs.unlinkSync(datei); } catch { /* egal */ }
|
||||||
|
if (!quellen.length) return;
|
||||||
|
openPopout('import', { kind: quellen[0].kind, vorauswahl: JSON.stringify(quellen) });
|
||||||
}
|
}
|
||||||
|
|
||||||
let tray = null;
|
let tray = null;
|
||||||
@@ -719,24 +843,20 @@ app.whenReady().then(() => {
|
|||||||
// befindliche Freund beim Start fälschlich als "gerade online gegangen" gelten).
|
// befindliche Freund beim Start fälschlich als "gerade online gegangen" gelten).
|
||||||
setTimeout(() => { pruefeFreundeOnline().catch(() => {}); }, 15000);
|
setTimeout(() => { pruefeFreundeOnline().catch(() => {}); }, 15000);
|
||||||
setInterval(() => { pruefeFreundeOnline().catch(() => {}); }, 60000);
|
setInterval(() => { pruefeFreundeOnline().catch(() => {}); }, 60000);
|
||||||
|
// Einladungen ("Komm auf meinen Server") öfter prüfen – sie gelten nur ein paar Minuten
|
||||||
|
setTimeout(() => { pruefeEinladungen().catch(() => {}); }, 8000);
|
||||||
|
setInterval(() => { pruefeEinladungen().catch(() => {}); }, 15000);
|
||||||
// Zahl offener Freundschaftsanfragen für den "Freunde"-Knopf
|
// Zahl offener Freundschaftsanfragen für den "Freunde"-Knopf
|
||||||
setTimeout(() => { aktualisiereAnfragenZahl().catch(() => {}); }, 3000);
|
setTimeout(() => { aktualisiereAnfragenZahl().catch(() => {}); }, 3000);
|
||||||
setInterval(() => { aktualisiereAnfragenZahl().catch(() => {}); }, 60000);
|
setInterval(() => { aktualisiereAnfragenZahl().catch(() => {}); }, 60000);
|
||||||
|
|
||||||
// automatisch auf Updates prüfen (kurz nach dem Start)
|
// gleich beim Start auf Updates prüfen; den Hinweis-Dialog aber erst zeigen,
|
||||||
setTimeout(async () => {
|
// wenn das Hauptfenster bereit ist (sonst ginge er verloren)
|
||||||
try {
|
pruefeAufUpdates().then(async (info) => {
|
||||||
const info = await updater.checkForUpdate(app.getVersion());
|
if (!info.available) return;
|
||||||
pendingUpdateInfo = info;
|
await hauptfensterBereit;
|
||||||
// Ergebnis immer melden, damit die Info-Seite auch "aktuell" anzeigen
|
broadcast('update:available', info);
|
||||||
// kann – nicht erst wenn der Nutzer manuell auf "Suchen" klickt
|
}).catch(() => {});
|
||||||
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
|
|
||||||
broadcast('update:checked', { error: (err && err.message) || String(err) });
|
|
||||||
}
|
|
||||||
}, 4000);
|
|
||||||
|
|
||||||
// Anmelde-Dienste automatisch überwachen (sofort + alle 2 Minuten)
|
// Anmelde-Dienste automatisch überwachen (sofort + alle 2 Minuten)
|
||||||
setTimeout(() => checkAuthServices(), 2500);
|
setTimeout(() => checkAuthServices(), 2500);
|
||||||
@@ -793,6 +913,10 @@ ipcMain.handle('settings:save', (_e, patch) => {
|
|||||||
aktualisiereFreundeTastenkuerzel();
|
aktualisiereFreundeTastenkuerzel();
|
||||||
aktualisiereAnfragenZahl().catch(() => {});
|
aktualisiereAnfragenZahl().catch(() => {});
|
||||||
}
|
}
|
||||||
|
// Server ein-/ausgeblendet -> Freunden gleich melden, nicht erst beim nächsten Herzschlag
|
||||||
|
if (patch && Object.prototype.hasOwnProperty.call(patch, 'friendsShareServer')) {
|
||||||
|
for (const herzschlag of laufendeHerzschlaege) herzschlag();
|
||||||
|
}
|
||||||
// Einstellungen können jetzt auch aus dem Popup-Fenster gespeichert werden –
|
// Einstellungen können jetzt auch aus dem Popup-Fenster gespeichert werden –
|
||||||
// das Hauptfenster muss Theme/Sortierung/Bedrock-Sichtbarkeit/Skin-Hintergrund
|
// das Hauptfenster muss Theme/Sortierung/Bedrock-Sichtbarkeit/Skin-Hintergrund
|
||||||
// dann selbst nachziehen. NICHT bei der beiläufigen "letzte Instanz"-Notiz
|
// dann selbst nachziehen. NICHT bei der beiläufigen "letzte Instanz"-Notiz
|
||||||
@@ -850,7 +974,11 @@ ipcMain.handle('mods:search', async (_e, opts) => {
|
|||||||
|
|
||||||
ipcMain.handle('mods:list', (_e, id, art) => store.listMods(id, art || 'mod'));
|
ipcMain.handle('mods:list', (_e, id, art) => store.listMods(id, art || 'mod'));
|
||||||
|
|
||||||
ipcMain.handle('mods:install', async (_e, id, project, art) => {
|
ipcMain.handle('mods:install', (_e, id, project, art) => installiereInhalt(id, project, art));
|
||||||
|
|
||||||
|
// installiert ein Projekt (Modrinth oder CurseForge) samt Pflicht-Abhängigkeiten –
|
||||||
|
// auch vom Performance-Paket beim Anlegen einer Instanz genutzt
|
||||||
|
async function installiereInhalt(id, project, art) {
|
||||||
const inst = store.getInstance(id);
|
const inst = store.getInstance(id);
|
||||||
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
|
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
|
||||||
const typ = services.inhaltsArt(art || 'mod');
|
const typ = services.inhaltsArt(art || 'mod');
|
||||||
@@ -923,6 +1051,44 @@ ipcMain.handle('mods:install', async (_e, id, project, art) => {
|
|||||||
await installOne(project, false);
|
await installOne(project, false);
|
||||||
if (!added.length) return { ok: false, message: firstError || 'Installation fehlgeschlagen.' };
|
if (!added.length) return { ok: false, message: firstError || 'Installation fehlgeschlagen.' };
|
||||||
return { ok: true, entry: added[0], added, depCount: added.length - 1 };
|
return { ok: true, entry: added[0], added, depCount: added.length - 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Performance-Paket: bewährte Optimierungs-Mods, per Häkchen beim Anlegen einer
|
||||||
|
* Instanz mitinstalliert. Feste Modrinth-IDs statt Namen, damit sie sich mit den
|
||||||
|
* automatisch nachgezogenen Abhängigkeiten nicht doppeln. Was es für Version
|
||||||
|
* oder Loader (noch) nicht gibt, wird einfach übersprungen.
|
||||||
|
*/
|
||||||
|
const PERFORMANCE_MODS = [
|
||||||
|
{ projectId: 'AANobbMI', title: 'Sodium' }, // Grafik/FPS
|
||||||
|
{ projectId: 'gvQqBUqZ', title: 'Lithium' }, // Spiellogik
|
||||||
|
{ projectId: 'uXXizFIs', title: 'FerriteCore' }, // Arbeitsspeicher
|
||||||
|
{ projectId: '5ZwdcRci', title: 'ImmediatelyFast' }, // Darstellung (HUD, Text)
|
||||||
|
{ projectId: 'NNAgCjsB', title: 'Entity Culling' }, // verdeckte Objekte nicht zeichnen
|
||||||
|
{ projectId: 'nmDcB62a', title: 'ModernFix' }, // Ladezeit/Speicher
|
||||||
|
];
|
||||||
|
const PERFORMANCE_LOADER = new Set(['fabric', 'quilt', 'neoforge']);
|
||||||
|
|
||||||
|
ipcMain.handle('instances:installPerformancePack', async (_e, id) => {
|
||||||
|
const inst = store.getInstance(id);
|
||||||
|
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
|
||||||
|
if (!PERFORMANCE_LOADER.has(inst.minecraft.loader)) {
|
||||||
|
return { ok: false, message: 'Das Performance-Paket gibt es für Fabric, Quilt und NeoForge.' };
|
||||||
|
}
|
||||||
|
const installiert = [];
|
||||||
|
const fehlend = [];
|
||||||
|
broadcast('ui:toast', { text: `Performance-Paket für „${inst.name}" wird installiert …` });
|
||||||
|
for (const mod of PERFORMANCE_MODS) {
|
||||||
|
const res = await installiereInhalt(id, { projectId: mod.projectId, title: mod.title }, 'mod').catch((err) => ({ ok: false, message: err.message }));
|
||||||
|
if (res.ok) installiert.push(mod.title);
|
||||||
|
else fehlend.push(mod.title);
|
||||||
|
}
|
||||||
|
broadcast('mods:changed', { id });
|
||||||
|
broadcast('ui:toast', installiert.length
|
||||||
|
? { text: `Performance-Paket: ${installiert.length} von ${PERFORMANCE_MODS.length} Mods installiert`
|
||||||
|
+ (fehlend.length ? ` (für diese Version nicht verfügbar: ${fehlend.join(', ')})` : '') + '.' }
|
||||||
|
: { text: 'Performance-Paket: Für diese Version/diesen Loader gibt es die Mods (noch) nicht.', error: true });
|
||||||
|
return { ok: true, installiert, fehlend };
|
||||||
});
|
});
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -1214,7 +1380,9 @@ ipcMain.handle('mods:checkUpdates', async (_e, id, art) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// lädt die gewählten Aktualisierungen und ersetzt die alten Dateien
|
// lädt die gewählten Aktualisierungen und ersetzt die alten Dateien
|
||||||
ipcMain.handle('mods:applyUpdates', async (_e, id, projectIds, art) => {
|
ipcMain.handle('mods:applyUpdates', (_e, id, projectIds, art) => wendeModUpdatesAn(id, projectIds, art));
|
||||||
|
|
||||||
|
async function wendeModUpdatesAn(id, projectIds, art) {
|
||||||
const inst = store.getInstance(id);
|
const inst = store.getInstance(id);
|
||||||
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
|
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
|
||||||
const gameVersion = inst.minecraft.version;
|
const gameVersion = inst.minecraft.version;
|
||||||
@@ -1243,7 +1411,29 @@ ipcMain.handle('mods:applyUpdates', async (_e, id, projectIds, art) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { ok: true, erneuert, fehler };
|
return { ok: true, erneuert, fehler };
|
||||||
});
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Automatische Mod-Updates vor dem Start (Einstellung autoModUpdates): sucht
|
||||||
|
* für die über Modrinth installierten Mods nach neueren Fassungen und spielt
|
||||||
|
* sie ein. Begrenzt auf eine Höchstdauer – ein langsamer Server soll den
|
||||||
|
* Spielstart nicht minutenlang aufhalten; dann wird eben ohne Update gestartet.
|
||||||
|
*/
|
||||||
|
async function modUpdatesVorDemStart(id) {
|
||||||
|
const inst = store.getInstance(id);
|
||||||
|
if (!inst || !inst.minecraft.version || !inst.minecraft.loader || inst.minecraft.loader === 'vanilla') return null;
|
||||||
|
const mods = store.listMods(id, 'mod');
|
||||||
|
if (!mods.some((m) => m.projectId)) return null;
|
||||||
|
broadcast('launch:progress', { id, phase: 'mod-updates', detail: 'Suche nach Mod-Updates …' });
|
||||||
|
const suche = services.modrinthFindUpdates(mods, inst.minecraft.version, inst.minecraft.loader, null, 'mod');
|
||||||
|
const zeitlimit = new Promise((resolve) => setTimeout(() => resolve(null), 20000));
|
||||||
|
const gefunden = await Promise.race([suche, zeitlimit]).catch(() => null);
|
||||||
|
if (!gefunden || !gefunden.treffer || !gefunden.treffer.length) return null;
|
||||||
|
broadcast('launch:progress', { id, phase: 'mod-updates', detail: `${gefunden.treffer.length} Mod-Update(s) werden installiert …` });
|
||||||
|
const res = await wendeModUpdatesAn(id, gefunden.treffer.map((t) => t.projectId), 'mod');
|
||||||
|
broadcast('mods:changed', { id });
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// IPC – Microsoft-Login (Phase 3)
|
// IPC – Microsoft-Login (Phase 3)
|
||||||
@@ -1451,6 +1641,7 @@ async function zeigeIngameOverlay(titel, text, pid, dauerMs = 6000) {
|
|||||||
// Tastenkürzel fängt die Kombination systemweit ab, das soll andere Programme
|
// Tastenkürzel fängt die Kombination systemweit ab, das soll andere Programme
|
||||||
// außerhalb des Spielens nicht stören.
|
// außerhalb des Spielens nicht stören.
|
||||||
const FREUNDE_TASTENKUERZEL = 'Control+Alt+F';
|
const FREUNDE_TASTENKUERZEL = 'Control+Alt+F';
|
||||||
|
const EINLADUNG_TASTENKUERZEL = 'Control+Alt+J';
|
||||||
let freundeTastenkuerzelAktiv = false;
|
let freundeTastenkuerzelAktiv = false;
|
||||||
|
|
||||||
function aktualisiereFreundeTastenkuerzel() {
|
function aktualisiereFreundeTastenkuerzel() {
|
||||||
@@ -1459,21 +1650,31 @@ function aktualisiereFreundeTastenkuerzel() {
|
|||||||
freundeTastenkuerzelAktiv = globalShortcut.register(FREUNDE_TASTENKUERZEL, () => {
|
freundeTastenkuerzelAktiv = globalShortcut.register(FREUNDE_TASTENKUERZEL, () => {
|
||||||
zeigeOnlineFreundeIngame().catch(() => {});
|
zeigeOnlineFreundeIngame().catch(() => {});
|
||||||
});
|
});
|
||||||
|
globalShortcut.register(EINLADUNG_TASTENKUERZEL, () => {
|
||||||
|
nimmEinladungIngameAn().catch(() => {});
|
||||||
|
});
|
||||||
} else if (!soll && freundeTastenkuerzelAktiv) {
|
} else if (!soll && freundeTastenkuerzelAktiv) {
|
||||||
globalShortcut.unregister(FREUNDE_TASTENKUERZEL);
|
globalShortcut.unregister(FREUNDE_TASTENKUERZEL);
|
||||||
|
globalShortcut.unregister(EINLADUNG_TASTENKUERZEL);
|
||||||
freundeTastenkuerzelAktiv = false;
|
freundeTastenkuerzelAktiv = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Konto + Fenster-PID des Spiels im Vordergrund (bei nur einem laufenden Spiel: dieses)
|
||||||
|
function vordergrundKonto() {
|
||||||
|
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];
|
||||||
|
return eintrag || null;
|
||||||
|
}
|
||||||
|
|
||||||
// Zeigt die gerade online befindlichen Freunde des Kontos, dessen
|
// Zeigt die gerade online befindlichen Freunde des Kontos, dessen
|
||||||
// Minecraft-Fenster gerade im Vordergrund ist, als Toast auf genau diesem
|
// Minecraft-Fenster gerade im Vordergrund ist, als Toast auf genau diesem
|
||||||
// Fenster. Läuft nur ein Spiel, wird dieses genommen, auch wenn es nicht
|
// Fenster. Läuft nur ein Spiel, wird dieses genommen, auch wenn es nicht
|
||||||
// im Vordergrund ist.
|
// im Vordergrund ist.
|
||||||
async function zeigeOnlineFreundeIngame() {
|
async function zeigeOnlineFreundeIngame() {
|
||||||
let aktivePid = null;
|
const eintrag = vordergrundKonto();
|
||||||
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;
|
if (!eintrag) return;
|
||||||
const [kontoUuid, pid] = eintrag;
|
const [kontoUuid, pid] = eintrag;
|
||||||
|
|
||||||
@@ -1495,6 +1696,125 @@ async function zeigeOnlineFreundeIngame() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Einladungen: "Komm auf meinen Server" ----------
|
||||||
|
// Eine Einladung enthält Server-Adresse und Minecraft-Version des Einladenden.
|
||||||
|
// Im Launcher: Hinweis + "Beitreten" im Freunde-Fenster (startet eine passende
|
||||||
|
// Instanz per Quick Play). Im Spiel: Toast auf dem richtigen Fenster; ein
|
||||||
|
// laufendes Minecraft lässt sich von außen nicht auf einen anderen Server
|
||||||
|
// umlenken – Strg+Alt+J kopiert deshalb die Adresse für "Direkt verbinden".
|
||||||
|
const gezeigteEinladungen = new Set(); // Einladungs-IDs, die schon gemeldet wurden
|
||||||
|
const letzteEinladung = new Map(); // kontoUuid -> zuletzt erhaltene Einladung (für Strg+Alt+J)
|
||||||
|
|
||||||
|
async function pruefeEinladungen() {
|
||||||
|
if (store.getSettings().friendsEnabled === false) return;
|
||||||
|
const aktiv = aktivesFreundeKonto();
|
||||||
|
const konten = new Set([...kontoPid.keys()]);
|
||||||
|
if (aktiv) konten.add(aktiv.uuid);
|
||||||
|
for (const kontoUuid of konten) {
|
||||||
|
const res = await friends.getInvites(kontoUuid);
|
||||||
|
if (!res.ok) continue;
|
||||||
|
for (const einl of res.data || []) {
|
||||||
|
if (gezeigteEinladungen.has(einl.id)) continue;
|
||||||
|
gezeigteEinladungen.add(einl.id);
|
||||||
|
letzteEinladung.set(kontoUuid, einl);
|
||||||
|
const pid = kontoPid.get(kontoUuid);
|
||||||
|
if (pid) {
|
||||||
|
await zeigeIngameOverlay('Einladung', [
|
||||||
|
{ uuid: einl.fromUuid, text: `${einl.fromUsername || 'Ein Freund'} lädt dich ein · ${einl.server}` },
|
||||||
|
'Strg+Alt+J: Adresse kopieren',
|
||||||
|
], pid, 10000);
|
||||||
|
}
|
||||||
|
if (aktiv && aktiv.uuid === kontoUuid) broadcast('friends:invite', einl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strg+Alt+J im Spiel: Adresse der letzten Einladung in die Zwischenablage
|
||||||
|
async function nimmEinladungIngameAn() {
|
||||||
|
const eintrag = vordergrundKonto();
|
||||||
|
if (!eintrag) return;
|
||||||
|
const [kontoUuid, pid] = eintrag;
|
||||||
|
const einl = letzteEinladung.get(kontoUuid);
|
||||||
|
if (!einl) { await zeigeIngameOverlay('Einladung', 'Gerade keine offene Einladung', pid, 4000); return; }
|
||||||
|
clipboard.writeText(einl.server);
|
||||||
|
await zeigeIngameOverlay('Adresse kopiert', [
|
||||||
|
einl.server,
|
||||||
|
'Mehrspieler → Direkt verbinden → Strg+V',
|
||||||
|
], pid, 9000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Passende eigene Instanz für einen Server: gleiche Version und den Server schon
|
||||||
|
// in der Liste > gleiche Version > (Version unbekannt) Server in der Liste.
|
||||||
|
// Jeweils die zuletzt gespielte.
|
||||||
|
function instanzFuerServer(server, version) {
|
||||||
|
const norm = (a) => String(a || '').trim().toLowerCase().replace(/:25565$/, '');
|
||||||
|
const ziel = norm(server);
|
||||||
|
const neuesteZuerst = (liste) => [...liste].sort((a, b) => String(b.lastPlayed || '').localeCompare(String(a.lastPlayed || '')));
|
||||||
|
const kenntServer = (inst) => {
|
||||||
|
try {
|
||||||
|
const r = servers.lies(store.gameDir(inst.id));
|
||||||
|
return r.ok && r.server.some((x) => norm(x.ip) === ziel);
|
||||||
|
} catch { return false; }
|
||||||
|
};
|
||||||
|
const alle = store.listInstances().filter((i) => i.minecraft && i.minecraft.version);
|
||||||
|
const passend = version ? alle.filter((i) => i.minecraft.version === version) : alle;
|
||||||
|
return neuesteZuerst(passend.filter(kenntServer))[0]
|
||||||
|
|| (version ? neuesteZuerst(passend)[0] : null)
|
||||||
|
|| null;
|
||||||
|
}
|
||||||
|
|
||||||
|
ipcMain.handle('friends:join', (_e, server, version) => {
|
||||||
|
if (!server) return { ok: false, message: 'Kein Server bekannt.' };
|
||||||
|
const inst = instanzFuerServer(server, version);
|
||||||
|
if (!inst) return { ok: false, noInstance: true, version: version || '' };
|
||||||
|
showMainWindow();
|
||||||
|
starteInstanzImFenster(inst.id, server);
|
||||||
|
return { ok: true, instanceName: inst.name };
|
||||||
|
});
|
||||||
|
|
||||||
|
// keine passende Instanz: neue Vanilla-Instanz mit der Version des Freundes anlegen und beitreten
|
||||||
|
ipcMain.handle('friends:joinNew', (_e, server, version) => {
|
||||||
|
if (!server || !version) return { ok: false, message: 'Server oder Version unbekannt.' };
|
||||||
|
const inst = store.createInstance({ name: `${server} (${version})`, minecraft: { version, loader: 'vanilla' } });
|
||||||
|
broadcast('instances:changed');
|
||||||
|
showMainWindow();
|
||||||
|
starteInstanzImFenster(inst.id, server);
|
||||||
|
return { ok: true, instanceName: inst.name };
|
||||||
|
});
|
||||||
|
|
||||||
|
// eigener aktueller Server (des aktiven Kontos), falls es gerade auf einem spielt
|
||||||
|
function eigenerServer() {
|
||||||
|
const konto = aktivesFreundeKonto();
|
||||||
|
const instId = konto && kontoInstanz.get(konto.uuid);
|
||||||
|
const server = instId && letzteQuickPlayServer.get(instId);
|
||||||
|
if (!server) return null;
|
||||||
|
const inst = store.getInstance(instId);
|
||||||
|
return { konto, server, version: (inst && inst.minecraft.version) || '' };
|
||||||
|
}
|
||||||
|
ipcMain.handle('friends:myServer', () => {
|
||||||
|
const e = eigenerServer();
|
||||||
|
return e ? { server: e.server, version: e.version } : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('friends:invite', async (_e, targetUuid) => {
|
||||||
|
const e = eigenerServer();
|
||||||
|
if (!e) return { ok: false, message: 'Du bist gerade auf keinem Server – erst einem Server beitreten.' };
|
||||||
|
return friends.sendInvite(e.konto.uuid, targetUuid, e.server, e.version);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('friends:invites', async () => {
|
||||||
|
const konto = aktivesFreundeKonto();
|
||||||
|
if (!konto) return { ok: false, message: 'Kein Konto angemeldet.' };
|
||||||
|
const res = await friends.getInvites(konto.uuid);
|
||||||
|
return res.ok ? { ok: true, list: res.data || [] } : res;
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('friends:dismissInvite', async (_e, inviteId) => {
|
||||||
|
const konto = aktivesFreundeKonto();
|
||||||
|
if (!konto) return { ok: false };
|
||||||
|
return friends.dismissInvite(konto.uuid, inviteId);
|
||||||
|
});
|
||||||
|
|
||||||
// ---------- Offene Freundschaftsanfragen (Zahl am "Freunde"-Knopf) ----------
|
// ---------- Offene Freundschaftsanfragen (Zahl am "Freunde"-Knopf) ----------
|
||||||
let letzteAnfragenZahl = 0;
|
let letzteAnfragenZahl = 0;
|
||||||
async function aktualisiereAnfragenZahl() {
|
async function aktualisiereAnfragenZahl() {
|
||||||
@@ -2602,6 +2922,10 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
|
|||||||
await backupNachZeitplan('vor-dem-start').catch(() => {});
|
await backupNachZeitplan('vor-dem-start').catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auf Wunsch vor dem Start Mods aktualisieren (Fehler/Zeitüberschreitung -> einfach ohne Update starten)
|
||||||
|
let modUpdates = null;
|
||||||
|
if (settings.autoModUpdates) modUpdates = await modUpdatesVorDemStart(id).catch(() => null);
|
||||||
|
|
||||||
const opts = {
|
const opts = {
|
||||||
versionId: inst.minecraft.version,
|
versionId: inst.minecraft.version,
|
||||||
loader: inst.minecraft.loader || 'vanilla',
|
loader: inst.minecraft.loader || 'vanilla',
|
||||||
@@ -2644,6 +2968,7 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
|
|||||||
const kontoId = account && account.profile ? account.profile.id : null;
|
const kontoId = account && account.profile ? account.profile.id : null;
|
||||||
let gezaehlt = false;
|
let gezaehlt = false;
|
||||||
let freundeHerzschlag = null;
|
let freundeHerzschlag = null;
|
||||||
|
let herzschlagJetzt = null; // sofort melden, z. B. sobald ein Server erkannt wurde
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let launchStart = 0;
|
let launchStart = 0;
|
||||||
@@ -2665,9 +2990,14 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
|
|||||||
uuid: account.profile.id,
|
uuid: account.profile.id,
|
||||||
username: account.profile.name,
|
username: account.profile.name,
|
||||||
instance: inst.name,
|
instance: inst.name,
|
||||||
server: letzteQuickPlayServer.get(id) || '',
|
// nur, wenn man seinen Server zeigen möchte (Einstellung, live gelesen)
|
||||||
|
server: store.getSettings().friendsShareServer === false ? '' : (letzteQuickPlayServer.get(id) || ''),
|
||||||
|
// für "Beitreten": damit Freunde eine passende Instanz wählen können
|
||||||
|
version: inst.minecraft.version || '',
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
};
|
};
|
||||||
|
herzschlagJetzt = sendeHerzschlag;
|
||||||
|
laufendeHerzschlaege.add(sendeHerzschlag);
|
||||||
sendeHerzschlag();
|
sendeHerzschlag();
|
||||||
freundeHerzschlag = setInterval(sendeHerzschlag, 60000);
|
freundeHerzschlag = setInterval(sendeHerzschlag, 60000);
|
||||||
}
|
}
|
||||||
@@ -2676,6 +3006,9 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
|
|||||||
if (gezaehlt) { gezaehlt = false; kontoSpieltNichtMehr(kontoId); }
|
if (gezaehlt) { gezaehlt = false; kontoSpieltNichtMehr(kontoId); }
|
||||||
discordpresence.clearActivity();
|
discordpresence.clearActivity();
|
||||||
if (freundeHerzschlag) { clearInterval(freundeHerzschlag); freundeHerzschlag = null; }
|
if (freundeHerzschlag) { clearInterval(freundeHerzschlag); freundeHerzschlag = null; }
|
||||||
|
if (herzschlagJetzt) laufendeHerzschlaege.delete(herzschlagJetzt);
|
||||||
|
herzschlagJetzt = null;
|
||||||
|
if (kontoId && kontoInstanz.get(kontoId) === id) kontoInstanz.delete(kontoId);
|
||||||
laufendePid.delete(id); // für die "Nur Minecraft"-Tonaufnahme: Prozess ist weg
|
laufendePid.delete(id); // für die "Nur Minecraft"-Tonaufnahme: Prozess ist weg
|
||||||
// beim nächsten Start dieses Kontos sollen bereits online befindliche
|
// beim nächsten Start dieses Kontos sollen bereits online befindliche
|
||||||
// Freunde wieder gemeldet werden
|
// Freunde wieder gemeldet werden
|
||||||
@@ -2696,6 +3029,8 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
|
|||||||
// damit ein schon offenes Aufzeichnungs-Fenster den Servernamen im
|
// damit ein schon offenes Aufzeichnungs-Fenster den Servernamen im
|
||||||
// Titel nachträgt, auch wenn es VOR dem Verbinden geöffnet wurde
|
// Titel nachträgt, auch wenn es VOR dem Verbinden geöffnet wurde
|
||||||
broadcast('recording:serverDetected', { id, server });
|
broadcast('recording:serverDetected', { id, server });
|
||||||
|
// Freunde sollen den Server gleich sehen (für "Beitreten"), nicht erst beim nächsten Herzschlag
|
||||||
|
if (herzschlagJetzt) herzschlagJetzt();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
broadcast('launch:progress', Object.assign({ id }, evt));
|
broadcast('launch:progress', Object.assign({ id }, evt));
|
||||||
@@ -2704,13 +3039,19 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
|
|||||||
// sonst bliebe das Konto für immer als "spielt" stehen
|
// sonst bliebe das Konto für immer als "spielt" stehen
|
||||||
if (!res.ok && gezaehlt) { gezaehlt = false; kontoSpieltNichtMehr(kontoId); discordpresence.clearActivity(); }
|
if (!res.ok && gezaehlt) { gezaehlt = false; kontoSpieltNichtMehr(kontoId); discordpresence.clearActivity(); }
|
||||||
if (!res.ok && freundeHerzschlag) { clearInterval(freundeHerzschlag); freundeHerzschlag = null; }
|
if (!res.ok && freundeHerzschlag) { clearInterval(freundeHerzschlag); freundeHerzschlag = null; }
|
||||||
if (res.ok) store.updateInstance(id, { lastPlayed: new Date().toISOString() });
|
if (!res.ok && herzschlagJetzt) { laufendeHerzschlaege.delete(herzschlagJetzt); herzschlagJetzt = null; }
|
||||||
|
if (res.ok) {
|
||||||
|
const aktuell = store.getInstance(id);
|
||||||
|
store.updateInstance(id, { lastPlayed: new Date().toISOString(), launchCount: ((aktuell && aktuell.launchCount) || 0) + 1 });
|
||||||
|
}
|
||||||
|
if (res.ok && modUpdates && modUpdates.erneuert && modUpdates.erneuert.length) res.modUpdates = modUpdates.erneuert.length;
|
||||||
if (res.ok && res.pid) laufendePid.set(id, res.pid);
|
if (res.ok && res.pid) laufendePid.set(id, res.pid);
|
||||||
if (res.ok && res.pid && kontoId) { kontoPid.set(kontoId, res.pid); aktualisiereFreundeTastenkuerzel(); }
|
if (res.ok && res.pid && kontoId) { kontoPid.set(kontoId, res.pid); kontoInstanz.set(kontoId, id); aktualisiereFreundeTastenkuerzel(); }
|
||||||
return res;
|
return res;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (gezaehlt) { gezaehlt = false; kontoSpieltNichtMehr(kontoId); discordpresence.clearActivity(); }
|
if (gezaehlt) { gezaehlt = false; kontoSpieltNichtMehr(kontoId); discordpresence.clearActivity(); }
|
||||||
if (freundeHerzschlag) { clearInterval(freundeHerzschlag); freundeHerzschlag = null; }
|
if (freundeHerzschlag) { clearInterval(freundeHerzschlag); freundeHerzschlag = null; }
|
||||||
|
if (herzschlagJetzt) { laufendeHerzschlaege.delete(herzschlagJetzt); herzschlagJetzt = null; }
|
||||||
return { ok: false, message: 'Start fehlgeschlagen: ' + err.message };
|
return { ok: false, message: 'Start fehlgeschlagen: ' + err.message };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -2973,13 +3314,8 @@ ipcMain.handle('icons:extract', () => {
|
|||||||
// IPC – Automatische Updates
|
// IPC – Automatische Updates
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
ipcMain.handle('update:check', async () => {
|
ipcMain.handle('update:check', () => pruefeAufUpdates());
|
||||||
try {
|
ipcMain.handle('update:lastCheck', () => letzterUpdateCheck);
|
||||||
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
|
// Ein Klick: herunterladen, prüfen, Update anwenden und Launcher beenden
|
||||||
ipcMain.handle('update:install', async (_e, asset) => {
|
ipcMain.handle('update:install', async (_e, asset) => {
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
listInstances: () => ipcRenderer.invoke('instances:list'),
|
listInstances: () => ipcRenderer.invoke('instances:list'),
|
||||||
getInstance: (id) => ipcRenderer.invoke('instances:get', id),
|
getInstance: (id) => ipcRenderer.invoke('instances:get', id),
|
||||||
createInstance: (data) => ipcRenderer.invoke('instances:create', data),
|
createInstance: (data) => ipcRenderer.invoke('instances:create', data),
|
||||||
|
installPerformancePack: (id) => ipcRenderer.invoke('instances:installPerformancePack', id),
|
||||||
updateInstance: (id, patch) => ipcRenderer.invoke('instances:update', id, patch),
|
updateInstance: (id, patch) => ipcRenderer.invoke('instances:update', id, patch),
|
||||||
deleteInstance: (id) => ipcRenderer.invoke('instances:delete', id),
|
deleteInstance: (id) => ipcRenderer.invoke('instances:delete', id),
|
||||||
duplicateInstance: (id) => ipcRenderer.invoke('instances:duplicate', id),
|
duplicateInstance: (id) => ipcRenderer.invoke('instances:duplicate', id),
|
||||||
@@ -225,6 +226,7 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
|
|
||||||
// Updates
|
// Updates
|
||||||
updateCheck: () => ipcRenderer.invoke('update:check'),
|
updateCheck: () => ipcRenderer.invoke('update:check'),
|
||||||
|
updateLastCheck: () => ipcRenderer.invoke('update:lastCheck'),
|
||||||
updateInstall: (asset) => ipcRenderer.invoke('update:install', asset),
|
updateInstall: (asset) => ipcRenderer.invoke('update:install', asset),
|
||||||
onUpdateAvailable: (cb) => {
|
onUpdateAvailable: (cb) => {
|
||||||
const l = (_e, d) => cb(d);
|
const l = (_e, d) => cb(d);
|
||||||
@@ -297,6 +299,29 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
friendsCancel: (otherUuid) => ipcRenderer.invoke('friends:cancel', otherUuid),
|
friendsCancel: (otherUuid) => ipcRenderer.invoke('friends:cancel', otherUuid),
|
||||||
friendsSetNickname: (uuid, nickname) => ipcRenderer.invoke('friends:setNickname', uuid, nickname),
|
friendsSetNickname: (uuid, nickname) => ipcRenderer.invoke('friends:setNickname', uuid, nickname),
|
||||||
friendsRequestCount: () => ipcRenderer.invoke('friends:requestCount'),
|
friendsRequestCount: () => ipcRenderer.invoke('friends:requestCount'),
|
||||||
|
clipSetActive: (active, instanceId) => ipcRenderer.invoke('clip:setActive', active, instanceId),
|
||||||
|
clipSave: (data) => ipcRenderer.invoke('clip:save', data),
|
||||||
|
onClipHotkey: (cb) => {
|
||||||
|
const l = () => cb();
|
||||||
|
ipcRenderer.on('clip:hotkey', l);
|
||||||
|
return () => ipcRenderer.removeListener('clip:hotkey', l);
|
||||||
|
},
|
||||||
|
onUiToast: (cb) => {
|
||||||
|
const l = (_e, t) => cb(t);
|
||||||
|
ipcRenderer.on('ui:toast', l);
|
||||||
|
return () => ipcRenderer.removeListener('ui:toast', l);
|
||||||
|
},
|
||||||
|
friendsJoin: (server, version) => ipcRenderer.invoke('friends:join', server, version),
|
||||||
|
friendsJoinNew: (server, version) => ipcRenderer.invoke('friends:joinNew', server, version),
|
||||||
|
friendsMyServer: () => ipcRenderer.invoke('friends:myServer'),
|
||||||
|
friendsInvite: (targetUuid) => ipcRenderer.invoke('friends:invite', targetUuid),
|
||||||
|
friendsInvites: () => ipcRenderer.invoke('friends:invites'),
|
||||||
|
friendsDismissInvite: (id) => ipcRenderer.invoke('friends:dismissInvite', id),
|
||||||
|
onFriendsInvite: (cb) => {
|
||||||
|
const l = (_e, einladung) => cb(einladung);
|
||||||
|
ipcRenderer.on('friends:invite', l);
|
||||||
|
return () => ipcRenderer.removeListener('friends:invite', l);
|
||||||
|
},
|
||||||
onFriendsRequestCount: (cb) => {
|
onFriendsRequestCount: (cb) => {
|
||||||
const l = (_e, zahl) => cb(zahl);
|
const l = (_e, zahl) => cb(zahl);
|
||||||
ipcRenderer.on('friends:requestCount', l);
|
ipcRenderer.on('friends:requestCount', l);
|
||||||
|
|||||||
+361
-13
@@ -547,6 +547,18 @@ let cfFound = [];
|
|||||||
let mcFound = [];
|
let mcFound = [];
|
||||||
let technicFound = [];
|
let technicFound = [];
|
||||||
let importKind = 'zip';
|
let importKind = 'zip';
|
||||||
|
// Nach der Installation: im Setup ausgewählte Launcher, die noch angeboten werden
|
||||||
|
let importWarteschlange = [];
|
||||||
|
|
||||||
|
// Nach einem Import nicht gleich schließen, solange weitere vorbereitete Launcher warten
|
||||||
|
function importAbgeschlossen(kind) {
|
||||||
|
importWarteschlange = importWarteschlange.filter((k) => k !== kind);
|
||||||
|
if (importWarteschlange.length) {
|
||||||
|
setImportKind(importWarteschlange[0]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hide('modal-import');
|
||||||
|
}
|
||||||
|
|
||||||
async function ensureDefaultMcScan() {
|
async function ensureDefaultMcScan() {
|
||||||
if (importKind !== 'mc') return;
|
if (importKind !== 'mc') return;
|
||||||
@@ -573,8 +585,9 @@ function setImportKind(kind) {
|
|||||||
el('technic-progress').classList.toggle('hidden', importKind !== 'technic' || !el('technic-progress').textContent);
|
el('technic-progress').classList.toggle('hidden', importKind !== 'technic' || !el('technic-progress').textContent);
|
||||||
}
|
}
|
||||||
|
|
||||||
function openImportDialog(kind = 'zip') {
|
function openImportDialog(kind = 'zip', vorauswahl = null) {
|
||||||
if (POPOUT !== 'import') { window.api.popoutOpen('import', { kind }); return; }
|
if (POPOUT !== 'import') { window.api.popoutOpen('import', { kind }); return; }
|
||||||
|
importWarteschlange = [];
|
||||||
mcFound = [];
|
mcFound = [];
|
||||||
el('mc-result').classList.add('hidden');
|
el('mc-result').classList.add('hidden');
|
||||||
el('mc-do').disabled = true;
|
el('mc-do').disabled = true;
|
||||||
@@ -599,6 +612,25 @@ function openImportDialog(kind = 'zip') {
|
|||||||
}
|
}
|
||||||
if (importKind === 'cf') el('cf-path').focus();
|
if (importKind === 'cf') el('cf-path').focus();
|
||||||
if (importKind === 'mmc') el('mmc-path').focus();
|
if (importKind === 'mmc') el('mmc-path').focus();
|
||||||
|
if (Array.isArray(vorauswahl) && vorauswahl.length) uebernehmeSetupVorauswahl(vorauswahl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Vom Setup vorbereitet: die dort angekreuzten Launcher (Art + Ordner) gleich
|
||||||
|
* eintragen und durchsuchen, damit man beim ersten Start nur noch auswählt.
|
||||||
|
*/
|
||||||
|
function uebernehmeSetupVorauswahl(quellen) {
|
||||||
|
const scanner = { mc: scanMc, cf: scanCf, mmc: scanMmc, technic: scanTechnic };
|
||||||
|
importWarteschlange = quellen.map((q) => q && q.kind).filter((k) => scanner[k]);
|
||||||
|
for (const q of quellen) {
|
||||||
|
if (!q || !scanner[q.kind]) continue;
|
||||||
|
if (q.path) el(`${q.kind}-path`).value = q.path;
|
||||||
|
scanner[q.kind]().catch(() => { /* Status zeigt den Fehler im jeweiligen Reiter */ });
|
||||||
|
}
|
||||||
|
if (importWarteschlange.length) {
|
||||||
|
setImportKind(importWarteschlange[0]);
|
||||||
|
toast('Aus deinen anderen Launchern gefunden – wähle aus, was AeroMC übernehmen soll.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runZipImport() {
|
async function runZipImport() {
|
||||||
@@ -812,7 +844,7 @@ async function runMcImport() {
|
|||||||
|
|
||||||
const res = await window.api.mcImport(entries, copy);
|
const res = await window.api.mcImport(entries, copy);
|
||||||
await reload();
|
await reload();
|
||||||
hide('modal-import');
|
importAbgeschlossen('mc');
|
||||||
const n = res.imported.length;
|
const n = res.imported.length;
|
||||||
const f = res.failed.length;
|
const f = res.failed.length;
|
||||||
toast(`${n} Profil${n === 1 ? '' : 'e'} aus dem Minecraft Launcher übernommen${f ? ` · ${f} fehlgeschlagen` : ''}.`, f > 0);
|
toast(`${n} Profil${n === 1 ? '' : 'e'} aus dem Minecraft Launcher übernommen${f ? ` · ${f} fehlgeschlagen` : ''}.`, f > 0);
|
||||||
@@ -882,7 +914,7 @@ async function runCfImport() {
|
|||||||
|
|
||||||
const res = await window.api.cfImport(entries, copy);
|
const res = await window.api.cfImport(entries, copy);
|
||||||
await reload();
|
await reload();
|
||||||
hide('modal-import');
|
importAbgeschlossen('cf');
|
||||||
const n = res.imported.length;
|
const n = res.imported.length;
|
||||||
const f = res.failed.length;
|
const f = res.failed.length;
|
||||||
toast(`${n} CurseForge-Instanz${n === 1 ? '' : 'en'} übernommen${f ? ` · ${f} fehlgeschlagen` : ''}.`, f > 0);
|
toast(`${n} CurseForge-Instanz${n === 1 ? '' : 'en'} übernommen${f ? ` · ${f} fehlgeschlagen` : ''}.`, f > 0);
|
||||||
@@ -952,7 +984,7 @@ async function runMmcImport() {
|
|||||||
|
|
||||||
const res = await window.api.mmcImport(entries, copy);
|
const res = await window.api.mmcImport(entries, copy);
|
||||||
await reload();
|
await reload();
|
||||||
hide('modal-import');
|
importAbgeschlossen('mmc');
|
||||||
const n = res.imported.length;
|
const n = res.imported.length;
|
||||||
const f = res.failed.length;
|
const f = res.failed.length;
|
||||||
toast(`${n} Instanz${n === 1 ? '' : 'en'} übernommen${f ? ` · ${f} fehlgeschlagen` : ''}.`, f > 0);
|
toast(`${n} Instanz${n === 1 ? '' : 'en'} übernommen${f ? ` · ${f} fehlgeschlagen` : ''}.`, f > 0);
|
||||||
@@ -1078,7 +1110,7 @@ async function runTechnicImport() {
|
|||||||
|
|
||||||
const res = await window.api.technicImport(entries, copy);
|
const res = await window.api.technicImport(entries, copy);
|
||||||
await reload();
|
await reload();
|
||||||
hide('modal-import');
|
importAbgeschlossen('technic');
|
||||||
const n = res.imported.length;
|
const n = res.imported.length;
|
||||||
const f = res.failed.length;
|
const f = res.failed.length;
|
||||||
toast(`${n} Technic-Modpack${n === 1 ? '' : 's'} übernommen${f ? ` · ${f} fehlgeschlagen` : ''}.`, f > 0);
|
toast(`${n} Technic-Modpack${n === 1 ? '' : 's'} übernommen${f ? ` · ${f} fehlgeschlagen` : ''}.`, f > 0);
|
||||||
@@ -1517,6 +1549,7 @@ const ICONS_SVG = {
|
|||||||
copy: '<path d="M9 9h10v10H9zM5 15V5h10"/>',
|
copy: '<path d="M9 9h10v10H9zM5 15V5h10"/>',
|
||||||
export: '<path d="M12 21V9m0 0l-4 4m4-4l4 4M4 4h16"/>',
|
export: '<path d="M12 21V9m0 0l-4 4m4-4l4 4M4 4h16"/>',
|
||||||
trash: '<path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/>',
|
trash: '<path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/>',
|
||||||
|
chart: '<path d="M4 20V10M10 20V4M16 20v-7M22 20H2"/>',
|
||||||
};
|
};
|
||||||
function svgIcon(name) {
|
function svgIcon(name) {
|
||||||
const p = ICONS_SVG[name];
|
const p = ICONS_SVG[name];
|
||||||
@@ -1943,6 +1976,8 @@ async function openCreate() {
|
|||||||
el('f-loader').value = 'vanilla';
|
el('f-loader').value = 'vanilla';
|
||||||
el('f-loader-version').value = '';
|
el('f-loader-version').value = '';
|
||||||
el('f-notes').value = '';
|
el('f-notes').value = '';
|
||||||
|
el('f-perf').checked = false;
|
||||||
|
aktualisierePerformanceZeile();
|
||||||
fillIconSelect('grass');
|
fillIconSelect('grass');
|
||||||
resetIconControls();
|
resetIconControls();
|
||||||
el('f-advanced').classList.add('hidden');
|
el('f-advanced').classList.add('hidden');
|
||||||
@@ -1966,6 +2001,7 @@ async function openEdit(inst) {
|
|||||||
el('f-loader').value = inst.minecraft.loader || 'vanilla';
|
el('f-loader').value = inst.minecraft.loader || 'vanilla';
|
||||||
el('f-loader-version').value = inst.minecraft.loaderVersion || '';
|
el('f-loader-version').value = inst.minecraft.loaderVersion || '';
|
||||||
el('f-notes').value = inst.notes || '';
|
el('f-notes').value = inst.notes || '';
|
||||||
|
aktualisierePerformanceZeile();
|
||||||
fillIconSelect(inst.icon);
|
fillIconSelect(inst.icon);
|
||||||
resetIconControls();
|
resetIconControls();
|
||||||
if (inst.customImage) {
|
if (inst.customImage) {
|
||||||
@@ -1990,6 +2026,12 @@ async function openEdit(inst) {
|
|||||||
await updateLoaderVersions(inst.minecraft.loaderVersion);
|
await updateLoaderVersions(inst.minecraft.loaderVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Performance-Paket nur beim Anlegen und nur für Loader, für die es die Mods gibt
|
||||||
|
const PERFORMANCE_LOADER = ['fabric', 'quilt', 'neoforge'];
|
||||||
|
function aktualisierePerformanceZeile() {
|
||||||
|
el('f-perf-row').classList.toggle('hidden', !!editingId || !PERFORMANCE_LOADER.includes(el('f-loader').value));
|
||||||
|
}
|
||||||
|
|
||||||
async function saveInstance() {
|
async function saveInstance() {
|
||||||
const name = el('f-name').value.trim();
|
const name = el('f-name').value.trim();
|
||||||
if (!name) { toast('Bitte einen Namen angeben.', true); el('f-name').focus(); return; }
|
if (!name) { toast('Bitte einen Namen angeben.', true); el('f-name').focus(); return; }
|
||||||
@@ -2029,6 +2071,11 @@ async function saveInstance() {
|
|||||||
} else {
|
} else {
|
||||||
const created = await window.api.createInstance(payload);
|
const created = await window.api.createInstance(payload);
|
||||||
targetId = created.id;
|
targetId = created.id;
|
||||||
|
// läuft im Hauptprozess weiter, auch wenn dieses Fenster gleich zugeht –
|
||||||
|
// das Ergebnis meldet das Hauptfenster
|
||||||
|
if (el('f-perf').checked && PERFORMANCE_LOADER.includes(payload.minecraft.loader)) {
|
||||||
|
window.api.installPerformancePack(targetId).catch(() => {});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// eigenes Bild anwenden bzw. entfernen
|
// eigenes Bild anwenden bzw. entfernen
|
||||||
if (pendingImagePath) await window.api.setInstanceImage(targetId, pendingImagePath);
|
if (pendingImagePath) await window.api.setInstanceImage(targetId, pendingImagePath);
|
||||||
@@ -2060,7 +2107,7 @@ async function launch(id, accountId, launchOpts) {
|
|||||||
const res = await window.api.launchInstance(id, accountId, launchOpts || null);
|
const res = await window.api.launchInstance(id, accountId, launchOpts || null);
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
status('Läuft (PID ' + res.pid + ')');
|
status('Läuft (PID ' + res.pid + ')');
|
||||||
toast('Minecraft gestartet.');
|
toast(res.modUpdates ? `Minecraft gestartet – ${res.modUpdates} Mod${res.modUpdates === 1 ? '' : 's'} vorher aktualisiert.` : 'Minecraft gestartet.');
|
||||||
const s = await window.api.getSettings();
|
const s = await window.api.getSettings();
|
||||||
if (s.showConsoleOnLaunch) openConsole(); // nur wenn gewünscht
|
if (s.showConsoleOnLaunch) openConsole(); // nur wenn gewünscht
|
||||||
} else if (res.needAuth) {
|
} else if (res.needAuth) {
|
||||||
@@ -2585,7 +2632,7 @@ async function zeigeAbsturzbericht(instanzId) {
|
|||||||
|
|
||||||
// Fortschrittsmeldungen der Download-/Start-Pipeline
|
// Fortschrittsmeldungen der Download-/Start-Pipeline
|
||||||
function onLaunchEvt(evt) {
|
function onLaunchEvt(evt) {
|
||||||
const labels = { version: 'Version', client: 'Client', libraries: 'Libraries', assets: 'Assets', loader: 'Loader', java: 'Java', launch: 'Start', modpack: 'Modpack', backup: 'Sicherung' };
|
const labels = { version: 'Version', client: 'Client', libraries: 'Libraries', assets: 'Assets', loader: 'Loader', java: 'Java', launch: 'Start', modpack: 'Modpack', backup: 'Sicherung', 'mod-updates': 'Mod-Updates' };
|
||||||
if (evt.phase === 'log') {
|
if (evt.phase === 'log') {
|
||||||
appendConsole(evt.line);
|
appendConsole(evt.line);
|
||||||
return;
|
return;
|
||||||
@@ -2805,6 +2852,8 @@ function speichereRecordEinstellungen() {
|
|||||||
|
|
||||||
async function openRecording() {
|
async function openRecording() {
|
||||||
if (POPOUT !== 'record') { window.api.popoutOpen('record', { instanceId: currentDetailId }); return; }
|
if (POPOUT !== 'record') { window.api.popoutOpen('record', { instanceId: currentDetailId }); return; }
|
||||||
|
// Clip-Modus läuft im Hintergrund weiter -> Fenster nur wieder zeigen, Erfassung nicht neu starten
|
||||||
|
if (clipAktiv) { show('modal-record'); return; }
|
||||||
await ladeRecordEinstellungen();
|
await ladeRecordEinstellungen();
|
||||||
await aktualisiereRecordTitel();
|
await aktualisiereRecordTitel();
|
||||||
// beim erneuten Öffnen (popout:reopen) eine evtl. noch offene Vorschau
|
// beim erneuten Öffnen (popout:reopen) eine evtl. noch offene Vorschau
|
||||||
@@ -3047,7 +3096,7 @@ function merkeTatsaechlicheGroesse(stream) {
|
|||||||
// Bild mit den neuen Grenzwerten neu erfassen (nur möglich, solange nicht
|
// Bild mit den neuen Grenzwerten neu erfassen (nur möglich, solange nicht
|
||||||
// aufgenommen wird, siehe Sperre der Auswahlfelder in updateVideoSummary)
|
// aufgenommen wird, siehe Sperre der Auswahlfelder in updateVideoSummary)
|
||||||
async function wendeVideoEinstellungenAn() {
|
async function wendeVideoEinstellungenAn() {
|
||||||
if (!mcCurrentSource || !mcVideoStream || recordingActive()) return;
|
if (!mcCurrentSource || !mcVideoStream || recordingActive() || clipAktiv) return;
|
||||||
stoppeVideoErfassung();
|
stoppeVideoErfassung();
|
||||||
mcVideoStream = await erfasseFensterVideo(mcCurrentSource);
|
mcVideoStream = await erfasseFensterVideo(mcCurrentSource);
|
||||||
merkeTatsaechlicheGroesse(mcVideoStream);
|
merkeTatsaechlicheGroesse(mcVideoStream);
|
||||||
@@ -3059,7 +3108,7 @@ function updateVideoSummary() {
|
|||||||
const groesse = mcActualSize ? ` (${mcActualSize.width}×${mcActualSize.height})` : '';
|
const groesse = mcActualSize ? ` (${mcActualSize.width}×${mcActualSize.height})` : '';
|
||||||
el('record-video-summary').textContent =
|
el('record-video-summary').textContent =
|
||||||
RESOLUTION_LABEL[mcResolution] + groesse + ' · ' + QUALITAET_LABEL[mcQuality] + ' · ' + mcFramerate + ' fps';
|
RESOLUTION_LABEL[mcResolution] + groesse + ' · ' + QUALITAET_LABEL[mcQuality] + ' · ' + mcFramerate + ' fps';
|
||||||
const aktiv = recordingActive();
|
const aktiv = recordingActive() || clipAktiv;
|
||||||
el('record-resolution').disabled = aktiv;
|
el('record-resolution').disabled = aktiv;
|
||||||
el('record-quality').disabled = aktiv;
|
el('record-quality').disabled = aktiv;
|
||||||
el('record-fps').disabled = aktiv;
|
el('record-fps').disabled = aktiv;
|
||||||
@@ -3335,6 +3384,138 @@ async function onMcRecordingStopped() {
|
|||||||
mcRecorder = null;
|
mcRecorder = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Clip-Modus ----------
|
||||||
|
// Nimmt laufend in kurzen, jeweils eigenständigen WebM-Abschnitten auf (jeder
|
||||||
|
// beginnt mit Kopfdaten + Schlüsselbild, lässt sich also einzeln abspielen) und
|
||||||
|
// behält nur so viele, wie für die gewählte Clip-Länge nötig sind. Beim
|
||||||
|
// Speichern setzt ffmpeg die Abschnitte zusammen und schneidet vorne auf die
|
||||||
|
// gewünschte Länge zu. Normale Aufnahme und Clip-Modus schließen sich aus
|
||||||
|
// (beide teilen sich Tonmischung und Bildquelle).
|
||||||
|
const CLIP_ABSCHNITT_MS = 10000;
|
||||||
|
let clipAktiv = false;
|
||||||
|
let clipStream = null;
|
||||||
|
let clipAbschnitte = []; // fertige Abschnitte { start, teile, fertig }
|
||||||
|
let clipLaufend = null; // gerade aufnehmender Abschnitt
|
||||||
|
let clipWechsel = null;
|
||||||
|
let clipSpeichert = false;
|
||||||
|
|
||||||
|
function clipLaenge() { return Number(el('clip-length').value) || 60; }
|
||||||
|
|
||||||
|
function neuerClipAbschnitt(mime, bitrate) {
|
||||||
|
const teile = [];
|
||||||
|
const rec = new MediaRecorder(clipStream, { mimeType: mime, videoBitsPerSecond: bitrate });
|
||||||
|
const abschnitt = { start: Date.now(), teile, rec, ende: null };
|
||||||
|
abschnitt.fertig = new Promise((resolve) => { rec.onstop = () => { abschnitt.ende = Date.now(); resolve(); }; });
|
||||||
|
rec.ondataavailable = (e) => { if (e.data && e.data.size) teile.push(e.data); };
|
||||||
|
rec.start(1000);
|
||||||
|
return abschnitt;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function starteClipModus() {
|
||||||
|
if (!mcVideoStream || recordingActive() || clipAktiv) return;
|
||||||
|
mcAudioTrack = await buildCombinedAudioTrack();
|
||||||
|
clipStream = new MediaStream([
|
||||||
|
...mcVideoStream.getVideoTracks(),
|
||||||
|
...(mcAudioTrack ? [mcAudioTrack] : []),
|
||||||
|
]);
|
||||||
|
const mime = MediaRecorder.isTypeSupported('video/webm;codecs=vp9,opus')
|
||||||
|
? 'video/webm;codecs=vp9,opus' : 'video/webm;codecs=vp8,opus';
|
||||||
|
const bitrate = QUALITAETEN[mcQuality];
|
||||||
|
clipAbschnitte = [];
|
||||||
|
clipLaufend = neuerClipAbschnitt(mime, bitrate);
|
||||||
|
clipWechsel = setInterval(() => {
|
||||||
|
// erst den neuen Abschnitt starten, dann den alten beenden -> keine Lücke
|
||||||
|
const alt = clipLaufend;
|
||||||
|
clipLaufend = neuerClipAbschnitt(mime, bitrate);
|
||||||
|
alt.rec.stop();
|
||||||
|
clipAbschnitte.push(alt);
|
||||||
|
// nur behalten, was für die längste mögliche Clip-Länge noch gebraucht wird
|
||||||
|
const grenze = Date.now() - (clipLaenge() * 1000 + CLIP_ABSCHNITT_MS * 2);
|
||||||
|
clipAbschnitte = clipAbschnitte.filter((a) => (a.ende || Date.now()) >= grenze);
|
||||||
|
}, CLIP_ABSCHNITT_MS);
|
||||||
|
clipAktiv = true;
|
||||||
|
|
||||||
|
const kuerzel = await window.api.clipSetActive(true, currentDetailId);
|
||||||
|
el('clip-toggle').checked = true;
|
||||||
|
el('clip-save').disabled = false;
|
||||||
|
el('clip-length').disabled = true;
|
||||||
|
el('record-toggle').disabled = true;
|
||||||
|
el('record-toggle').title = 'Während der Clip-Modus läuft, ist keine normale Aufnahme möglich.';
|
||||||
|
el('record-audio-edit').disabled = true;
|
||||||
|
el('record-video-edit').disabled = true;
|
||||||
|
el('clip-status').textContent = kuerzel
|
||||||
|
? `Läuft · ${kuerzel.replace('Control', 'Strg')} speichert die letzten ${clipLaenge()} Sek. – auch im Spiel`
|
||||||
|
: `Läuft · „Clip speichern“ sichert die letzten ${clipLaenge()} Sek. (kein Tastenkürzel frei)`;
|
||||||
|
el('clip-bar').classList.add('aktiv');
|
||||||
|
updateVideoSummary();
|
||||||
|
}
|
||||||
|
|
||||||
|
function stoppeClipModus() {
|
||||||
|
if (!clipAktiv) return;
|
||||||
|
clearInterval(clipWechsel);
|
||||||
|
clipWechsel = null;
|
||||||
|
try { if (clipLaufend && clipLaufend.rec.state !== 'inactive') clipLaufend.rec.stop(); } catch { /* egal */ }
|
||||||
|
clipLaufend = null;
|
||||||
|
clipAbschnitte = [];
|
||||||
|
clipStream = null;
|
||||||
|
cleanupAudioMix();
|
||||||
|
mcAudioTrack = null;
|
||||||
|
clipAktiv = false;
|
||||||
|
window.api.clipSetActive(false, currentDetailId);
|
||||||
|
el('clip-toggle').checked = false;
|
||||||
|
el('clip-save').disabled = true;
|
||||||
|
el('clip-length').disabled = false;
|
||||||
|
el('record-toggle').disabled = false;
|
||||||
|
el('record-toggle').title = '';
|
||||||
|
el('record-audio-edit').disabled = false;
|
||||||
|
el('record-video-edit').disabled = false;
|
||||||
|
el('clip-status').textContent = 'Speichert auf Tastendruck die letzten Sekunden – auch bei geschlossenem Fenster.';
|
||||||
|
el('clip-bar').classList.remove('aktiv');
|
||||||
|
updateVideoSummary();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function speichereClip() {
|
||||||
|
if (!clipAktiv || clipSpeichert || !clipLaufend) return;
|
||||||
|
clipSpeichert = true;
|
||||||
|
el('clip-save').disabled = true;
|
||||||
|
el('clip-save').textContent = 'Wird gespeichert…';
|
||||||
|
try {
|
||||||
|
const jetzt = Date.now();
|
||||||
|
const laenge = clipLaenge();
|
||||||
|
const laufend = clipLaufend;
|
||||||
|
// Daten des laufenden Abschnitts bis jetzt einsammeln
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
laufend.rec.addEventListener('dataavailable', () => resolve(), { once: true });
|
||||||
|
try { laufend.rec.requestData(); } catch { resolve(); }
|
||||||
|
setTimeout(resolve, 1500);
|
||||||
|
});
|
||||||
|
const fertige = clipAbschnitte.filter((a) => (a.ende || jetzt) > jetzt - laenge * 1000);
|
||||||
|
await Promise.all(fertige.map((a) => a.fertig)); // gerade beendeter Abschnitt liefert evtl. noch Daten nach
|
||||||
|
const abschnitte = [...fertige, laufend].filter((a) => a.teile.length);
|
||||||
|
if (!abschnitte.length) throw new Error('Noch nichts aufgenommen.');
|
||||||
|
const daten = await Promise.all(abschnitte.map((a) => new Blob(a.teile, { type: 'video/webm' }).arrayBuffer()));
|
||||||
|
const gesamt = (jetzt - abschnitte[0].start) / 1000;
|
||||||
|
const inst = await window.api.getInstance(currentDetailId);
|
||||||
|
const res = await window.api.clipSave({
|
||||||
|
instanceName: inst ? inst.name : 'Instanz',
|
||||||
|
instanceId: currentDetailId,
|
||||||
|
abschnitte: daten,
|
||||||
|
serverLabel: await aktuellerAufnahmeServer(),
|
||||||
|
fps: mcFramerate,
|
||||||
|
vorneAbschneiden: Math.max(0, gesamt - laenge),
|
||||||
|
laenge: Math.min(laenge, Math.round(gesamt)),
|
||||||
|
});
|
||||||
|
if (res.ok) toast(`Clip gespeichert (${Math.min(laenge, Math.round(gesamt))} Sek.).`);
|
||||||
|
else toast(res.message || 'Clip konnte nicht gespeichert werden.', true);
|
||||||
|
} catch (err) {
|
||||||
|
toast('Clip konnte nicht gespeichert werden: ' + err.message, true);
|
||||||
|
} finally {
|
||||||
|
clipSpeichert = false;
|
||||||
|
el('clip-save').textContent = 'Clip speichern';
|
||||||
|
el('clip-save').disabled = !clipAktiv;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function updateMcTimer() {
|
function updateMcTimer() {
|
||||||
const s = Math.floor((Date.now() - mcStartedAt) / 1000);
|
const s = Math.floor((Date.now() - mcStartedAt) / 1000);
|
||||||
el('record-timer').textContent = String(Math.floor(s / 60)).padStart(2, '0') + ':' + String(s % 60).padStart(2, '0');
|
el('record-timer').textContent = String(Math.floor(s / 60)).padStart(2, '0') + ':' + String(s % 60).padStart(2, '0');
|
||||||
@@ -3544,6 +3725,59 @@ async function deleteSelectedRecords() {
|
|||||||
// ---------- Freunde ----------
|
// ---------- Freunde ----------
|
||||||
let friendsAutoRefresh = null;
|
let friendsAutoRefresh = null;
|
||||||
|
|
||||||
|
// ---------- Spielzeit-Statistik ----------
|
||||||
|
// Gesamtwerte als Kennzahlen, darunter eine nach Spielzeit sortierte Balkenliste
|
||||||
|
// (eine Reihe = eine Instanz, Balkenlänge relativ zur meistgespielten).
|
||||||
|
async function openStats() {
|
||||||
|
if (POPOUT !== 'stats') { window.api.popoutOpen('stats'); return; }
|
||||||
|
show('modal-stats');
|
||||||
|
await ladeStatistik();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ladeStatistik() {
|
||||||
|
const alle = await window.api.listInstances();
|
||||||
|
const gespielt = alle.filter((i) => (i.totalPlaySeconds || 0) > 0)
|
||||||
|
.sort((a, b) => (b.totalPlaySeconds || 0) - (a.totalPlaySeconds || 0));
|
||||||
|
const gesamt = gespielt.reduce((s, i) => s + (i.totalPlaySeconds || 0), 0);
|
||||||
|
const starts = alle.reduce((s, i) => s + (i.launchCount || 0), 0);
|
||||||
|
const zuletzt = alle.filter((i) => i.lastPlayed).sort((a, b) => String(b.lastPlayed).localeCompare(String(a.lastPlayed)))[0];
|
||||||
|
const top = gespielt[0];
|
||||||
|
|
||||||
|
const kachel = (label, wert, sub) => `<div class="stat-tile"><div class="stat-label">${escapeHtml(label)}</div>
|
||||||
|
<div class="stat-value" title="${escapeHtml(wert)}">${escapeHtml(wert)}</div>${sub ? `<div class="stat-sub" title="${escapeHtml(sub)}">${escapeHtml(sub)}</div>` : ''}</div>`;
|
||||||
|
el('stats-tiles').innerHTML =
|
||||||
|
kachel('Spielzeit gesamt', fmtDuration(gesamt), `${gespielt.length} von ${alle.length} Instanzen`)
|
||||||
|
+ kachel('Spielstarts', starts ? String(starts) : '–', 'seit diesem Update')
|
||||||
|
+ kachel('Meistgespielt', top ? top.name : '–', top ? fmtDuration(top.totalPlaySeconds) : '')
|
||||||
|
+ kachel('Zuletzt gespielt', zuletzt ? zuletzt.name : '–', zuletzt ? fmtDate(zuletzt.lastPlayed) : '');
|
||||||
|
|
||||||
|
const box = el('stats-list');
|
||||||
|
box.innerHTML = '';
|
||||||
|
if (!gespielt.length) {
|
||||||
|
box.innerHTML = '<div class="friends-empty">Noch keine Spielzeit erfasst – starte eine Instanz.</div>';
|
||||||
|
}
|
||||||
|
const max = top ? top.totalPlaySeconds : 1;
|
||||||
|
for (const inst of gespielt) {
|
||||||
|
const sek = inst.totalPlaySeconds || 0;
|
||||||
|
const anteil = gesamt ? Math.round((sek / gesamt) * 100) : 0;
|
||||||
|
const loader = inst.minecraft.loader && inst.minecraft.loader !== 'vanilla'
|
||||||
|
? (LOADER_LABEL[inst.minecraft.loader] || inst.minecraft.loader) : 'Vanilla';
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'stat-row';
|
||||||
|
row.setAttribute('role', 'listitem');
|
||||||
|
// Tooltip mit allen Details (Balken allein trägt nur die Länge)
|
||||||
|
row.title = `${inst.name}\nSpielzeit: ${fmtDuration(sek)} (${anteil} % der Gesamtzeit)`
|
||||||
|
+ (inst.launchCount ? `\nStarts: ${inst.launchCount}` : '')
|
||||||
|
+ `\nZuletzt gespielt: ${fmtDate(inst.lastPlayed)}`;
|
||||||
|
row.innerHTML = `<div class="stat-name"><b>${escapeHtml(inst.name)}</b><span>${escapeHtml(inst.minecraft.version || '?')} · ${escapeHtml(loader)}</span></div>
|
||||||
|
<div class="stat-track"><div class="stat-bar" style="width:${Math.max(1, (sek / max) * 100).toFixed(1)}%"></div></div>
|
||||||
|
<div class="stat-value-cell">${escapeHtml(fmtDuration(sek))}</div>`;
|
||||||
|
box.appendChild(row);
|
||||||
|
}
|
||||||
|
const nie = alle.length - gespielt.length;
|
||||||
|
el('stats-unplayed').textContent = nie ? `${nie} Instanz${nie === 1 ? '' : 'en'} noch nie gespielt.` : '';
|
||||||
|
}
|
||||||
|
|
||||||
async function openFriends() {
|
async function openFriends() {
|
||||||
if (POPOUT !== 'friends') { window.api.popoutOpen('friends'); return; }
|
if (POPOUT !== 'friends') { window.api.popoutOpen('friends'); return; }
|
||||||
show('modal-friends');
|
show('modal-friends');
|
||||||
@@ -3558,16 +3792,40 @@ async function ladeFreunde() {
|
|||||||
el('friends-content').classList.toggle('hidden', !aktiv);
|
el('friends-content').classList.toggle('hidden', !aktiv);
|
||||||
if (!aktiv) return;
|
if (!aktiv) return;
|
||||||
|
|
||||||
const [anfragenRes, gesendetRes, freundeRes, einstellungen] = await Promise.all([
|
const [anfragenRes, gesendetRes, freundeRes, einstellungen, einladungenRes, meinServer] = await Promise.all([
|
||||||
window.api.friendsRequests(),
|
window.api.friendsRequests(),
|
||||||
window.api.friendsSentRequests(),
|
window.api.friendsSentRequests(),
|
||||||
window.api.friendsList(),
|
window.api.friendsList(),
|
||||||
window.api.getSettings(),
|
window.api.getSettings(),
|
||||||
|
window.api.friendsInvites(),
|
||||||
|
window.api.friendsMyServer(),
|
||||||
]);
|
]);
|
||||||
const spitznamen = einstellungen.friendNicknames || {};
|
const spitznamen = einstellungen.friendNicknames || {};
|
||||||
|
|
||||||
const reqBox = el('friends-requests-box');
|
const reqBox = el('friends-requests-box');
|
||||||
reqBox.innerHTML = '';
|
reqBox.innerHTML = '';
|
||||||
|
if (einladungenRes.ok && einladungenRes.list.length) {
|
||||||
|
const titel = document.createElement('div');
|
||||||
|
titel.className = 'friends-section-title';
|
||||||
|
titel.textContent = 'Einladungen';
|
||||||
|
reqBox.appendChild(titel);
|
||||||
|
for (const inv of einladungenRes.list) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'friend-row';
|
||||||
|
const name = spitznamen[inv.fromUuid] || inv.fromUsername || inv.fromUuid;
|
||||||
|
row.innerHTML = `<img class="friend-avatar" src="https://crafthead.net/cube/${escapeHtml(inv.fromUuid)}/64" alt="" />
|
||||||
|
<div class="friend-info"><div class="friend-name">${escapeHtml(name)}</div><div class="friend-status">lädt dich ein · ${escapeHtml(inv.server)}${inv.version ? ' · ' + escapeHtml(inv.version) : ''}</div></div>
|
||||||
|
<button class="btn btn-mini btn-primary" data-act="join">Beitreten</button>
|
||||||
|
<button class="btn btn-mini" data-act="dismiss" title="Ablehnen">✕</button>`;
|
||||||
|
row.querySelector('.friend-avatar').addEventListener('error', function () { this.remove(); });
|
||||||
|
row.querySelector('[data-act="join"]').addEventListener('click', () => freundBeitreten(inv.server, inv.version, inv.id));
|
||||||
|
row.querySelector('[data-act="dismiss"]').addEventListener('click', async () => {
|
||||||
|
await window.api.friendsDismissInvite(inv.id);
|
||||||
|
ladeFreunde();
|
||||||
|
});
|
||||||
|
reqBox.appendChild(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (anfragenRes.ok && anfragenRes.list.length) {
|
if (anfragenRes.ok && anfragenRes.list.length) {
|
||||||
const titel = document.createElement('div');
|
const titel = document.createElement('div');
|
||||||
titel.className = 'friends-section-title';
|
titel.className = 'friends-section-title';
|
||||||
@@ -3630,15 +3888,35 @@ async function ladeFreunde() {
|
|||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
row.className = 'friend-row';
|
row.className = 'friend-row';
|
||||||
const anzeigeName = spitznamen[f.uuid] || f.username || f.uuid;
|
const anzeigeName = spitznamen[f.uuid] || f.username || f.uuid;
|
||||||
|
const spielt = f.version || f.instance;
|
||||||
const status = f.online
|
const status = f.online
|
||||||
? (f.instance ? `spielt ${f.instance}${f.server ? ' · ' + f.server : ''}` : 'online')
|
? (f.server ? `auf ${f.server}${f.version ? ' · ' + f.version : ''}` : (spielt ? `spielt ${spielt}` : 'online'))
|
||||||
: 'offline';
|
: 'offline';
|
||||||
|
const kannBeitreten = f.online && f.server && (!meinServer || meinServer.server !== f.server);
|
||||||
|
const kannEinladen = f.online && meinServer && meinServer.server !== f.server;
|
||||||
row.innerHTML = `<span class="friend-dot${f.online ? ' online' : ''}"></span>
|
row.innerHTML = `<span class="friend-dot${f.online ? ' online' : ''}"></span>
|
||||||
<img class="friend-avatar" src="https://crafthead.net/cube/${escapeHtml(f.uuid)}/64" alt="" />
|
<img class="friend-avatar" src="https://crafthead.net/cube/${escapeHtml(f.uuid)}/64" alt="" />
|
||||||
<div class="friend-info"><div class="friend-name">${escapeHtml(anzeigeName)}</div><div class="friend-status">${escapeHtml(status)}</div></div>
|
<div class="friend-info"><div class="friend-name">${escapeHtml(anzeigeName)}</div><div class="friend-status">${escapeHtml(status)}</div></div>
|
||||||
|
${kannBeitreten ? '<button class="btn btn-mini btn-primary" data-act="join" title="Passende Instanz starten und diesem Server beitreten">Beitreten</button>' : ''}
|
||||||
|
${kannEinladen ? `<button class="btn btn-mini" data-act="invite" title="Auf ${escapeHtml(meinServer.server)} einladen">Einladen</button>` : ''}
|
||||||
<button class="btn btn-mini" data-act="nick" title="Spitzname">✎</button>
|
<button class="btn btn-mini" data-act="nick" title="Spitzname">✎</button>
|
||||||
<button class="btn btn-mini btn-danger" data-act="remove" title="Entfernen">🗑</button>`;
|
<button class="btn btn-mini btn-danger" data-act="remove" title="Entfernen">🗑</button>`;
|
||||||
row.querySelector('.friend-avatar').addEventListener('error', function () { this.remove(); });
|
row.querySelector('.friend-avatar').addEventListener('error', function () { this.remove(); });
|
||||||
|
if (kannBeitreten) row.querySelector('[data-act="join"]').addEventListener('click', () => freundBeitreten(f.server, f.version));
|
||||||
|
if (kannEinladen) {
|
||||||
|
row.querySelector('[data-act="invite"]').addEventListener('click', async (ev) => {
|
||||||
|
const btn = ev.currentTarget;
|
||||||
|
btn.disabled = true;
|
||||||
|
const res = await window.api.friendsInvite(f.uuid);
|
||||||
|
if (res.ok) {
|
||||||
|
toast(`${anzeigeName} wurde auf ${meinServer.server} eingeladen.`);
|
||||||
|
btn.textContent = 'Eingeladen';
|
||||||
|
} else {
|
||||||
|
toast(res.message || 'Einladung fehlgeschlagen.', true);
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
row.querySelector('[data-act="nick"]').addEventListener('click', async () => {
|
row.querySelector('[data-act="nick"]').addEventListener('click', async () => {
|
||||||
const wert = await askPrompt({
|
const wert = await askPrompt({
|
||||||
title: 'Spitzname',
|
title: 'Spitzname',
|
||||||
@@ -3660,6 +3938,36 @@ async function ladeFreunde() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Einem Freund auf seinen Server folgen: passende eigene Instanz (gleiche
|
||||||
|
* Version, Server am besten schon in der Liste) per Quick Play starten.
|
||||||
|
* Gibt es keine, wird angeboten, eine Vanilla-Instanz mit seiner Version anzulegen.
|
||||||
|
*/
|
||||||
|
async function freundBeitreten(server, version, einladungId) {
|
||||||
|
const res = await window.api.friendsJoin(server, version);
|
||||||
|
if (res.ok) {
|
||||||
|
toast(`Starte „${res.instanceName}" und verbinde mit ${server} …`);
|
||||||
|
if (einladungId) { await window.api.friendsDismissInvite(einladungId); ladeFreunde(); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (res.noInstance) {
|
||||||
|
if (!version) {
|
||||||
|
toast('Keine passende Instanz gefunden – die Minecraft-Version deines Freundes ist unbekannt.', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
askConfirm('Keine passende Instanz',
|
||||||
|
`Für Minecraft ${version} gibt es noch keine Instanz. Eine neue Vanilla-Instanz anlegen und ${server} beitreten?`,
|
||||||
|
'Anlegen & beitreten', async () => {
|
||||||
|
const neu = await window.api.friendsJoinNew(server, version);
|
||||||
|
if (!neu.ok) { toast(neu.message || 'Beitreten fehlgeschlagen.', true); return; }
|
||||||
|
toast(`„${neu.instanceName}" angelegt – Minecraft wird gestartet …`);
|
||||||
|
if (einladungId) { await window.api.friendsDismissInvite(einladungId); ladeFreunde(); }
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast(res.message || 'Beitreten fehlgeschlagen.', true);
|
||||||
|
}
|
||||||
|
|
||||||
async function sendeFreundschaftsanfrage() {
|
async function sendeFreundschaftsanfrage() {
|
||||||
const input = el('friends-add-input');
|
const input = el('friends-add-input');
|
||||||
const name = input.value.trim();
|
const name = input.value.trim();
|
||||||
@@ -4093,6 +4401,8 @@ function setSettingsPane(paneId) {
|
|||||||
async function openSettings(pane) {
|
async function openSettings(pane) {
|
||||||
if (POPOUT !== 'settings') { window.api.popoutOpen('settings', { pane }); return; }
|
if (POPOUT !== 'settings') { window.api.popoutOpen('settings', { pane }); return; }
|
||||||
setSettingsPane(pane || new URLSearchParams(location.search).get('pane') || 'pane-general');
|
setSettingsPane(pane || new URLSearchParams(location.search).get('pane') || 'pane-general');
|
||||||
|
// Ergebnis der Update-Suche vom Start übernehmen (dieses Fenster gab es da noch nicht)
|
||||||
|
window.api.updateLastCheck().then(renderUpdateStatus).catch(() => {});
|
||||||
|
|
||||||
const s = await window.api.getSettings();
|
const s = await window.api.getSettings();
|
||||||
el('s-theme').value = s.theme || 'dark';
|
el('s-theme').value = s.theme || 'dark';
|
||||||
@@ -4119,6 +4429,8 @@ async function openSettings(pane) {
|
|||||||
el('s-bedrock').checked = s.bedrockEnabled !== false;
|
el('s-bedrock').checked = s.bedrockEnabled !== false;
|
||||||
el('s-notify').checked = s.desktopNotifications !== false;
|
el('s-notify').checked = s.desktopNotifications !== false;
|
||||||
el('s-friends').checked = s.friendsEnabled !== false;
|
el('s-friends').checked = s.friendsEnabled !== false;
|
||||||
|
el('s-friends-server').checked = s.friendsShareServer !== false;
|
||||||
|
el('s-auto-mod-updates').checked = !!s.autoModUpdates;
|
||||||
el('s-autostart').checked = !!s.autostart;
|
el('s-autostart').checked = !!s.autostart;
|
||||||
el('s-auto-java').checked = s.autoJava !== false;
|
el('s-auto-java').checked = s.autoJava !== false;
|
||||||
el('s-autobackup').checked = !!s.autoBackupEnabled;
|
el('s-autobackup').checked = !!s.autoBackupEnabled;
|
||||||
@@ -4248,6 +4560,8 @@ async function saveSettings() {
|
|||||||
bedrockEnabled: el('s-bedrock').checked,
|
bedrockEnabled: el('s-bedrock').checked,
|
||||||
desktopNotifications: el('s-notify').checked,
|
desktopNotifications: el('s-notify').checked,
|
||||||
friendsEnabled: el('s-friends').checked,
|
friendsEnabled: el('s-friends').checked,
|
||||||
|
friendsShareServer: el('s-friends-server').checked,
|
||||||
|
autoModUpdates: el('s-auto-mod-updates').checked,
|
||||||
autostart: el('s-autostart').checked,
|
autostart: el('s-autostart').checked,
|
||||||
autoJava: el('s-auto-java').checked,
|
autoJava: el('s-auto-java').checked,
|
||||||
autoBackupEnabled: el('s-autobackup').checked,
|
autoBackupEnabled: el('s-autobackup').checked,
|
||||||
@@ -4905,6 +5219,7 @@ function wire() {
|
|||||||
|
|
||||||
// Freunde
|
// Freunde
|
||||||
el('btn-friends').addEventListener('click', openFriends);
|
el('btn-friends').addEventListener('click', openFriends);
|
||||||
|
el('btn-stats').addEventListener('click', openStats);
|
||||||
el('friends-add-btn').addEventListener('click', sendeFreundschaftsanfrage);
|
el('friends-add-btn').addEventListener('click', sendeFreundschaftsanfrage);
|
||||||
el('friends-add-input').addEventListener('keydown', (e) => {
|
el('friends-add-input').addEventListener('keydown', (e) => {
|
||||||
if (e.key === 'Enter') sendeFreundschaftsanfrage();
|
if (e.key === 'Enter') sendeFreundschaftsanfrage();
|
||||||
@@ -4924,7 +5239,7 @@ function wire() {
|
|||||||
// Instanz-Dialog
|
// Instanz-Dialog
|
||||||
el('btn-save-instance').addEventListener('click', saveInstance);
|
el('btn-save-instance').addEventListener('click', saveInstance);
|
||||||
el('f-version-type').addEventListener('change', () => fillVersionSelect(el('f-version').value));
|
el('f-version-type').addEventListener('change', () => fillVersionSelect(el('f-version').value));
|
||||||
el('f-loader').addEventListener('change', () => updateLoaderVersions());
|
el('f-loader').addEventListener('change', () => { updateLoaderVersions(); aktualisierePerformanceZeile(); });
|
||||||
el('f-version').addEventListener('change', () => updateLoaderVersions());
|
el('f-version').addEventListener('change', () => updateLoaderVersions());
|
||||||
|
|
||||||
el('f-optifine-install').addEventListener('click', async () => {
|
el('f-optifine-install').addEventListener('click', async () => {
|
||||||
@@ -5008,9 +5323,19 @@ function wire() {
|
|||||||
el('p-record').addEventListener('click', openRecording);
|
el('p-record').addEventListener('click', openRecording);
|
||||||
el('record-search-retry').addEventListener('click', startWindowSearch);
|
el('record-search-retry').addEventListener('click', startWindowSearch);
|
||||||
el('record-toggle').addEventListener('click', () => {
|
el('record-toggle').addEventListener('click', () => {
|
||||||
|
if (clipAktiv) return;
|
||||||
if (!recordingActive()) startMcRecording();
|
if (!recordingActive()) startMcRecording();
|
||||||
else stopMcRecording();
|
else stopMcRecording();
|
||||||
});
|
});
|
||||||
|
el('clip-toggle').addEventListener('change', async (e) => {
|
||||||
|
if (e.target.checked) {
|
||||||
|
if (recordingActive()) { e.target.checked = false; toast('Bitte erst die laufende Aufnahme beenden.', true); return; }
|
||||||
|
await starteClipModus();
|
||||||
|
} else {
|
||||||
|
stoppeClipModus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
el('clip-save').addEventListener('click', speichereClip);
|
||||||
el('record-open-folder').addEventListener('click', async () => {
|
el('record-open-folder').addEventListener('click', async () => {
|
||||||
const inst = await window.api.getInstance(currentDetailId);
|
const inst = await window.api.getInstance(currentDetailId);
|
||||||
window.api.recordingOpenFolder(inst ? inst.name : mcLastSavedInstanceName);
|
window.api.recordingOpenFolder(inst ? inst.name : mcLastSavedInstanceName);
|
||||||
@@ -5465,7 +5790,11 @@ const POPOUT_OPENERS = {
|
|||||||
skin: () => openSkin(),
|
skin: () => openSkin(),
|
||||||
bedrock: () => openBedrock(),
|
bedrock: () => openBedrock(),
|
||||||
login: (p) => openLogin({ addAccount: p.addAccount === true || p.addAccount === 'true' }),
|
login: (p) => openLogin({ addAccount: p.addAccount === true || p.addAccount === 'true' }),
|
||||||
import: (p) => openImportDialog(p.kind || new URLSearchParams(location.search).get('kind') || 'zip'),
|
import: (p) => {
|
||||||
|
let vorauswahl = null;
|
||||||
|
try { vorauswahl = p.vorauswahl ? JSON.parse(p.vorauswahl) : null; } catch { /* ungültig -> normal öffnen */ }
|
||||||
|
openImportDialog(p.kind || new URLSearchParams(location.search).get('kind') || 'zip', vorauswahl);
|
||||||
|
},
|
||||||
update: async () => { try { showUpdateDialog(await window.api.updateGetPending()); } catch { /* egal */ } },
|
update: async () => { try { showUpdateDialog(await window.api.updateGetPending()); } catch { /* egal */ } },
|
||||||
instance: async (p) => {
|
instance: async (p) => {
|
||||||
if (p.instanceId) {
|
if (p.instanceId) {
|
||||||
@@ -5477,6 +5806,7 @@ const POPOUT_OPENERS = {
|
|||||||
},
|
},
|
||||||
record: () => openRecording(),
|
record: () => openRecording(),
|
||||||
friends: () => openFriends(),
|
friends: () => openFriends(),
|
||||||
|
stats: () => openStats(),
|
||||||
};
|
};
|
||||||
|
|
||||||
function popoutBoot(params) {
|
function popoutBoot(params) {
|
||||||
@@ -5505,6 +5835,16 @@ window.addEventListener('DOMContentLoaded', async () => {
|
|||||||
// bisher gesammelte Konsolentext weg)
|
// bisher gesammelte Konsolentext weg)
|
||||||
window.api.onPopoutReopen((params) => popoutBoot(params));
|
window.api.onPopoutReopen((params) => popoutBoot(params));
|
||||||
if (POPOUT === 'record') window.api.onRecordCloseAttempt(() => confirmStopAndCloseRecording());
|
if (POPOUT === 'record') window.api.onRecordCloseAttempt(() => confirmStopAndCloseRecording());
|
||||||
|
if (POPOUT === 'record') {
|
||||||
|
window.api.onClipHotkey(() => speichereClip());
|
||||||
|
// Spiel beendet -> Clip-Modus ist ohne Bildquelle sinnlos
|
||||||
|
window.api.onLaunchProgress((evt) => {
|
||||||
|
if (evt.phase === 'exit' && evt.id === currentDetailId && clipAktiv) {
|
||||||
|
stoppeClipModus();
|
||||||
|
toast('Clip-Modus beendet – Minecraft wurde geschlossen.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
wire();
|
wire();
|
||||||
@@ -5529,7 +5869,15 @@ window.addEventListener('DOMContentLoaded', async () => {
|
|||||||
};
|
};
|
||||||
window.api.onFriendsRequestCount(zeigeAnfragenZahl);
|
window.api.onFriendsRequestCount(zeigeAnfragenZahl);
|
||||||
window.api.friendsRequestCount().then(zeigeAnfragenZahl).catch(() => {});
|
window.api.friendsRequestCount().then(zeigeAnfragenZahl).catch(() => {});
|
||||||
|
// Meldungen aus dem Hauptprozess, deren Fenster schon zu ist (z. B. Performance-Paket)
|
||||||
|
window.api.onUiToast((t) => toast(t.text, !!t.error));
|
||||||
|
// Einladung "Komm auf meinen Server" (im Spiel kommt zusätzlich ein Toast)
|
||||||
|
window.api.onFriendsInvite((inv) => {
|
||||||
|
toast(`${inv.fromUsername || 'Ein Freund'} lädt dich auf ${inv.server} ein – im Freunde-Fenster beitreten.`);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
// offenes Freunde-Fenster bei neuer Einladung gleich aktualisieren
|
||||||
|
if (POPOUT === 'friends') window.api.onFriendsInvite(() => ladeFreunde());
|
||||||
await refreshSkinBackground();
|
await refreshSkinBackground();
|
||||||
window.api.onLaunchProgress(onLaunchEvt);
|
window.api.onLaunchProgress(onLaunchEvt);
|
||||||
// Servername live in den Titel nachtragen, falls das Aufzeichnungs-Fenster
|
// Servername live in den Titel nachtragen, falls das Aufzeichnungs-Fenster
|
||||||
|
|||||||
+6
-1
@@ -76,7 +76,9 @@ const DEFAULT_SETTINGS = {
|
|||||||
iso3dIcons: true, // Block-Symbole räumlich darstellen
|
iso3dIcons: true, // Block-Symbole räumlich darstellen
|
||||||
bedrockEnabled: true, // Bedrock-Bereich anzeigen (nur wenn auch installiert)
|
bedrockEnabled: true, // Bedrock-Bereich anzeigen (nur wenn auch installiert)
|
||||||
desktopNotifications: true, // Windows-Meldung bei fertigem Hintergrund-Download/-Backup
|
desktopNotifications: true, // Windows-Meldung bei fertigem Hintergrund-Download/-Backup
|
||||||
friendsEnabled: true, // Freundesystem: eigene Online-Meldung + auffindbar für Anfragen
|
friendsEnabled: true,
|
||||||
|
friendsShareServer: true, // Freunde sehen, auf welchem Server man spielt (für "Beitreten")
|
||||||
|
autoModUpdates: false, // vor jedem Start Mods über Modrinth aktualisieren // Freundesystem: eigene Online-Meldung + auffindbar für Anfragen
|
||||||
friendNicknames: {}, // rein lokal: uuid -> eigener Spitzname für einen Freund
|
friendNicknames: {}, // rein lokal: uuid -> eigener Spitzname für einen Freund
|
||||||
autostart: false, // mit Windows starten, im Infobereich statt als Fenster
|
autostart: false, // mit Windows starten, im Infobereich statt als Fenster
|
||||||
discordClientId: '', // optional, nur per Hand in settings.json; leer => eingebaute AeroMC-App-ID
|
discordClientId: '', // optional, nur per Hand in settings.json; leer => eingebaute AeroMC-App-ID
|
||||||
@@ -295,6 +297,7 @@ function defaultInstance(name) {
|
|||||||
created: now,
|
created: now,
|
||||||
lastPlayed: null,
|
lastPlayed: null,
|
||||||
totalPlaySeconds: 0,
|
totalPlaySeconds: 0,
|
||||||
|
launchCount: 0, // Anzahl Spielstarts (Spielzeit-Statistik)
|
||||||
minecraft: {
|
minecraft: {
|
||||||
version: '', // z. B. "1.21.4" – wird in Phase 2 aus dem Mojang-Manifest befüllt
|
version: '', // z. B. "1.21.4" – wird in Phase 2 aus dem Mojang-Manifest befüllt
|
||||||
loader: 'vanilla', // vanilla | fabric | forge | neoforge | quilt
|
loader: 'vanilla', // vanilla | fabric | forge | neoforge | quilt
|
||||||
@@ -426,6 +429,7 @@ function createOrReplaceImportedInstance(data) {
|
|||||||
inst.created = existing.created || inst.created;
|
inst.created = existing.created || inst.created;
|
||||||
inst.lastPlayed = existing.lastPlayed || null;
|
inst.lastPlayed = existing.lastPlayed || null;
|
||||||
inst.totalPlaySeconds = existing.totalPlaySeconds || 0;
|
inst.totalPlaySeconds = existing.totalPlaySeconds || 0;
|
||||||
|
inst.launchCount = existing.launchCount || 0;
|
||||||
|
|
||||||
const p = instancePath(existing.id);
|
const p = instancePath(existing.id);
|
||||||
if (fs.existsSync(p)) fs.rmSync(p, { recursive: true, force: true });
|
if (fs.existsSync(p)) fs.rmSync(p, { recursive: true, force: true });
|
||||||
@@ -458,6 +462,7 @@ function duplicateInstance(id) {
|
|||||||
copy.created = new Date().toISOString();
|
copy.created = new Date().toISOString();
|
||||||
copy.lastPlayed = null;
|
copy.lastPlayed = null;
|
||||||
copy.totalPlaySeconds = 0;
|
copy.totalPlaySeconds = 0;
|
||||||
|
copy.launchCount = 0;
|
||||||
ensureDir(gameDir(copy.id));
|
ensureDir(gameDir(copy.id));
|
||||||
// Spielinhalte mitkopieren (Mods/Configs) – ohne Fehler wenn leer
|
// Spielinhalte mitkopieren (Mods/Configs) – ohne Fehler wenn leer
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -391,6 +391,42 @@ img.account-av{object-fit:cover;image-rendering:auto}
|
|||||||
.tb-badge{display:inline-block;min-width:16px;height:16px;padding:0 4px;margin-left:6px;border-radius:8px;background:#d9453c;color:#fff;font-size:11px;font-weight:600;line-height:16px;text-align:center;box-sizing:border-box}
|
.tb-badge{display:inline-block;min-width:16px;height:16px;padding:0 4px;margin-left:6px;border-radius:8px;background:#d9453c;color:#fff;font-size:11px;font-weight:600;line-height:16px;text-align:center;box-sizing:border-box}
|
||||||
#modal-friends .field-row{margin-bottom:14px}
|
#modal-friends .field-row{margin-bottom:14px}
|
||||||
#modal-friends .field-row input{flex:1}
|
#modal-friends .field-row input{flex:1}
|
||||||
|
/* ---- Spielzeit-Statistik: Kennzahlen + Balken pro Instanz ---- */
|
||||||
|
:root{--stat-bar:#3a6ea5}
|
||||||
|
:root[data-theme="dark"]{--stat-bar:#3d8fe0}
|
||||||
|
.stats-tiles{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:6px}
|
||||||
|
.stat-tile{border:1px solid var(--border);border-radius:4px;padding:8px 10px;min-width:0}
|
||||||
|
.stat-tile .stat-label{font-size:11px;color:var(--dim)}
|
||||||
|
.stat-tile .stat-value{font-size:18px;font-weight:600;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||||
|
.stat-tile .stat-sub{font-size:11px;color:var(--dim);line-height:1.35}
|
||||||
|
.stats-list{display:flex;flex-direction:column;gap:2px}
|
||||||
|
.stat-row{display:grid;grid-template-columns:minmax(0,38%) 1fr auto;align-items:center;gap:10px;padding:5px 4px;border-radius:3px}
|
||||||
|
.stat-row:hover{background:var(--sel)}
|
||||||
|
.stat-name{min-width:0}
|
||||||
|
.stat-name b{display:block;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||||
|
.stat-name span{font-size:11px;color:var(--dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:block}
|
||||||
|
.stat-track{height:8px}
|
||||||
|
.stat-bar{height:8px;background:var(--stat-bar);border-radius:0 4px 4px 0;min-width:2px}
|
||||||
|
.stat-value-cell{font-variant-numeric:tabular-nums;white-space:nowrap;text-align:right;min-width:92px}
|
||||||
|
.popout-host .stats-list{max-height:none}
|
||||||
|
|
||||||
|
/* Clip-Modus-Leiste im Aufzeichnungsfenster */
|
||||||
|
.clip-bar{display:flex;align-items:center;gap:14px;flex-wrap:wrap;padding:8px 12px;border:1px solid var(--border);border-radius:4px;background:var(--area)}
|
||||||
|
.clip-bar.aktiv{border-color:var(--accent);box-shadow:inset 3px 0 0 var(--accent)}
|
||||||
|
.clip-switch{display:inline-flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;font-weight:600;user-select:none}
|
||||||
|
.clip-switch input{position:absolute;opacity:0;width:1px;height:1px;pointer-events:none}
|
||||||
|
.clip-switch-track{position:relative;width:32px;height:18px;border-radius:9px;background:var(--border);transition:background .15s}
|
||||||
|
.clip-switch-knob{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.35);transition:left .15s}
|
||||||
|
.clip-switch input:checked + .clip-switch-track{background:var(--accent)}
|
||||||
|
.clip-switch input:checked + .clip-switch-track .clip-switch-knob{left:16px}
|
||||||
|
.clip-switch input:focus-visible + .clip-switch-track{outline:2px solid var(--accent);outline-offset:2px}
|
||||||
|
.clip-length{display:inline-flex;align-items:center;gap:6px;font-size:12px;color:var(--dim)}
|
||||||
|
.clip-length select{padding:3px 6px;border:1px solid var(--border);border-radius:3px;background:var(--input,#fff);color:var(--text);font-family:inherit;font-size:12px}
|
||||||
|
.clip-length select:disabled{opacity:.6}
|
||||||
|
.clip-status{flex:1;min-width:200px;font-size:12px;color:var(--dim)}
|
||||||
|
.clip-bar.aktiv .clip-status{color:var(--text)}
|
||||||
|
.clip-bar.aktiv .clip-status::before{content:"";display:inline-block;width:8px;height:8px;margin-right:7px;border-radius:50%;background:var(--danger);vertical-align:1px;animation:rec-pulse 1.6s infinite}
|
||||||
|
|
||||||
.friends-section-title{font-size:11px;color:var(--dim);text-transform:uppercase;letter-spacing:.03em;margin:10px 0 6px}
|
.friends-section-title{font-size:11px;color:var(--dim);text-transform:uppercase;letter-spacing:.03em;margin:10px 0 6px}
|
||||||
.friend-row{display:flex;align-items:center;gap:10px;padding:6px 4px;border-radius:3px}
|
.friend-row{display:flex;align-items:center;gap:10px;padding:6px 4px;border-radius:3px}
|
||||||
.friend-row:hover{background:var(--sel,rgba(0,0,0,.04))}
|
.friend-row:hover{background:var(--sel,rgba(0,0,0,.04))}
|
||||||
|
|||||||
+4
-3
@@ -42,7 +42,7 @@ function computeInstallKind() {
|
|||||||
// 2. Notnagel über den Dateinamen, falls die Variablen einmal fehlen
|
// 2. Notnagel über den Dateinamen, falls die Variablen einmal fehlen
|
||||||
if (/portable/i.test(path.basename(exe))) return 'portable';
|
if (/portable/i.test(path.basename(exe))) return 'portable';
|
||||||
|
|
||||||
// 3. Setup: NSIS legt neben der EXE immer einen Uninstaller ab. Nur wenn der
|
// 3. Setup: das Setup legt neben der EXE immer "Uninstall AeroMC.exe" ab. Nur wenn der
|
||||||
// da ist, ist es wirklich eine Installation.
|
// da ist, ist es wirklich eine Installation.
|
||||||
if (hasUninstaller(path.dirname(exe))) return 'setup';
|
if (hasUninstaller(path.dirname(exe))) return 'setup';
|
||||||
|
|
||||||
@@ -319,7 +319,7 @@ exit /b 1
|
|||||||
|
|
||||||
/*
|
/*
|
||||||
* Wendet das geladene Update an.
|
* Wendet das geladene Update an.
|
||||||
* setup -> NSIS-Installer starten, er überschreibt die Installation selbst
|
* setup -> AeroMC-Setup mit --update starten, es überschreibt die Installation selbst
|
||||||
* portable -> Skript starten, das die laufende EXE nach dem Beenden ersetzt
|
* portable -> Skript starten, das die laufende EXE nach dem Beenden ersetzt
|
||||||
* Der Launcher muss sich danach beenden.
|
* Der Launcher muss sich danach beenden.
|
||||||
*/
|
*/
|
||||||
@@ -331,7 +331,8 @@ function applyUpdate(file) {
|
|||||||
const kind = installKind;
|
const kind = installKind;
|
||||||
|
|
||||||
if (kind !== 'portable') {
|
if (kind !== 'portable') {
|
||||||
const child = spawn(file.path, [], { detached: true, stdio: 'ignore' });
|
// --update: das AeroMC-Setup aktualisiert ohne Rückfragen und startet AeroMC danach neu
|
||||||
|
const child = spawn(file.path, ['--update'], { detached: true, stdio: 'ignore' });
|
||||||
child.unref();
|
child.unref();
|
||||||
return { started: 'installer' };
|
return { started: 'installer' };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
/**
|
/**
|
||||||
* Plugin Name: AeroMC Freunde
|
* Plugin Name: AeroMC Freunde
|
||||||
* Description: Backend für das Freundesystem des AeroMC Launchers - meldet, wer gerade online/im Spiel ist, und verwaltet Freundschaftsanfragen. Keine eigene Anmeldung: der Launcher meldet sich direkt mit der Minecraft-UUID des jeweiligen Kontos.
|
* Description: Backend für das Freundesystem des AeroMC Launchers - meldet, wer gerade online/im Spiel ist, und verwaltet Freundschaftsanfragen. Keine eigene Anmeldung: der Launcher meldet sich direkt mit der Minecraft-UUID des jeweiligen Kontos.
|
||||||
* Version: 1.0.0
|
* Version: 1.1.0
|
||||||
* Author: M_Viper
|
* Author: M_Viper
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -10,9 +10,10 @@ if (!defined('ABSPATH')) {
|
|||||||
exit; // kein direkter Aufruf der Datei
|
exit; // kein direkter Aufruf der Datei
|
||||||
}
|
}
|
||||||
|
|
||||||
define('AEROMC_FRIENDS_VERSION', '1.0.0');
|
define('AEROMC_FRIENDS_VERSION', '1.1.0');
|
||||||
define('AEROMC_FRIENDS_PRESENCE_TABLE', 'aeromc_presence');
|
define('AEROMC_FRIENDS_PRESENCE_TABLE', 'aeromc_presence');
|
||||||
define('AEROMC_FRIENDS_FRIENDS_TABLE', 'aeromc_friends');
|
define('AEROMC_FRIENDS_FRIENDS_TABLE', 'aeromc_friends');
|
||||||
|
define('AEROMC_FRIENDS_INVITES_TABLE', 'aeromc_invites');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Legt die beiden Tabellen an (bzw. bringt sie bei einem Update auf den
|
* Legt die beiden Tabellen an (bzw. bringt sie bei einem Update auf den
|
||||||
@@ -23,6 +24,7 @@ function aeromc_friends_install() {
|
|||||||
$charset_collate = $wpdb->get_charset_collate();
|
$charset_collate = $wpdb->get_charset_collate();
|
||||||
$presence_table = $wpdb->prefix . AEROMC_FRIENDS_PRESENCE_TABLE;
|
$presence_table = $wpdb->prefix . AEROMC_FRIENDS_PRESENCE_TABLE;
|
||||||
$friends_table = $wpdb->prefix . AEROMC_FRIENDS_FRIENDS_TABLE;
|
$friends_table = $wpdb->prefix . AEROMC_FRIENDS_FRIENDS_TABLE;
|
||||||
|
$invites_table = $wpdb->prefix . AEROMC_FRIENDS_INVITES_TABLE;
|
||||||
|
|
||||||
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
||||||
|
|
||||||
@@ -34,6 +36,7 @@ function aeromc_friends_install() {
|
|||||||
username varchar(32) NOT NULL,
|
username varchar(32) NOT NULL,
|
||||||
instance_name varchar(128) DEFAULT NULL,
|
instance_name varchar(128) DEFAULT NULL,
|
||||||
server_label varchar(128) DEFAULT NULL,
|
server_label varchar(128) DEFAULT NULL,
|
||||||
|
mc_version varchar(32) DEFAULT NULL,
|
||||||
last_seen datetime NOT NULL,
|
last_seen datetime NOT NULL,
|
||||||
PRIMARY KEY (uuid)
|
PRIMARY KEY (uuid)
|
||||||
) $charset_collate;";
|
) $charset_collate;";
|
||||||
@@ -50,7 +53,31 @@ function aeromc_friends_install() {
|
|||||||
KEY uuid_b (uuid_b)
|
KEY uuid_b (uuid_b)
|
||||||
) $charset_collate;";
|
) $charset_collate;";
|
||||||
dbDelta($sql_friends);
|
dbDelta($sql_friends);
|
||||||
|
|
||||||
|
// Einladungen "Komm auf meinen Server" - gelten nur ein paar Minuten
|
||||||
|
$sql_invites = "CREATE TABLE $invites_table (
|
||||||
|
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
from_uuid varchar(32) NOT NULL,
|
||||||
|
to_uuid varchar(32) NOT NULL,
|
||||||
|
server varchar(128) NOT NULL,
|
||||||
|
mc_version varchar(32) DEFAULT NULL,
|
||||||
|
created_at datetime NOT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY to_uuid (to_uuid)
|
||||||
|
) $charset_collate;";
|
||||||
|
dbDelta($sql_invites);
|
||||||
|
|
||||||
|
update_option('aeromc_friends_db_version', AEROMC_FRIENDS_VERSION);
|
||||||
}
|
}
|
||||||
register_activation_hook(__FILE__, 'aeromc_friends_install');
|
register_activation_hook(__FILE__, 'aeromc_friends_install');
|
||||||
|
|
||||||
|
// Beim Ersetzen der Plugin-Dateien (Update per Upload) läuft der
|
||||||
|
// Aktivierungs-Hook NICHT erneut - neue Spalten/Tabellen deshalb anlegen,
|
||||||
|
// sobald die gespeicherte DB-Version nicht mehr zur Plugin-Version passt.
|
||||||
|
add_action('plugins_loaded', function () {
|
||||||
|
if (get_option('aeromc_friends_db_version') !== AEROMC_FRIENDS_VERSION) {
|
||||||
|
aeromc_friends_install();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
require_once plugin_dir_path(__FILE__) . 'includes/routes.php';
|
require_once plugin_dir_path(__FILE__) . 'includes/routes.php';
|
||||||
|
|||||||
@@ -20,7 +20,38 @@ if (!defined('ABSPATH')) {
|
|||||||
define('AEROMC_FRIENDS_ONLINE_SECONDS', 180);
|
define('AEROMC_FRIENDS_ONLINE_SECONDS', 180);
|
||||||
// Häufigere Herzschläge als das werden ignoriert (Schutz gegen Missbrauch/
|
// Häufigere Herzschläge als das werden ignoriert (Schutz gegen Missbrauch/
|
||||||
// versehentliche Anfrageflut) - der Launcher selbst sendet ohnehin nur alle 60s.
|
// versehentliche Anfrageflut) - der Launcher selbst sendet ohnehin nur alle 60s.
|
||||||
|
// Ausnahme: hat sich der Server geändert, wird sofort gespeichert ("Beitreten").
|
||||||
define('AEROMC_FRIENDS_HEARTBEAT_MIN_GAP', 20);
|
define('AEROMC_FRIENDS_HEARTBEAT_MIN_GAP', 20);
|
||||||
|
// So lange ist eine Einladung "Komm auf meinen Server" gültig.
|
||||||
|
define('AEROMC_FRIENDS_INVITE_SECONDS', 600);
|
||||||
|
// Frühestens nach so vielen Sekunden darf derselbe Freund erneut eingeladen werden.
|
||||||
|
define('AEROMC_FRIENDS_INVITE_MIN_GAP', 30);
|
||||||
|
|
||||||
|
function aeromc_friends_invites_table() {
|
||||||
|
global $wpdb;
|
||||||
|
return $wpdb->prefix . AEROMC_FRIENDS_INVITES_TABLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Server-Adresse (Host oder IP, optional :Port) - nur harmlose Zeichen. */
|
||||||
|
function aeromc_friends_valid_server($server) {
|
||||||
|
return is_string($server) && preg_match('/^[A-Za-z0-9.\-_:\[\]]{1,128}$/', $server);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minecraft-Version wie "1.21.1", "24w14a" oder "1.20.1-pre2". */
|
||||||
|
function aeromc_friends_clean_version($version) {
|
||||||
|
$version = (string) $version;
|
||||||
|
return preg_match('/^[A-Za-z0-9.\-_ ]{1,32}$/', $version) ? $version : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sind die beiden befreundet (Anfrage angenommen)? */
|
||||||
|
function aeromc_friends_are_friends($a, $b) {
|
||||||
|
global $wpdb;
|
||||||
|
$friends_table = aeromc_friends_friends_table();
|
||||||
|
return (bool) $wpdb->get_var($wpdb->prepare(
|
||||||
|
"SELECT id FROM $friends_table WHERE ((uuid_a = %s AND uuid_b = %s) OR (uuid_a = %s AND uuid_b = %s)) AND status = 'accepted'",
|
||||||
|
$a, $b, $b, $a
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
function aeromc_friends_presence_table() {
|
function aeromc_friends_presence_table() {
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
@@ -89,9 +120,24 @@ add_action('rest_api_init', function () {
|
|||||||
'callback' => 'aeromc_friends_route_remove',
|
'callback' => 'aeromc_friends_route_remove',
|
||||||
'permission_callback' => '__return_true',
|
'permission_callback' => '__return_true',
|
||||||
]);
|
]);
|
||||||
|
register_rest_route('aeromc/v1', '/friends/invite', [
|
||||||
|
'methods' => 'POST',
|
||||||
|
'callback' => 'aeromc_friends_route_invite',
|
||||||
|
'permission_callback' => '__return_true',
|
||||||
|
]);
|
||||||
|
register_rest_route('aeromc/v1', '/friends/invites', [
|
||||||
|
'methods' => 'GET',
|
||||||
|
'callback' => 'aeromc_friends_route_invites',
|
||||||
|
'permission_callback' => '__return_true',
|
||||||
|
]);
|
||||||
|
register_rest_route('aeromc/v1', '/friends/invites/dismiss', [
|
||||||
|
'methods' => 'POST',
|
||||||
|
'callback' => 'aeromc_friends_route_invite_dismiss',
|
||||||
|
'permission_callback' => '__return_true',
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
/** POST /presence {uuid, username, instance, server} */
|
/** POST /presence {uuid, username, instance, server, version} */
|
||||||
function aeromc_friends_route_presence($request) {
|
function aeromc_friends_route_presence($request) {
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
$uuid = aeromc_friends_normalize_uuid($request->get_param('uuid'));
|
$uuid = aeromc_friends_normalize_uuid($request->get_param('uuid'));
|
||||||
@@ -101,12 +147,14 @@ function aeromc_friends_route_presence($request) {
|
|||||||
}
|
}
|
||||||
$instance = sanitize_text_field((string) $request->get_param('instance'));
|
$instance = sanitize_text_field((string) $request->get_param('instance'));
|
||||||
$server = sanitize_text_field((string) $request->get_param('server'));
|
$server = sanitize_text_field((string) $request->get_param('server'));
|
||||||
|
$version = aeromc_friends_clean_version($request->get_param('version'));
|
||||||
$table = aeromc_friends_presence_table();
|
$table = aeromc_friends_presence_table();
|
||||||
|
|
||||||
$letzte = $wpdb->get_var($wpdb->prepare(
|
$letzte = $wpdb->get_row($wpdb->prepare(
|
||||||
"SELECT last_seen FROM $table WHERE uuid = %s", $uuid
|
"SELECT last_seen, server_label FROM $table WHERE uuid = %s", $uuid
|
||||||
));
|
));
|
||||||
if ($letzte && (time() - strtotime($letzte)) < AEROMC_FRIENDS_HEARTBEAT_MIN_GAP) {
|
$server_gleich = $letzte && (string) $letzte->server_label === (string) $server;
|
||||||
|
if ($letzte && $server_gleich && (time() - strtotime($letzte->last_seen)) < AEROMC_FRIENDS_HEARTBEAT_MIN_GAP) {
|
||||||
return ['ok' => true]; // zu häufig - stillschweigend ignoriert
|
return ['ok' => true]; // zu häufig - stillschweigend ignoriert
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,11 +166,11 @@ function aeromc_friends_route_presence($request) {
|
|||||||
// Zeitzone praktisch nie als offline erkannt worden wäre.
|
// Zeitzone praktisch nie als offline erkannt worden wäre.
|
||||||
$jetzt_utc = current_time('mysql', true);
|
$jetzt_utc = current_time('mysql', true);
|
||||||
$wpdb->query($wpdb->prepare(
|
$wpdb->query($wpdb->prepare(
|
||||||
"INSERT INTO $table (uuid, username, instance_name, server_label, last_seen)
|
"INSERT INTO $table (uuid, username, instance_name, server_label, mc_version, last_seen)
|
||||||
VALUES (%s, %s, %s, %s, %s)
|
VALUES (%s, %s, %s, %s, %s, %s)
|
||||||
ON DUPLICATE KEY UPDATE username = %s, instance_name = %s, server_label = %s, last_seen = %s",
|
ON DUPLICATE KEY UPDATE username = %s, instance_name = %s, server_label = %s, mc_version = %s, last_seen = %s",
|
||||||
$uuid, $username, $instance ?: null, $server ?: null, $jetzt_utc,
|
$uuid, $username, $instance ?: null, $server ?: null, $version ?: null, $jetzt_utc,
|
||||||
$username, $instance ?: null, $server ?: null, $jetzt_utc
|
$username, $instance ?: null, $server ?: null, $version ?: null, $jetzt_utc
|
||||||
));
|
));
|
||||||
return ['ok' => true];
|
return ['ok' => true];
|
||||||
}
|
}
|
||||||
@@ -147,7 +195,7 @@ function aeromc_friends_route_list($request) {
|
|||||||
$ergebnis = [];
|
$ergebnis = [];
|
||||||
foreach ($rows as $row) {
|
foreach ($rows as $row) {
|
||||||
$p = $wpdb->get_row($wpdb->prepare(
|
$p = $wpdb->get_row($wpdb->prepare(
|
||||||
"SELECT username, instance_name, server_label, last_seen FROM $presence_table WHERE uuid = %s",
|
"SELECT username, instance_name, server_label, mc_version, last_seen FROM $presence_table WHERE uuid = %s",
|
||||||
$row->other_uuid
|
$row->other_uuid
|
||||||
));
|
));
|
||||||
$online = $p && (time() - strtotime($p->last_seen)) < AEROMC_FRIENDS_ONLINE_SECONDS;
|
$online = $p && (time() - strtotime($p->last_seen)) < AEROMC_FRIENDS_ONLINE_SECONDS;
|
||||||
@@ -157,6 +205,7 @@ function aeromc_friends_route_list($request) {
|
|||||||
'online' => (bool) $online,
|
'online' => (bool) $online,
|
||||||
'instance' => $online ? $p->instance_name : null,
|
'instance' => $online ? $p->instance_name : null,
|
||||||
'server' => $online ? $p->server_label : null,
|
'server' => $online ? $p->server_label : null,
|
||||||
|
'version' => $online ? $p->mc_version : null,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
return $ergebnis;
|
return $ergebnis;
|
||||||
@@ -301,3 +350,87 @@ function aeromc_friends_route_remove($request) {
|
|||||||
));
|
));
|
||||||
return ['ok' => true];
|
return ['ok' => true];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** POST /friends/invite {uuid, targetUuid, server, version} - "Komm auf meinen Server" */
|
||||||
|
function aeromc_friends_route_invite($request) {
|
||||||
|
global $wpdb;
|
||||||
|
$uuid = aeromc_friends_normalize_uuid($request->get_param('uuid'));
|
||||||
|
$target = aeromc_friends_normalize_uuid($request->get_param('targetUuid'));
|
||||||
|
$server = (string) $request->get_param('server');
|
||||||
|
$version = aeromc_friends_clean_version($request->get_param('version'));
|
||||||
|
if (!$uuid || !$target || !aeromc_friends_valid_server($server)) {
|
||||||
|
return new WP_Error('aeromc_invalid', 'Ungültige uuid/targetUuid/server.', ['status' => 400]);
|
||||||
|
}
|
||||||
|
if (!aeromc_friends_are_friends($uuid, $target)) {
|
||||||
|
return new WP_Error('aeromc_not_friends', 'Ihr seid nicht befreundet.', ['status' => 403]);
|
||||||
|
}
|
||||||
|
$table = aeromc_friends_invites_table();
|
||||||
|
|
||||||
|
$letzte = $wpdb->get_var($wpdb->prepare(
|
||||||
|
"SELECT created_at FROM $table WHERE from_uuid = %s AND to_uuid = %s ORDER BY id DESC LIMIT 1",
|
||||||
|
$uuid, $target
|
||||||
|
));
|
||||||
|
if ($letzte && (time() - strtotime($letzte)) < AEROMC_FRIENDS_INVITE_MIN_GAP) {
|
||||||
|
return new WP_Error('aeromc_too_fast', 'Du hast diesen Freund gerade erst eingeladen.', ['status' => 429]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ältere Einladung an denselben Freund ersetzen
|
||||||
|
$wpdb->query($wpdb->prepare(
|
||||||
|
"DELETE FROM $table WHERE from_uuid = %s AND to_uuid = %s", $uuid, $target
|
||||||
|
));
|
||||||
|
$wpdb->insert($table, [
|
||||||
|
'from_uuid' => $uuid,
|
||||||
|
'to_uuid' => $target,
|
||||||
|
'server' => $server,
|
||||||
|
'mc_version' => $version ?: null,
|
||||||
|
'created_at' => current_time('mysql', true),
|
||||||
|
]);
|
||||||
|
return ['ok' => true];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /friends/invites?uuid= - an mich gerichtete, noch gültige Einladungen */
|
||||||
|
function aeromc_friends_route_invites($request) {
|
||||||
|
global $wpdb;
|
||||||
|
$uuid = aeromc_friends_normalize_uuid($request->get_param('uuid'));
|
||||||
|
if (!$uuid) {
|
||||||
|
return new WP_Error('aeromc_invalid', 'Ungültige uuid.', ['status' => 400]);
|
||||||
|
}
|
||||||
|
$table = aeromc_friends_invites_table();
|
||||||
|
$presence_table = aeromc_friends_presence_table();
|
||||||
|
|
||||||
|
// abgelaufene Einladungen (aller Spieler) wegräumen
|
||||||
|
$grenze = gmdate('Y-m-d H:i:s', time() - AEROMC_FRIENDS_INVITE_SECONDS);
|
||||||
|
$wpdb->query($wpdb->prepare("DELETE FROM $table WHERE created_at < %s", $grenze));
|
||||||
|
|
||||||
|
$rows = $wpdb->get_results($wpdb->prepare(
|
||||||
|
"SELECT i.id, i.from_uuid, p.username AS from_username, i.server, i.mc_version, i.created_at
|
||||||
|
FROM $table i
|
||||||
|
LEFT JOIN $presence_table p ON p.uuid = i.from_uuid
|
||||||
|
WHERE i.to_uuid = %s
|
||||||
|
ORDER BY i.id DESC",
|
||||||
|
$uuid
|
||||||
|
));
|
||||||
|
return array_map(function ($r) {
|
||||||
|
return [
|
||||||
|
'id' => (int) $r->id,
|
||||||
|
'fromUuid' => $r->from_uuid,
|
||||||
|
'fromUsername' => $r->from_username,
|
||||||
|
'server' => $r->server,
|
||||||
|
'version' => $r->mc_version,
|
||||||
|
'createdAt' => $r->created_at,
|
||||||
|
];
|
||||||
|
}, $rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /friends/invites/dismiss {uuid, id} - Einladung erledigt/abgelehnt */
|
||||||
|
function aeromc_friends_route_invite_dismiss($request) {
|
||||||
|
global $wpdb;
|
||||||
|
$uuid = aeromc_friends_normalize_uuid($request->get_param('uuid'));
|
||||||
|
$id = (int) $request->get_param('id');
|
||||||
|
if (!$uuid || $id <= 0) {
|
||||||
|
return new WP_Error('aeromc_invalid', 'Ungültige uuid/id.', ['status' => 400]);
|
||||||
|
}
|
||||||
|
$table = aeromc_friends_invites_table();
|
||||||
|
$wpdb->query($wpdb->prepare("DELETE FROM $table WHERE id = %d AND to_uuid = %s", $id, $uuid));
|
||||||
|
return ['ok' => true];
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user