'use strict'; /* renderer.js – UI-Logik für AeroMC */ // ---------- Instanz-Icons: eigene Pixel-Grafiken im Minecraft-Stil ---------- // Jedes Icon ist ein 16x16-Raster; jedes Zeichen verweist auf eine Farbe der Palette. const PIX = { grass: { p: { a: '#7cbd56', b: '#63a343', c: '#8b6b4a', d: '#6f5238' }, r: [ 'aaaaaaaaaaaaaaaa', 'abaabaaabaabaaab', 'aaaaaaaaaaaaaaaa', 'babaabaabaaabaab', 'cccccccccccccccc', 'cdcccdcccccdccdc', 'ccccccccdcccccdc', 'cccdccccccccdccc', 'dcccccdcccccdccc', 'ccccdccccdccccdc', 'cdccccccccccccdc', 'ccccccdcccccdccc', 'ccdcccccccdccccc', 'cccccccdccccccdc', 'cdccccccccccdccc', 'cccccccccccccccc', ] }, creeper: { p: { a: '#7cbd56', b: '#5f9c42', k: '#0d0f0d' }, r: [ 'abbaabbaabbaabba', 'baabbaabbaabbaab', 'abbaabbaabbaabba', 'aakkaaaaaaaakkaa', 'aakkaaaaaaaakkaa', 'aaaaaakkkkaaaaaa', 'aaaaakkkkkkaaaaa', 'aaaaakkkkkkaaaaa', 'aaaaakkaakkaaaaa', 'aaaaakkaakkaaaaa', 'abbaabbaabbaabba', 'baabbaabbaabbaab', 'abbaabbaabbaabba', 'baabbaabbaabbaab', 'abbaabbaabbaabba', 'baabbaabbaabbaab', ] }, diamond: { p: { a: '#5ce4e0', b: '#3ec6c9', c: '#2aa3ab', w: '#c9fbff', t: null }, r: [ 'tttttttttttttttt', 'tttttttttttttttt', 'tttttaaaaaattttt', 'ttttawwwaaaatttt', 'tttawwbbbaaaattt', 'ttawwbbbbbaaaatt', 'tawwbbbbbbbaaaat', 'abbbbbbbbbbbccca', 'abbbbbbbbbbbccca', 'tabbbbbbbbbcccat', 'ttabbbbbbbcccatt', 'tttabbbbbcccattt', 'ttttabbbcccatttt', 'tttttabcccattttt', 'ttttttaccatttttt', 'tttttttaattttttt', ] }, tnt: { p: { r: '#c0392b', w: '#f2f2f2', k: '#2b2b2b', d: '#8e2a20' }, r: [ 'rrrrrrrrrrrrrrrr', 'rdrrrdrrrrdrrrdr', 'wwwwwwwwwwwwwwww', 'wkkwkkwkkwkkwkkw', 'wkwkwkwkwkwkwkwk', 'wwwwwwwwwwwwwwww', 'rrrrrrrrrrrrrrrr', 'rdrrrrrdrrrrrdrr', 'rrrrdrrrrrdrrrrr', 'rrdrrrrrdrrrrrdr', 'rrrrrdrrrrrdrrrr', 'rdrrrrrrdrrrrrrr', 'rrrrdrrrrrrrdrrr', 'rrdrrrrdrrrrrrdr', 'rrrrrrrrrrrrrrrr', 'rrrrrrrrrrrrrrrr', ] }, chest: { p: { a: '#a97142', b: '#8a5a33', c: '#6b4423', k: '#3a2716', y: '#e0c060' }, r: [ 'cccccccccccccccc', 'cabababababababc', 'cbababababababac', 'cabababababababc', 'ckkkkkkkkkkkkkkc', 'cabababababababc', 'cababyyyyyybabac', 'cababykkkkybabac', 'cababyyyyyybabac', 'cabababababababc', 'cabababababababc', 'cabababababababc', 'cabababababababc', 'cabababababababc', 'cabababababababc', 'cccccccccccccccc', ] }, sword: { p: { s: '#d8dde6', l: '#f4f7fb', d: '#9aa3b0', h: '#8a5a33', g: '#e0c060', t: null }, r: [ 'tttttttttttttsst', 'ttttttttttttslls', 'tttttttttttslldt', 'ttttttttttslldtt', 'tttttttttslldttt', 'ttttttttslldtttt', 'tttttttslldttttt', 'ttttttslldtttttt', 'tttttslldttttttt', 'ttttslldtttttttt', 'tttgllgdtttttttt', 'tthhghhttttttttt', 'ttthhhtttttttttt', 'tttthhtttttttttt', 'ttttthtttttttttt', 'tttttttttttttttt', ] }, pickaxe: { p: { s: '#c9ced6', l: '#eef2f7', d: '#8e959f', h: '#8a5a33', b: '#6b4423', t: null }, r: [ 'tttssssttssssttt', 'ttslllsddslllstt', 'tsllddsttsddllst', 'tsldttttttttdlst', 'ttdttttsstttttdt', 'tttttthhtttttttt', 'ttttttbhtttttttt', 'ttttttthbttttttt', 'tttttttbhttttttt', 'ttttttttbhtttttt', 'ttttttttthbttttt', 'ttttttttttbhtttt', 'tttttttttttbhttt', 'ttttttttttttbhtt', 'tttttttttttttbht', 'tttttttttttttttt', ] }, potion: { p: { g: '#cfd8e3', l: '#eef3fa', v: '#c0392b', d: '#8e2a20', k: '#5a4632', t: null }, r: [ 'ttttttkkkktttttt', 'tttttkllkktttttt', 'tttttkllkttttttt', 'tttttkllkttttttt', 'ttttkglllgkttttt', 'tttkgllllllgkttt', 'ttkglvvvvvvlgktt', 'tkglvvvvvvvvlgkt', 'tkglvvvvvvvvlgkt', 'tkglvvvvvvvvlgkt', 'tkgdvvvvvvvvdgkt', 'tkgdvvvvvvvvdgkt', 'ttkgdvvvvvvdgktt', 'tttkgdvvvvdgkttt', 'ttttkkgggggkkttt', 'tttttkkkkkkkkttt', ] }, nether: { p: { a: '#e07b39', b: '#c9542a', c: '#8f2f18', y: '#f7c948', t: null }, r: [ 'tttttttyyttttttt', 'ttttttyaaytttttt', 'tttttyaaaayttttt', 'ttttyaaaaaaytttt', 'tttyaabbaaaayttt', 'ttyaabbbbaaayttt', 'tyaabbbbbbaaaytt', 'yaabbbcccbbbaayt', 'yabbbcccccbbbayt', 'yabbcccccccbbayt', 'yabbcccccccbbayt', 'tyabcccccccbaytt', 'ttyabcccccbayttt', 'tttyabcccbaytttt', 'ttttyaabbayttttt', 'tttttyyyyytttttt', ] }, end: { p: { a: '#2f6f66', b: '#3f8f83', c: '#1f4a45', w: '#bff5ea', t: null }, r: [ 'tttttbbbbbbttttt', 'tttbbaaaaaabbttt', 'ttbaaaawwaaaabtt', 'tbaaawwwwwwaaabt', 'baaawwbbbbwwaaab', 'baawwbbaabbwwaab', 'baawbbaaaabbwaab', 'bawbbaaaaaabbwab', 'bawbbaaaaaabbwab', 'baawbbaaaabbwaab', 'baawwbbaabbwwaab', 'baaawwbbbbwwaaab', 'tbaaawwwwwwaaabt', 'ttbaaaawwaaaabtt', 'tttbbaaaaaabbttt', 'tttttbbbbbbttttt', ] }, redstone: { p: { a: '#c0392b', b: '#8e2a20', c: '#5c1a13', g: '#3a3a3a', t: null }, r: [ 'tttttttggttttttt', 'ttttttgaagtttttt', 'tttttgaaaagttttt', 'ttttgaabbaagtttt', 'tttgaabbbbaagttt', 'ttgaabbbbbbaagtt', 'tgaabbbccbbbaagt', 'gaabbbcccccbbaag', 'gaabbbcccccbbaag', 'tgaabbbccbbbaagt', 'ttgaabbbbbbaagtt', 'tttgaabbbbaagttt', 'ttttgaabbaagtttt', 'tttttgaaaagttttt', 'ttttttgaagtttttt', 'tttttttggttttttt', ] }, gold: { p: { a: '#f7d774', b: '#e0b53c', c: '#b8891f', t: null }, r: [ 'tttttttttttttttt', 'tttttttttttttttt', 'tttaaaaaaaaaattt', 'ttaaaaaaaaaaaatt', 'taaabbbbbbbbaaat', 'taabbbbbbbbbbaat', 'taabbbbbbbbbbaat', 'tabbbbbbbbbbbbat', 'tabbbbbbbbbbbbat', 'tabbbcccccbbbbat', 'taccccccccccccat', 'taccccccccccccat', 'ttcccccccccccctt', 'tttccccccccccttt', 'tttttttttttttttt', 'tttttttttttttttt', ] }, book: { p: { a: '#8a5a33', b: '#6b4423', w: '#f0e6d2', y: '#e0c060', t: null }, r: [ 'tbbbbbbbbbbbbbbt', 'bawwwwwwwwwwwwab', 'bawwwwwwwwwwwwab', 'bawwbbbwwbbbwwab', 'bawwwwwwwwwwwwab', 'bawwbbbbwbbbbwab', 'bawwwwwwwwwwwwab', 'bawwbbbwwbbbwwab', 'bawwwwwwwwwwwwab', 'bawwbbbbwbbbbwab', 'bawwwwwwwwwwwwab', 'bawwwwyywwwwwwab', 'bawwwwyywwwwwwab', 'bawwwwwwwwwwwwab', 'bawwwwwwwwwwwwab', 'tbbbbbbbbbbbbbbt', ] }, map: { p: { a: '#e8dcc0', b: '#cdbb95', c: '#8a5a33', g: '#7cbd56', u: '#4a90d9' }, r: [ 'cccccccccccccccc', 'caaaaaaaaaaaaaac', 'cabbaaaaaaaaaabc', 'caaauuuaaggaaaac', 'caaauuuaagggaaac', 'caaaauaaaggaaaac', 'caaaaaaaaaaaaaac', 'caagggaaaaauuaac', 'caaggggaaaauuaac', 'caagggaaaaauuaac', 'caaaaaaaaaaaaaac', 'caaaaaabbaaaaaac', 'caaaaabbbbaaaaac', 'caaaaaabbaaaaaac', 'caaaaaaaaaaaaaac', 'cccccccccccccccc', ] }, star: { p: { a: '#ffffff', b: '#dfe6f2', c: '#9fb0cc', t: null }, r: [ 'tttttttaattttttt', 'tttttttaattttttt', 'ttttttbaabtttttt', 'ttttttbaabtttttt', 'tttttbbaabbttttt', 'ttattbbaabbttatt', 'tabaabbaabbaabat', 'taaaaaaaaaaaaaat', 'taaaaaaaaaaaaaat', 'tabaabbaabbaabat', 'ttcttbbaabbttctt', 'tttttbbaabbttttt', 'ttttttbaabtttttt', 'ttttttbaabtttttt', 'tttttttaattttttt', 'tttttttaattttttt', ] }, }; // baut aus dem Raster ein SVG function pixelIconRects(key, scale = 1, offsetX = 0, offsetY = 0) { const def = PIX[key] || PIX.grass; let rects = ''; def.r.forEach((row, y) => { for (let x = 0; x < 16 && x < row.length; x++) { const col = def.p[row[x]]; if (!col || col === '#00000000') continue; rects += ``; } }); return rects; } function pixelIconSvg(key, size) { const rects = pixelIconRects(key); return `${rects}`; } const HD_ICON_THEMES = { grass: { glyph: 'loop', bg: '#46b96c', shade: '#1f5b33', glow: '#95f08a' }, dirt: { glyph: 'cube', bg: '#a96d43', shade: '#58311a', glow: '#d9a56e' }, stone: { glyph: 'cube', bg: '#98a3b3', shade: '#495364', glow: '#dfe5ef' }, planks: { glyph: 'cube', bg: '#cf9b61', shade: '#704521', glow: '#f2cb97' }, tnt: { glyph: 'crate', bg: '#db4d45', shade: '#6d1d18', glow: '#fff2d2' }, obsidian: { glyph: 'cube', bg: '#6f5cd7', shade: '#25184d', glow: '#b8abff' }, netherrack: { glyph: 'cube', bg: '#c14f4f', shade: '#5d1717', glow: '#ff9a7b' }, crafting: { glyph: 'grid', bg: '#bf8750', shade: '#613a20', glow: '#f5d5a4' }, furnace: { glyph: 'forge', bg: '#7e8997', shade: '#38414c', glow: '#ffbd67' }, bookshelf: { glyph: 'books', bg: '#b77a4d', shade: '#5b331f', glow: '#ffd966' }, diamondblock: { glyph: 'cube', bg: '#42d0d7', shade: '#14606d', glow: '#dfffff' }, goldblock: { glyph: 'cube', bg: '#f0bf3c', shade: '#8b5a09', glow: '#fff1a1' }, emeraldblock: { glyph: 'cube', bg: '#2ec97b', shade: '#0b5a35', glow: '#b4ffd7' }, redstoneblock: { glyph: 'cube', bg: '#e14d51', shade: '#75191d', glow: '#ffb1b4' }, diamond: { glyph: 'gem', bg: '#47dce0', shade: '#126b79', glow: '#defcff' }, emerald: { glyph: 'gem', bg: '#2fd485', shade: '#0d683c', glow: '#d8ffe8' }, gold: { glyph: 'ingot', bg: '#f2c44e', shade: '#8d6208', glow: '#fff3b4' }, iron: { glyph: 'ingot', bg: '#b9c3d1', shade: '#57606d', glow: '#f5f8fc' }, redstone: { glyph: 'spark', bg: '#ea5956', shade: '#7e1717', glow: '#ffc8c6' }, coal: { glyph: 'coal', bg: '#59606b', shade: '#1f242b', glow: '#cfd5df' }, sword: { glyph: 'sword', bg: '#5d9ff4', shade: '#1f477c', glow: '#eef6ff' }, pickaxe: { glyph: 'pickaxe', bg: '#57a8de', shade: '#1f5276', glow: '#ebf8ff' }, axe: { glyph: 'axe', bg: '#6997df', shade: '#27456c', glow: '#eef4ff' }, bow: { glyph: 'bow', bg: '#b97a58', shade: '#5c3522', glow: '#ffe1cb' }, apple: { glyph: 'apple', bg: '#ea5353', shade: '#7e1717', glow: '#ffd3d3' }, goldenapple: { glyph: 'apple', bg: '#f0c44f', shade: '#8c6208', glow: '#fff1b2' }, book: { glyph: 'book', bg: '#7a5ad3', shade: '#33205f', glow: '#ece4ff' }, enchantedbook: { glyph: 'bookSpark', bg: '#6f63e7', shade: '#272266', glow: '#d9fcff' }, map: { glyph: 'map', bg: '#5aa67d', shade: '#1d563f', glow: '#f0f1c9' }, star: { glyph: 'star', bg: '#5f7ce0', shade: '#22357c', glow: '#ffffff' }, endereye: { glyph: 'eye', bg: '#3da984', shade: '#124a40', glow: '#ccfff0' }, enderpearl: { glyph: 'pearl', bg: '#4cc0c7', shade: '#155b62', glow: '#e6ffff' }, blaze: { glyph: 'flame', bg: '#ef8b38', shade: '#7c3a06', glow: '#ffe3a3' }, xp: { glyph: 'bottle', bg: '#3ea977', shade: '#15503a', glow: '#dcffe0' }, cake: { glyph: 'cake', bg: '#f08cab', shade: '#8a3453', glow: '#fff6dc' }, bucket: { glyph: 'bucket', bg: '#5b94dc', shade: '#1f4a79', glow: '#d8f1ff' }, lava: { glyph: 'bucket', bg: '#ec7134', shade: '#862d08', glow: '#ffd4ab' }, tnt_item: { glyph: 'powder', bg: '#9ba2af', shade: '#404753', glow: '#fff4ce' }, creeper: { glyph: 'creeper', bg: '#67c35b', shade: '#234f1c', glow: '#d7ffd0' }, chest: { glyph: 'chest', bg: '#c4884f', shade: '#653a1d', glow: '#f5d28f' }, potion: { glyph: 'potion', bg: '#8d63d9', shade: '#3b2466', glow: '#ffd7f5' }, nether: { glyph: 'flame', bg: '#d65a3b', shade: '#6b2111', glow: '#ffd1a0' }, end: { glyph: 'eye', bg: '#3f8d7d', shade: '#153f39', glow: '#d6fff4' }, }; const LEGACY_REAL_ICON_MAP = { chest: 'chest', creeper: 'creeper', potion: 'potion', nether: 'blaze', end: 'endereye', tnt_item: 'tnt_item', }; function hdIconTheme(key) { return HD_ICON_THEMES[key] || { glyph: 'letter', bg: '#5b6474', shade: '#252b34', glow: '#e8edf7' }; } function hdIconGlyph(key, theme) { switch (theme.glyph) { case 'cube': return ` `; case 'loop': return ` `; case 'crate': return ` `; case 'grid': return ` `; case 'forge': return ` `; case 'books': return ` `; case 'gem': return ` `; case 'ingot': return ` `; case 'spark': return ` `; case 'coal': return ` `; case 'sword': return ` `; case 'pickaxe': return ` `; case 'axe': return ` `; case 'bow': return ` `; case 'apple': return ` `; case 'book': return ` `; case 'bookSpark': return ` `; case 'map': return ` `; case 'star': return ` `; case 'eye': return ` `; case 'pearl': return ` `; case 'flame': return ` `; case 'bottle': return ` `; case 'cake': return ` `; case 'bucket': return ` `; case 'powder': return ` `; case 'creeper': return ` `; case 'chest': return ` `; case 'potion': return ` `; case 'letter': return `${escapeHtml((key || '?').slice(0, 1).toUpperCase())}`; default: return pixelIconSvg(key, 44); } } function hdIconSvg(key, size = 44) { const theme = hdIconTheme(key); const gid = `g-${String(key || 'icon').replace(/[^a-z0-9_-]/gi, '-')}-${size}`; return ` `; } const ICON_KEYS = Object.keys(PIX); const ICON_LABELS = { grass: 'Grasblock', creeper: 'Creeper', diamond: 'Diamant', tnt: 'TNT', chest: 'Truhe', sword: 'Schwert', pickaxe: 'Spitzhacke', potion: 'Trank', nether: 'Feuer', end: 'Endauge', redstone: 'Redstone', gold: 'Goldbarren', book: 'Buch', map: 'Karte', star: 'Netherstern', }; // echte Minecraft-Icons (aus den Spieldateien extrahiert), key -> dataUri let mcIcons = {}; async function loadMcIcons() { try { let list = await window.api.iconsList(); const needRefresh = !list.length || !['potion', 'chest', 'creeper', 'paper', 'slimeball', 'cookie'].every((key) => list.some((i) => i.key === key)); if (needRefresh) { const res = await window.api.iconsExtract(); if (res && res.ok && Array.isArray(res.icons) && res.icons.length) list = res.icons; } mcIcons = Object.fromEntries(list.map((i) => [i.key, i])); await buildIsoCubes(); // räumliche Block-Symbole vorbereiten } catch { mcIcons = {}; } } function resolveRealIconKey(key) { if (mcIcons[key]) return key; const mapped = LEGACY_REAL_ICON_MAP[key]; if (mapped && mcIcons[mapped]) return mapped; return ''; } function realItemIconSvg(key, size = 44) { const realKey = resolveRealIconKey(key); if (!realKey) return ''; const real = mcIcons[realKey]; if (!real) return ''; const theme = hdIconTheme(realKey); const src = iso3dEnabled && BLOCK_KEYS.has(realKey) && isoCache[realKey] ? isoCache[realKey] : real.dataUri; const plate = BLOCK_KEYS.has(realKey) ? `` : ``; return ` `; } // erzeugt die Würfel-Darstellungen einmalig im Voraus async function buildIsoCubes() { const jobs = Object.values(mcIcons) .filter((i) => BLOCK_KEYS.has(i.key)) .map((i) => new Promise((resolve) => { const img = new Image(); img.onload = () => { isoSources[i.key] = img; resolve(); }; img.onerror = () => resolve(); img.src = i.dataUri; })); await Promise.all(jobs); for (const key of Object.keys(isoSources)) renderIsoCube(key); } // Block-Texturen, die als räumlicher Würfel dargestellt werden const BLOCK_KEYS = new Set(['grass', 'dirt', 'stone', 'planks', 'tnt', 'obsidian', 'netherrack', 'crafting', 'furnace', 'bookshelf', 'diamondblock', 'goldblock', 'emeraldblock', 'redstoneblock']); let iso3dEnabled = true; const isoCache = {}; // key -> fertiges Würfelbild const isoSources = {}; // key -> geladenes Textur-Bild // zeichnet aus einer 16x16-Textur einen isometrischen Würfel (Canvas -> data:-URI) function renderIsoCube(key) { const img = isoSources[key]; if (!img) return null; const S = 96, c = document.createElement('canvas'); c.width = S; c.height = S; const g = c.getContext('2d'); g.imageSmoothingEnabled = true; if ('imageSmoothingQuality' in g) g.imageSmoothingQuality = 'high'; const w = 26, h = 15, cx = S / 2, top = 8; const face = (m, bright) => { g.save(); g.setTransform(m[0], m[1], m[2], m[3], m[4], m[5]); g.drawImage(img, 0, 0, 16, 16); g.restore(); if (bright !== 1) { // Fläche abdunkeln/aufhellen g.save(); g.setTransform(m[0], m[1], m[2], m[3], m[4], m[5]); g.globalCompositeOperation = bright < 1 ? 'source-atop' : 'lighter'; g.fillStyle = bright < 1 ? `rgba(0,0,0,${(1 - bright).toFixed(2)})` : 'rgba(255,255,255,.12)'; g.fillRect(0, 0, 16, 16); g.restore(); } }; // Oberseite (Raute), linke und rechte Seitenfläche face([w / 16, h / 16, -w / 16, h / 16, cx, top], 1); face([w / 16, h / 16, 0, 30 / 16, cx - w, top + h], 0.72); face([w / 16, -h / 16, 0, 30 / 16, cx, top + h * 2], 0.55); const out = c.toDataURL('image/png'); isoCache[key] = out; return out; } // liefert das Icon-Markup als skalierbares HD-SVG; eigene Bilder der Instanz bleiben separat erhalten function iconOf(key, size = 44) { const real = realItemIconSvg(key, size); if (real) return real; if (PIX[key]) return pixelIconSvg(key, size); return hdIconSvg(key, size); } const LOADER_LABEL = { vanilla: 'Vanilla', fabric: 'Fabric', forge: 'Forge', neoforge: 'NeoForge', quilt: 'Quilt', optifine: 'OptiFine', }; // Bewährte Garbage-Collector-Flags (bekannt als "Aikar's Flags"), reduzieren // bei größeren Modpacks spürbar GC-bedingte Ruckler. Wirkt erst ab genug RAM // sinnvoll, schadet aber auch bei wenig RAM nicht. const AIKAR_FLAGS = '-XX:+UseG1GC -XX:+ParallelRefProcEnabled -XX:MaxGCPauseMillis=200 ' + '-XX:+UnlockExperimentalVMOptions -XX:+DisableExplicitGC -XX:+AlwaysPreTouch ' + '-XX:G1NewSizePercent=30 -XX:G1MaxNewSizePercent=40 -XX:G1HeapRegionSize=8M ' + '-XX:G1ReservePercent=20 -XX:G1HeapWastePercent=5 -XX:G1MixedGCCountTarget=4 ' + '-XX:InitiatingHeapOccupancyPercent=15 -XX:G1MixedGCLiveThresholdPercent=90 ' + '-XX:G1RSetUpdatingPauseTimePercent=5 -XX:SurvivorRatio=32 -XX:+PerfDisableSharedMem ' + '-XX:MaxTenuringThreshold=1'; // ---------- Zustand ---------- let instances = []; let currentDetailId = null; let selectedInstanceIds = new Set(); let editingId = null; let confirmAction = null; let mcVersionData = null; // gecachte Mojang-Versionsliste let currentModsInstalled = new Set(); // projectIds im offenen Mod-Dialog let pendingImagePath = null; // im Dialog gewähltes eigenes Bild (noch nicht gespeichert) let pendingClearImage = false; // eigenes Bild soll entfernt werden let loginVerificationUri = null; // aktueller Device-Code-Link const collapsedGroups = new Set(); // eingeklappte Gruppen let consoleBuffer = ''; // gesammelte Log-Ausgabe (roh, für Kopieren) let consolePending = ''; // noch nicht abgeschlossene Zeile // ---------- Popup-Fenster ---------- // In einem eigenen Popup-Fenster (aktuell nur Einstellungen) steht in der URL // "?popout=settings" – dann blendet die Oberfläche die Haupt-UI aus und zeigt // nur dieses eine Modal vollflächig (siehe styles.css ".is-popout"/".popout-host"). const POPOUT = new URLSearchParams(location.search).get('popout'); // ---------- DOM-Helfer ---------- const el = (id) => document.getElementById(id); function show(id) { el(id).classList.remove('hidden'); } function hide(id) { el(id).classList.add('hidden'); } function toast(msg, isError = false) { const t = el('toast'); t.textContent = msg; t.classList.toggle('err', isError); t.classList.remove('hidden'); clearTimeout(toast._t); toast._t = setTimeout(() => t.classList.add('hidden'), 2800); } function fmtDate(iso) { if (!iso) return '–'; try { return new Date(iso).toLocaleString('de-DE'); } catch { return iso; } } function status(text) { el('status-left').textContent = text; } function fmtDuration(s) { s = s || 0; if (s < 60) return s + ' Sek.'; const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60); return h ? `${h} Std. ${m} Min.` : `${m} Min.`; } function escapeHtml(s) { return String(s).replace(/[&<>"']/g, (c) => ( { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] )); } function numOrNull(v) { const n = parseInt(v, 10); return Number.isFinite(n) ? n : null; } function applyTheme(theme) { document.documentElement.setAttribute('data-theme', theme === 'light' ? 'light' : 'dark'); } // ---------- Instanzen aus MultiMC übernehmen ---------- let mmcFound = []; let cfFound = []; let mcFound = []; let technicFound = []; let importKind = 'zip'; async function ensureDefaultMcScan() { if (importKind !== 'mc') return; if (el('mc-path').value.trim()) return; if (mcFound.length) return; await scanMc(); } function setImportKind(kind) { importKind = ['zip', 'modpack', 'mc', 'cf', 'mmc', 'technic'].includes(kind) ? kind : 'zip'; ['zip', 'modpack', 'mc', 'cf', 'mmc', 'technic'].forEach((entryKind) => { el(`import-tab-${entryKind}`).classList.toggle('on', entryKind === importKind); el(`import-pane-${entryKind}`).classList.toggle('hidden', entryKind !== importKind); }); el('import-zip-do').classList.toggle('hidden', importKind !== 'zip'); el('import-modpack-do').classList.toggle('hidden', importKind !== 'modpack'); el('mc-do').classList.toggle('hidden', importKind !== 'mc'); el('cf-do').classList.toggle('hidden', importKind !== 'cf'); el('mmc-do').classList.toggle('hidden', importKind !== 'mmc'); el('technic-do').classList.toggle('hidden', importKind !== 'technic'); el('mc-progress').classList.toggle('hidden', importKind !== 'mc' || !el('mc-progress').textContent); el('cf-progress').classList.toggle('hidden', importKind !== 'cf' || !el('cf-progress').textContent); el('mmc-progress').classList.toggle('hidden', importKind !== 'mmc' || !el('mmc-progress').textContent); el('technic-progress').classList.toggle('hidden', importKind !== 'technic' || !el('technic-progress').textContent); } function openImportDialog(kind = 'zip') { if (POPOUT !== 'import') { window.api.popoutOpen('import', { kind }); return; } mcFound = []; el('mc-result').classList.add('hidden'); el('mc-do').disabled = true; el('mc-progress').textContent = ''; el('mc-progress').classList.add('hidden'); el('mc-status').textContent = 'Leer lassen nutzt automatisch den Standardpfad unter Windows.'; el('cf-result').classList.add('hidden'); el('cf-do').disabled = true; el('cf-progress').textContent = ''; el('cf-progress').classList.add('hidden'); el('cf-status').textContent = 'Standardpfad unter Windows: C:\\Users\\DeinName\\curseforge\\minecraft\\Instances'; el('mmc-result').classList.add('hidden'); el('mmc-do').disabled = true; el('mmc-progress').textContent = ''; el('mmc-progress').classList.add('hidden'); el('mmc-status').textContent = 'Gib den Ordner an, in dem MultiMC.exe oder PrismLauncher.exe liegt.'; setImportKind(kind); show('modal-import'); if (importKind === 'mc') { el('mc-path').focus(); ensureDefaultMcScan(); } if (importKind === 'cf') el('cf-path').focus(); if (importKind === 'mmc') el('mmc-path').focus(); } async function runZipImport() { el('import-zip-do').disabled = true; try { const res = await window.api.importInstance(); if (res.canceled) return; if (res.ok) { await reload(); hide('modal-import'); toast('Importiert: ' + res.instance.name); return; } toast(res.message || 'Import fehlgeschlagen.', true); } finally { el('import-zip-do').disabled = false; } } async function runModpackImport() { el('import-modpack-do').disabled = true; status('Importiere Modpack …'); try { const res = await window.api.importModpack(); status('Bereit'); el('status-right').textContent = ''; if (res.canceled) return; if (res.ok) { await reload(); hide('modal-import'); toast(`Modpack importiert: ${res.instance.name} (${res.fileCount} Dateien)`); // CurseForge gibt nicht jede Datei heraus – ehrlich benennen statt verschweigen if (res.fehlend) toast(`${res.fehlend} Datei(en) konnten nicht geladen werden und fehlen in der Instanz.`, true); return; } toast(res.message || 'Modpack-Import fehlgeschlagen.', true); if (res.needsKey) { hide('modal-import'); openSettings('pane-network'); } } finally { status('Bereit'); el('status-right').textContent = ''; el('import-modpack-do').disabled = false; } } function fmtDownloads(n) { n = Number(n) || 0; if (n >= 1e6) return (n / 1e6).toFixed(1).replace(/\.0$/, '') + 'M'; if (n >= 1e3) return (n / 1e3).toFixed(1).replace(/\.0$/, '') + 'k'; return String(n); } async function searchModpacks() { const query = el('mp-query').value.trim(); const gameVersion = el('mp-mc').value.trim(); const sort = el('mp-sort').value || 'relevance'; const source = (el('mp-source') && el('mp-source').value) || 'modrinth'; const box = el('mp-results'); const st = el('mp-status'); st.textContent = 'Suche …'; box.innerHTML = '
Suche läuft …
'; try { const res = await window.api.modpackSearch({ query, gameVersion, sort, limit: 30, offset: 0, art: 'modpack', source, }); if (res.error) { st.textContent = 'Fehler: ' + res.error; box.innerHTML = '
Suche fehlgeschlagen.
'; if (res.needKey) { const wrap = document.createElement('div'); wrap.className = 'mods-empty'; const btn = document.createElement('button'); btn.className = 'btn btn-mini'; btn.textContent = 'Zu den Netzwerk-Einstellungen'; btn.addEventListener('click', () => { hide('modal-import'); openSettings('pane-network'); }); wrap.appendChild(btn); box.appendChild(wrap); } return; } const hits = res.hits || []; st.textContent = hits.length ? (res.total + ' Treffer' + (gameVersion ? ' für ' + gameVersion : '')) : 'Keine Treffer.'; box.innerHTML = ''; if (!hits.length) { box.innerHTML = '
Keine Modpacks gefunden.
'; return; } for (const h of hits) { const item = document.createElement('div'); item.className = 'mod-item'; const meta = [ h.author ? ('von ' + h.author) : '', h.downloads != null ? (fmtDownloads(h.downloads) + ' Downloads') : '', ].filter(Boolean).join(' · '); const cats = (h.displayCategories || h.categories || []).slice(0, 4).join(', '); item.innerHTML = `
${h.iconUrl ? `` : '📦'}
${escapeHtml(h.title || h.slug || h.projectId)}
${escapeHtml(meta)}${cats ? ' · ' + escapeHtml(cats) : ''}
${escapeHtml((h.description || '').slice(0, 120))}${(h.description || '').length > 120 ? '…' : ''}
`; const btn = document.createElement('button'); btn.className = 'btn btn-primary btn-mini'; btn.textContent = 'Installieren'; btn.addEventListener('click', () => installModpackFromSearch(h, btn)); item.querySelector('.mod-action').appendChild(btn); box.appendChild(item); } } catch (err) { st.textContent = 'Fehler: ' + (err.message || err); box.innerHTML = '
Suche fehlgeschlagen.
'; } } async function installModpackFromSearch(hit, btn) { if (!hit || !hit.projectId) return; const gameVersion = el('mp-mc').value.trim(); const prev = btn ? btn.textContent : ''; if (btn) { btn.disabled = true; btn.textContent = '…'; } status('Installiere Modpack: ' + (hit.title || hit.slug || '') + ' …'); try { const res = hit.source === 'curseforge' ? await window.api.modpackInstallFromCurseForge({ cfId: hit.cfId, projectId: hit.projectId, title: hit.title || hit.slug, gameVersion, }) : await window.api.modpackInstallFromModrinth({ projectId: hit.projectId, title: hit.title || hit.slug, gameVersion, }); status('Bereit'); el('status-right').textContent = ''; if (res.ok) { await reload(); hide('modal-import'); toast(`Modpack installiert: ${res.instance.name} (${res.fileCount || 0} Dateien)`); if (currentDetailId !== res.instance.id) { try { await selectInstance(res.instance.id); } catch { /* egal */ } } return; } toast(res.message || 'Installation fehlgeschlagen.', true); } catch (err) { status('Bereit'); el('status-right').textContent = ''; toast('Installation fehlgeschlagen: ' + (err.message || err), true); } finally { if (btn) { btn.disabled = false; btn.textContent = prev || 'Installieren'; } } } async function scanMc() { const folder = el('mc-path').value.trim(); el('mc-status').textContent = 'Suche Launcher-Profile …'; const res = await window.api.mcScan(folder); if (!res.ok) { el('mc-status').textContent = '✖ Dort wurde kein launcher_profiles.json gefunden.'; el('mc-result').classList.add('hidden'); el('mc-do').disabled = true; return; } mcFound = res.instances; if (!folder && res.rootDir) el('mc-path').value = res.rootDir; el('mc-status').textContent = `${res.count} Profil${res.count === 1 ? '' : 'e'} gefunden.`; el('mc-result').classList.remove('hidden'); el('mc-all').checked = true; renderMcList(); } function renderMcList() { const box = el('mc-list'); box.innerHTML = ''; if (!mcFound.length) { box.innerHTML = '
Keine Profile gefunden.
'; return; } mcFound.forEach((inst, idx) => { const row = document.createElement('label'); row.className = 'mmc-item'; const loaderTxt = inst.loader === 'vanilla' ? 'Vanilla' : (LOADER_LABEL[inst.loader] || inst.loader) + (inst.loaderVersion ? ' ' + inst.loaderVersion : ''); const meta = [inst.version || 'ohne Version', loaderTxt, inst.usesDefaultGameDir ? 'Standard-Spielordner' : 'eigener Spielordner'] .filter(Boolean).join(' · '); row.innerHTML = `
${escapeHtml(inst.name)}
${escapeHtml(meta)}
`; box.appendChild(row); }); updateMcButton(); } function selectedMc() { return [...document.querySelectorAll('#mc-list input[type=checkbox]')] .filter((c) => c.checked) .map((c) => mcFound[Number(c.dataset.idx)]); } function updateMcButton() { const n = selectedMc().length; el('mc-do').disabled = n === 0; el('mc-do').textContent = n ? `${n} übernehmen` : 'Übernehmen'; } async function runMcImport() { const entries = selectedMc(); if (!entries.length) return; const copy = el('mc-copy').checked; el('mc-do').disabled = true; el('mc-progress').textContent = 'Übernehme …'; el('mc-progress').classList.remove('hidden'); const res = await window.api.mcImport(entries, copy); await reload(); hide('modal-import'); const n = res.imported.length; const f = res.failed.length; toast(`${n} Profil${n === 1 ? '' : 'e'} aus dem Minecraft Launcher übernommen${f ? ` · ${f} fehlgeschlagen` : ''}.`, f > 0); if (instances.length) await selectInstance(instances[0].id); } async function scanCf() { const folder = el('cf-path').value.trim(); if (!folder) { el('cf-status').textContent = 'Bitte zuerst einen Ordner angeben.'; return; } el('cf-status').textContent = 'Suche Instanzen …'; const res = await window.api.cfScan(folder); if (!res.ok) { el('cf-status').textContent = '✖ Dort wurde kein CurseForge-Instances-Ordner gefunden.'; el('cf-result').classList.add('hidden'); el('cf-do').disabled = true; return; } cfFound = res.instances; el('cf-status').textContent = `${res.count} Instanz${res.count === 1 ? '' : 'en'} gefunden.`; el('cf-result').classList.remove('hidden'); el('cf-all').checked = true; renderCfList(); } function renderCfList() { const box = el('cf-list'); box.innerHTML = ''; if (!cfFound.length) { box.innerHTML = '
Keine Instanzen gefunden.
'; return; } cfFound.forEach((inst, idx) => { const row = document.createElement('label'); row.className = 'mmc-item'; const loaderTxt = inst.loader === 'vanilla' ? 'Vanilla' : (LOADER_LABEL[inst.loader] || inst.loader) + (inst.loaderVersion ? ' ' + inst.loaderVersion : ''); const meta = [inst.version || 'ohne Version', loaderTxt] .filter(Boolean).join(' · '); row.innerHTML = `
${escapeHtml(inst.name)}
${escapeHtml(meta)}
`; box.appendChild(row); }); updateCfButton(); } function selectedCf() { return [...document.querySelectorAll('#cf-list input[type=checkbox]')] .filter((c) => c.checked) .map((c) => cfFound[Number(c.dataset.idx)]); } function updateCfButton() { const n = selectedCf().length; el('cf-do').disabled = n === 0; el('cf-do').textContent = n ? `${n} übernehmen` : 'Übernehmen'; } async function runCfImport() { const entries = selectedCf(); if (!entries.length) return; const copy = el('cf-copy').checked; el('cf-do').disabled = true; el('cf-progress').textContent = 'Übernehme …'; el('cf-progress').classList.remove('hidden'); const res = await window.api.cfImport(entries, copy); await reload(); hide('modal-import'); const n = res.imported.length; const f = res.failed.length; toast(`${n} CurseForge-Instanz${n === 1 ? '' : 'en'} übernommen${f ? ` · ${f} fehlgeschlagen` : ''}.`, f > 0); if (instances.length) await selectInstance(instances[0].id); } async function scanMmc() { const folder = el('mmc-path').value.trim(); if (!folder) { el('mmc-status').textContent = 'Bitte zuerst einen Ordner angeben.'; return; } el('mmc-status').textContent = 'Suche Instanzen …'; const res = await window.api.mmcScan(folder); if (!res.ok) { el('mmc-status').textContent = '✖ Dort wurde kein instances-Ordner gefunden.'; el('mmc-result').classList.add('hidden'); el('mmc-do').disabled = true; return; } mmcFound = res.instances; el('mmc-status').textContent = `${res.count} Instanz${res.count === 1 ? '' : 'en'} gefunden.`; el('mmc-result').classList.remove('hidden'); el('mmc-all').checked = true; renderMmcList(); } function renderMmcList() { const box = el('mmc-list'); box.innerHTML = ''; if (!mmcFound.length) { box.innerHTML = '
Keine Instanzen gefunden.
'; return; } mmcFound.forEach((inst, idx) => { const row = document.createElement('label'); row.className = 'mmc-item'; const loaderTxt = inst.loader === 'vanilla' ? 'Vanilla' : (LOADER_LABEL[inst.loader] || inst.loader) + (inst.loaderVersion ? ' ' + inst.loaderVersion : ''); const meta = [inst.version || 'ohne Version', loaderTxt, inst.group ? 'Gruppe: ' + inst.group : ''] .filter(Boolean).join(' · '); row.innerHTML = `
${escapeHtml(inst.name)}
${escapeHtml(meta)}
`; box.appendChild(row); }); updateMmcButton(); } function selectedMmc() { return [...document.querySelectorAll('#mmc-list input[type=checkbox]')] .filter((c) => c.checked) .map((c) => mmcFound[Number(c.dataset.idx)]); } function updateMmcButton() { const n = selectedMmc().length; el('mmc-do').disabled = n === 0; el('mmc-do').textContent = n ? `${n} übernehmen` : 'Übernehmen'; } async function runMmcImport() { const entries = selectedMmc(); if (!entries.length) return; const copy = el('mmc-copy').checked; el('mmc-do').disabled = true; el('mmc-progress').textContent = 'Übernehme …'; el('mmc-progress').classList.remove('hidden'); const res = await window.api.mmcImport(entries, copy); await reload(); hide('modal-import'); const n = res.imported.length; const f = res.failed.length; toast(`${n} Instanz${n === 1 ? '' : 'en'} übernommen${f ? ` · ${f} fehlgeschlagen` : ''}.`, f > 0); if (instances.length) await selectInstance(instances[0].id); } // ---------- Technic-Plattform: Modpack-Katalog ---------- async function searchTechnicPlatform() { const query = el('tp-query').value.trim(); const box = el('tp-results'); const st = el('tp-status'); st.textContent = 'Suche …'; box.innerHTML = '
Suche läuft …
'; const res = await window.api.technicPlatformSearch(query); if (!res.ok) { st.textContent = 'Fehler: ' + (res.message || 'Suche fehlgeschlagen.'); box.innerHTML = '
Suche fehlgeschlagen.
'; return; } const hits = res.hits || []; st.textContent = hits.length ? `${hits.length} Treffer` : 'Keine Treffer.'; box.innerHTML = ''; if (!hits.length) { box.innerHTML = '
Keine Modpacks gefunden.
'; return; } for (const h of hits) { const item = document.createElement('div'); item.className = 'mod-item'; item.innerHTML = `
${h.iconUrl ? `` : '📦'}
${escapeHtml(h.name)}
`; const btn = document.createElement('button'); btn.className = 'btn btn-primary btn-mini'; btn.textContent = 'Installieren'; btn.addEventListener('click', () => installTechnicFromSearch(h, btn)); item.querySelector('.mod-action').appendChild(btn); box.appendChild(item); } } async function installTechnicFromSearch(hit, btn) { if (!hit || !hit.slug) return; const prev = btn ? btn.textContent : ''; if (btn) { btn.disabled = true; btn.textContent = '…'; } const st = el('tp-status'); st.textContent = `Installiere „${hit.name}" …`; try { const res = await window.api.technicPlatformInstall(hit.slug); if (res.ok) { await reload(); hide('modal-import'); toast(`Modpack installiert: ${res.instance.name}`); await selectInstance(res.instance.id); } else { st.textContent = '✖ ' + (res.message || 'Installation fehlgeschlagen.'); toast(res.message || 'Installation fehlgeschlagen.', true); } } finally { if (btn) { btn.disabled = false; btn.textContent = prev; } } } async function scanTechnic() { const folder = el('technic-path').value.trim(); el('technic-status').textContent = 'Suche Modpacks …'; const res = await window.api.technicScan(folder); if (!res.ok) { el('technic-status').textContent = '✖ Dort wurde kein modpacks-Ordner gefunden.'; el('technic-result').classList.add('hidden'); el('technic-do').disabled = true; return; } technicFound = res.instances; if (!folder && res.instancesDir) el('technic-path').value = res.instancesDir; el('technic-status').textContent = `${res.count} Modpack${res.count === 1 ? '' : 's'} gefunden.`; el('technic-result').classList.remove('hidden'); el('technic-all').checked = true; renderTechnicList(); } function renderTechnicList() { const box = el('technic-list'); box.innerHTML = ''; if (!technicFound.length) { box.innerHTML = '
Keine Modpacks gefunden.
'; return; } technicFound.forEach((inst, idx) => { const row = document.createElement('label'); row.className = 'mmc-item'; const loaderTxt = inst.loader === 'vanilla' ? 'Vanilla' : (LOADER_LABEL[inst.loader] || inst.loader) + (inst.loaderVersion ? ' ' + inst.loaderVersion : ''); const meta = [inst.version || 'Version unbekannt', loaderTxt] .filter(Boolean).join(' · '); row.innerHTML = `
${escapeHtml(inst.name)}
${escapeHtml(meta)}
`; box.appendChild(row); }); updateTechnicButton(); } function selectedTechnic() { return [...document.querySelectorAll('#technic-list input[type=checkbox]')] .filter((c) => c.checked) .map((c) => technicFound[Number(c.dataset.idx)]); } function updateTechnicButton() { const n = selectedTechnic().length; el('technic-do').disabled = n === 0; el('technic-do').textContent = n ? `${n} übernehmen` : 'Übernehmen'; } async function runTechnicImport() { const entries = selectedTechnic(); if (!entries.length) return; const copy = el('technic-copy').checked; el('technic-do').disabled = true; el('technic-progress').textContent = 'Übernehme …'; el('technic-progress').classList.remove('hidden'); const res = await window.api.technicImport(entries, copy); await reload(); hide('modal-import'); const n = res.imported.length; const f = res.failed.length; toast(`${n} Technic-Modpack${n === 1 ? '' : 's'} übernommen${f ? ` · ${f} fehlgeschlagen` : ''}.`, f > 0); if (instances.length) await selectInstance(instances[0].id); } // ---------- Bedrock (Windows-Store-Ausgabe) ---------- let bedrockKind = 'worlds'; // Bedrock-Knopf nur zeigen, wenn aktiviert UND tatsächlich installiert async function applyBedrockVisibility() { const btn = el('btn-bedrock'); let show = false; try { const s = await window.api.getSettings(); if (s.bedrockEnabled !== false) { const info = await window.api.bedrockDetect(); show = !!info.installed; const hint = el('s-bedrock-hint'); if (hint) { hint.textContent = info.installed ? 'Bedrock erkannt (Version ' + info.version + ').' : 'Bedrock ist auf diesem PC nicht installiert – der Bereich bleibt ausgeblendet.'; } } } catch { /* im Zweifel ausblenden */ } btn.classList.toggle('hidden', !show); } async function openBedrock() { if (POPOUT !== 'bedrock') { window.api.popoutOpen('bedrock'); return; } show('modal-bedrock'); el('bd-sub').textContent = 'Wird geprüft …'; const info = await window.api.bedrockDetect(); if (!info.installed) { el('bd-sub').textContent = 'nicht installiert'; el('bd-missing').classList.remove('hidden'); el('bd-content').classList.add('hidden'); return; } el('bd-missing').classList.add('hidden'); el('bd-content').classList.remove('hidden'); el('bd-sub').textContent = 'Version ' + info.version; switchBedrockTab('worlds'); } function switchBedrockTab(kind) { bedrockKind = kind; document.querySelectorAll('.btab').forEach((t) => t.classList.toggle('on', t.dataset.kind === kind)); renderBedrockList(); } function fmtBytes(n) { if (!n) return ''; if (n < 1024) return n + ' B'; if (n < 1048576) return (n / 1024).toFixed(0) + ' KB'; return (n / 1048576).toFixed(1) + ' MB'; } async function renderBedrockList() { const box = el('bd-list'); box.innerHTML = '
Lade …
'; const kind = bedrockKind; const items = kind === 'worlds' ? await window.api.bedrockWorlds() : await window.api.bedrockList(kind); box.innerHTML = ''; if (!items.length) { const leer = { worlds: 'Noch keine Welten vorhanden.', resource_packs: 'Keine Ressourcenpakete installiert.', behavior_packs: 'Keine Verhaltenspakete installiert.', skin_packs: 'Keine Skin-Pakete installiert.', screenshots: 'Noch keine Bildschirmfotos.', }; box.innerHTML = `
${leer[kind] || 'Nichts vorhanden.'}
`; return; } for (const it of items) { const name = it.name || it.id; const item = document.createElement('div'); item.className = 'mod-item'; const meta = [fmtBytes(it.bytes), it.modified ? fmtDate(it.modified) : ''].filter(Boolean).join(' · '); item.innerHTML = `
${kind === 'worlds' ? '🌍' : kind === 'screenshots' ? '🖼' : '📦'}
${escapeHtml(name)}
${escapeHtml(meta)}
`; const actions = item.querySelector('.mod-action'); if (kind === 'worlds') { const open = document.createElement('button'); open.className = 'btn btn-mini'; open.textContent = 'Ordner'; open.addEventListener('click', () => window.api.bedrockOpenWorld(it.dir)); actions.appendChild(open); } const del = document.createElement('button'); del.className = 'btn btn-danger btn-mini'; del.textContent = 'Löschen'; del.addEventListener('click', () => askConfirm( 'Löschen', `„${name}" wird unwiderruflich gelöscht. Fortfahren?`, 'Endgültig löschen', async () => { await window.api.bedrockRemove(kind, kind === 'worlds' ? it.id : it.name); await renderBedrockList(); toast('Gelöscht: ' + name); })); actions.appendChild(del); box.appendChild(item); } } // ---------- Kontextmenü (Rechtsklick auf eine Instanz) ---------- function openContextMenu(x, y) { const menu = el('ctx-menu'); menu.classList.remove('hidden'); // erst anzeigen, dann Größe messen und am Fensterrand ausrichten const r = menu.getBoundingClientRect(); const px = Math.min(x, window.innerWidth - r.width - 6); const py = Math.min(y, window.innerHeight - r.height - 6); menu.style.left = Math.max(6, px) + 'px'; menu.style.top = Math.max(6, py) + 'px'; } function closeContextMenu() { el('ctx-menu').classList.add('hidden'); } function formatVersionLabel(version) { return String(version || '') .replace(/^v/i, '') .replace(/-beta$/i, ' Beta') .trim() || '–'; } // ---------- Automatische Updates ---------- let pendingUpdate = null; const CHANNEL_LABEL = { portable: 'Portable', setup: 'Setup' }; function showUpdateDialog(info) { if (POPOUT !== 'update') { window.api.popoutOpen('update'); return; } if (!info) return; // Popup geöffnet, aber (aktuell) kein Update-Ergebnis vorhanden pendingUpdate = info; const channel = CHANNEL_LABEL[info.installKind] || null; el('upd-current').textContent = formatVersionLabel(info.currentVersion); el('upd-new').textContent = formatVersionLabel(info.latestVersion); el('upd-channel').textContent = channel ? `Update-Kanal: ${channel}` : 'Update-Kanal: unbekannt – automatisches Update nicht möglich'; el('upd-notes').textContent = (info.notes || '').trim() || 'Keine Änderungshinweise hinterlegt.'; el('upd-progress').classList.add('hidden'); el('upd-bar-fill').style.width = '0%'; // Ohne passendes Paket wird bewusst nichts installiert – ein Portable-Launcher // darf nie einen Installer bekommen und umgekehrt. Statt eines toten Knopfes // führen wir dann zum Release, damit der Nutzer nicht in der Sackgasse steht. const hatPaket = !!info.asset; el('upd-install').classList.toggle('hidden', !hatPaket); el('upd-install').disabled = !hatPaket; el('upd-install').textContent = 'Installieren'; el('upd-open').classList.toggle('hidden', hatPaket || !info.url); el('upd-hint').textContent = hatPaket ? 'Der Launcher lädt das Update herunter und startet die Installation automatisch.' : (channel ? `Für den Kanal ${channel} liegt in diesem Release keine passende Datei bereit. Bitte im Release nachsehen und von Hand aktualisieren.` : 'Die Paketart dieses Launchers lässt sich nicht sicher bestimmen. Bitte das Update von Hand aus dem Release laden.'); show('modal-update'); } function openReleasePage() { if (pendingUpdate && pendingUpdate.url) window.api.openExternal(pendingUpdate.url); } async function startUpdate() { if (!pendingUpdate || !pendingUpdate.asset) return; const btn = el('upd-install'); btn.disabled = true; btn.textContent = 'Wird installiert …'; el('upd-progress').classList.remove('hidden'); el('upd-status').textContent = 'Wird heruntergeladen …'; const res = await window.api.updateInstall(pendingUpdate.asset); if (res.ok) { el('upd-status').textContent = res.kind === 'portable' ? 'Launcher schließt sich, wird ersetzt und startet automatisch neu.' : 'Installation wird gestartet – der Launcher schließt sich.'; } else { el('upd-status').textContent = '✖ ' + (res.message || 'Update fehlgeschlagen.'); btn.disabled = false; btn.textContent = 'Erneut versuchen'; } } function onUpdateProgress(p) { if (p.phase === 'download') { el('upd-bar-fill').style.width = p.percent + '%'; const mb = (n) => (n / 1048576).toFixed(1); el('upd-status').textContent = `Wird heruntergeladen … ${p.percent}% (${mb(p.loaded)} / ${mb(p.total)} MB)`; } else if (p.phase === 'ready') { el('upd-bar-fill').style.width = '100%'; el('upd-status').textContent = 'Download fertig – Installation startet …'; } } // Aktualisiert die Statuszeile im Info-Bereich – genutzt sowohl vom manuellen // "Nach Updates suchen"-Knopf als auch vom automatischen Check beim Start. function renderUpdateStatus(info) { const st = el('s-update-status'); if (!st) return; st.classList.remove('status-ok', 'status-bad'); if (info.checking) { st.textContent = 'Suche nach Updates …'; } else if (info.error) { st.classList.add('status-bad'); st.textContent = '✖ ' + info.error; } else if (info.noReleases) { st.textContent = 'Noch keine Veröffentlichung vorhanden.'; } else if (info.available) { st.textContent = `Version ${formatVersionLabel(info.latestVersion)} verfügbar.`; } else { st.classList.add('status-ok'); st.textContent = `✓ Du hast bereits die neueste Version (${formatVersionLabel(info.currentVersion)}).`; } } // ---------- Zustand der Anmelde-Dienste in der Statusleiste ---------- function showAuthHealth(h) { const box = el('status-auth'); if (!h || h.ok) { box.classList.add('hidden'); box.textContent = ''; return; } const list = (h.down || []).join(', '); box.textContent = (h.down && h.down.length === AUTH_SERVICE_COUNT) ? 'Anmelde-Server nicht erreichbar – Login/Start derzeit nicht möglich' : 'Anmelde-Dienst gestört: ' + list; box.title = 'Zuletzt geprüft: ' + (h.checked ? new Date(h.checked).toLocaleTimeString('de-DE') : '–') + ' · Klicken zum erneut prüfen'; box.classList.remove('hidden'); } const AUTH_SERVICE_COUNT = 3; // ---------- Skin-Hintergrund (wie MultiMCs Katze, nur mit eigenem Skin) ---------- // uuidOverride: wird vom Konten-Wechsler (siehe unten) genutzt, um gezielt // einen bestimmten Skin zu zeigen, ohne die eigentliche Einstellung anzufassen async function applySkinBackground(uuidOverride) { const s = await window.api.getSettings(); const root = document.documentElement; let uuid = ''; if (uuidOverride) { uuid = uuidOverride; } else if (s.bgSkin === 'active') { const acc = await window.api.authStatus(); uuid = acc && acc.profile ? acc.profile.id : ''; } else if (s.bgSkin) { uuid = s.bgSkin; } if (!uuid) { root.style.setProperty('--bg-skin', 'none'); return; } root.style.setProperty('--bg-skin-opacity', String((s.bgSkinOpacity ?? 55) / 100)); // Jede Pose hat auch eine Entsprechung beim 3D-Dienst (Kamerawinkel/Ansicht), // damit die Auswahl immer sichtbar wirkt – auch wenn der Pose-Dienst offline ist. // 1) Skin lokal in 3D rendern – unabhängig von Render-Diensten const local = await renderSkin3D(`https://crafthead.net/skin/${uuid}`, s.bgSkinPose || 'walking'); if (local) { root.style.setProperty('--bg-skin', `url("${local}")`); return; } // 2) Rückfall: externe Render-Dienste for (const url of [ `https://nmsr.nickac.dev/fullbody/${uuid}?yaw=25&pitch=8`, `https://visage.surgeplay.com/full/512/${uuid}`, `https://crafthead.net/armor/body/${uuid}?size=512`, ]) { const ok = await new Promise((resolve) => { const img = new Image(); img.onload = () => resolve(true); img.onerror = () => resolve(false); img.src = url; }); if (ok) { root.style.setProperty('--bg-skin', `url("${url}")`); return; } } root.style.setProperty('--bg-skin', 'none'); } // ---------- Skin-Hintergrund: automatischer Kontenwechsel ---------- let skinCycleTimer = null; let skinCycleIndex = 0; function stopSkinCycle() { if (skinCycleTimer) { clearInterval(skinCycleTimer); skinCycleTimer = null; } } async function skinCycleTick() { const accs = await window.api.authAccounts(); if (accs.length < 2) { stopSkinCycle(); await applySkinBackground(); return; } skinCycleIndex = (skinCycleIndex + 1) % accs.length; // am Ende wieder von vorn await applySkinBackground(accs[skinCycleIndex].id); } /* * Einstiegspunkt für alles, was den Skin-Hintergrund neu anzeigen soll. * Ersetzt die frühere direkte applySkinBackground()-Aufrufe: kümmert sich * zusätzlich darum, den Kontenwechsel-Timer je nach Einstellung zu * starten/stoppen, statt dass jede Aufrufstelle das selbst tun müsste. */ async function refreshSkinBackground() { stopSkinCycle(); const s = await window.api.getSettings(); if (s.bgSkinCycle) { const accs = await window.api.authAccounts(); if (accs.length >= 2) { skinCycleIndex = 0; await applySkinBackground(accs[0].id); const seconds = Math.max(5, Number(s.bgSkinCycleSeconds) || 30); skinCycleTimer = setInterval(skinCycleTick, seconds * 1000); return; } // weniger als 2 Konten angemeldet -> normal weiter wie ohne Wechsel } await applySkinBackground(); } // Pose-Vorlagen für den lokalen 3D-Renderer (eigene Nachbildungen bekannter Motive) const SKIN_POSES = { walking: { label: 'Laufend', fov: 36, zoom: 0.62, apply: (p) => { p.rotation.set(0.05, -0.62, 0); p.skin.leftArm.rotation.set(-0.95, 0, 0.06); p.skin.rightArm.rotation.set(0.85, 0, -0.06); p.skin.leftLeg.rotation.set(0.8, 0, 0); p.skin.rightLeg.rotation.set(-0.8, 0, 0); } }, standing: { label: 'Stehend', fov: 36, zoom: 0.6, apply: (p) => { p.rotation.set(0, -0.35, 0); p.skin.leftArm.rotation.set(0, 0, 0.08); p.skin.rightArm.rotation.set(0, 0, -0.08); p.skin.leftLeg.rotation.set(0, 0, 0.02); p.skin.rightLeg.rotation.set(0, 0, -0.02); } }, legends: { label: 'Heldenhaft', fov: 34, zoom: 0.58, apply: (p) => { p.rotation.set(-0.10, -0.5, 0); p.skin.leftArm.rotation.set(-0.25, 0, 0.35); p.skin.rightArm.rotation.set(-0.15, 0, -0.42); p.skin.leftLeg.rotation.set(0.12, 0, 0.16); p.skin.rightLeg.rotation.set(-0.12, 0, -0.16); } }, bedwars: { label: 'Kampfhaltung', fov: 36, zoom: 0.6, apply: (p) => { p.rotation.set(0.06, -0.85, 0); p.skin.rightArm.rotation.set(-1.45, 0, -0.18); // Arm vorgestreckt p.skin.leftArm.rotation.set(0.45, 0, 0.30); p.skin.leftLeg.rotation.set(0.45, 0, 0.10); p.skin.rightLeg.rotation.set(-0.35, 0, -0.10); } }, hoverboard: { label: 'Schwebend', fov: 38, zoom: 0.6, apply: (p) => { p.rotation.set(0.28, -0.7, 0.12); // vorgebeugt, gekippt p.skin.leftArm.rotation.set(-0.35, 0, 1.05); // Arme zum Ausbalancieren p.skin.rightArm.rotation.set(-0.35, 0, -1.05); p.skin.leftLeg.rotation.set(0.5, 0, 0.18); p.skin.rightLeg.rotation.set(0.15, 0, -0.18); } }, sitting: { label: 'Sitzend', fov: 36, zoom: 0.62, apply: (p) => { p.rotation.set(0.05, -0.55, 0); p.skin.leftArm.rotation.set(-0.55, 0, 0.18); p.skin.rightArm.rotation.set(-0.55, 0, -0.18); p.skin.leftLeg.rotation.set(-1.5, 0, 0.12); // Beine angewinkelt p.skin.rightLeg.rotation.set(-1.5, 0, -0.12); } }, spooky: { label: 'Unheimlich', fov: 32, zoom: 0.6, apply: (p) => { p.rotation.set(-0.06, -0.45, 0); // gedreht + leichte Untersicht p.skin.head.rotation.set(0.22, -0.2, 0.28); // Kopf schief gelegt p.skin.leftArm.rotation.set(-1.35, 0, 0.12); // Arme nach vorn ausgestreckt p.skin.rightArm.rotation.set(-1.25, 0, -0.16); p.skin.leftLeg.rotation.set(0.18, 0, 0.06); p.skin.rightLeg.rotation.set(-0.12, 0, -0.06); } }, }; // Rendert den Skin lokal mit WebGL in der gewählten Pose /* * Rendert eine Skin-Textur als Spielfigur und gibt ein Bild zurück. * quelle: Adresse ODER data:-URI – so lässt sich auch eine Datei anzeigen, * die noch gar nicht hochgeladen ist. * modell: 'default' (breite Arme) oder 'slim' (Alex) */ async function renderSkin3D(quelle, poseKey, opts = {}) { if (typeof skinview3d === 'undefined') return null; const canvas = el('skin-canvas'); const breite = opts.breite || 420; const hoehe = opts.hoehe || 700; try { const viewer = new skinview3d.SkinViewer({ canvas, width: breite, height: hoehe, skin: quelle, model: opts.modell === 'slim' ? 'slim' : 'default', preserveDrawingBuffer: true, }); // Auf das Laden der Skin-Textur warten await new Promise((resolve, reject) => { const t = setTimeout(reject, 8000); const check = () => { if (viewer.playerObject && viewer.playerObject.skin && viewer.playerObject.skin.map) { clearTimeout(t); resolve(); } else setTimeout(check, 60); }; check(); }); const pose = SKIN_POSES[poseKey] || SKIN_POSES.walking; viewer.fov = pose.fov; viewer.zoom = pose.zoom; viewer.autoRotate = false; viewer.animation = null; pose.apply(viewer.playerObject); viewer.render(); const data = canvas.toDataURL('image/png'); viewer.dispose(); return data && data.length > 2000 ? data : null; } catch { return null; } } // ---------- Icon-System (SVG statt Emoji) ---------- const ICONS_SVG = { plus: '', import: '', package: '', folder: '', terminal: '', user: '', users: '', settings: '', play: '', blocks: '', globe: '', image: '', edit: '', copy: '', export: '', trash: '', }; function svgIcon(name) { const p = ICONS_SVG[name]; if (!p) return ''; const filled = name === 'play'; const attrs = filled ? 'fill="currentColor" stroke="none"' : 'fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"'; return `${p}`; } function applyIcons(root = document) { root.querySelectorAll('[data-ic]').forEach((b) => { if (b.querySelector('svg')) return; b.insertAdjacentHTML('afterbegin', svgIcon(b.dataset.ic)); }); } // ---------- Instanz-Kacheln ---------- const SINGLE_SELECTION_BUTTON_IDS = [ 'p-play', 'p-offline', 'p-edit', 'p-notes', 'p-mods', 'p-worlds', 'p-screens', 'p-logs', 'p-servers', 'p-modpack', 'p-mcfolder', 'p-configfolder', 'p-instfolder', 'p-shortcut', 'p-export', 'p-copy', ]; function orderedSelectedInstanceIds() { return instances.map((inst) => inst.id).filter((id) => selectedInstanceIds.has(id)); } function syncCardSelectionUi() { document.querySelectorAll('.card').forEach((card) => { card.classList.toggle('sel', selectedInstanceIds.has(card.dataset.id)); }); } function setSingleSelectionActionsDisabled(disabled) { SINGLE_SELECTION_BUTTON_IDS.forEach((id) => { el(id).disabled = disabled; }); const groupLine = el('p-groupline'); groupLine.style.pointerEvents = disabled ? 'none' : ''; groupLine.style.color = disabled ? 'var(--dim)' : ''; } async function refreshSelectionPanel() { const selectedIds = orderedSelectedInstanceIds(); const count = selectedIds.length; const panelC = el('p-content'); const panelE = el('p-empty'); syncCardSelectionUi(); if (!count) { currentDetailId = null; setSingleSelectionActionsDisabled(false); el('p-delete').disabled = false; el('p-delete').textContent = 'Löschen'; panelC.classList.add('hidden'); panelE.classList.remove('hidden'); el('status-left').textContent = 'Bereit'; el('status-play').textContent = 'Keine Instanz ausgewählt'; el('status-total').textContent = ''; return; } if (count > 1) { currentDetailId = null; setSingleSelectionActionsDisabled(true); el('p-delete').disabled = false; el('p-delete').textContent = `${count} Instanzen löschen`; panelE.classList.add('hidden'); panelC.classList.remove('hidden'); el('p-icon').innerHTML = iconOf('chest', 60); el('p-name').textContent = `${count} Instanzen ausgewählt`; el('p-groupline').textContent = 'Mehrfachauswahl aktiv'; el('status-left').textContent = `${count} Instanzen ausgewählt`; el('status-play').textContent = 'Strg+Klick wählt weitere Instanzen aus oder ab'; el('status-total').textContent = 'Sammellöschen verfügbar'; return; } const id = selectedIds[0]; currentDetailId = id; setSingleSelectionActionsDisabled(false); el('p-delete').disabled = false; el('p-delete').textContent = 'Löschen'; window.api.saveSettings({ lastSelectedId: id || '' }); const inst = id ? await window.api.getInstance(id) : null; if (!inst) { selectedInstanceIds.delete(id); panelC.classList.add('hidden'); panelE.classList.remove('hidden'); el('status-left').textContent = 'Bereit'; el('status-play').textContent = 'Keine Instanz ausgewählt'; el('status-total').textContent = ''; return; } panelE.classList.add('hidden'); panelC.classList.remove('hidden'); // "Modpack aktualisieren" nur bei Instanzen, die aus einem Modrinth-Paket stammen el('p-modpack').classList.toggle('hidden', !(inst.modpack && inst.modpack.projectId)); const iconData = inst.customImage ? await window.api.instanceIcon(id) : null; if (iconData) el('p-icon').innerHTML = ``; else { el('p-icon').innerHTML = iconOf(inst.icon, 60); } el('p-name').textContent = inst.name; el('p-groupline').textContent = inst.group ? `Gruppe: ${inst.group}` : 'Ohne Gruppe'; const loaderTxt = inst.minecraft.loader && inst.minecraft.loader !== 'vanilla' ? ' · ' + (LOADER_LABEL[inst.minecraft.loader] || inst.minecraft.loader) : ''; el('status-left').textContent = inst.name + ' (' + (inst.minecraft.version || 'ohne Version') + loaderTxt + ')'; el('status-play').textContent = 'Zuletzt gespielt: ' + (inst.lastPlayed ? fmtDate(inst.lastPlayed) : 'nie'); el('status-total').textContent = 'Spielzeit: ' + fmtDuration(inst.totalPlaySeconds); } async function applySelection(ids) { const valid = new Set(instances.map((inst) => inst.id)); selectedInstanceIds = new Set((ids || []).filter((id) => valid.has(id))); await refreshSelectionPanel(); } async function toggleInstanceSelection(id) { const next = new Set(selectedInstanceIds); if (next.has(id)) next.delete(id); else next.add(id); await applySelection([...next]); } function buildCard(inst) { const card = document.createElement('div'); card.className = 'card' + (selectedInstanceIds.has(inst.id) ? ' sel' : ''); card.dataset.id = inst.id; const iconHtml = inst.iconData ? `` : `${iconOf(inst.icon, 60)}`; // Tooltip: Name, Version/Loader und – falls vorhanden – die Notizen const loaderTxt = inst.minecraft.loader && inst.minecraft.loader !== 'vanilla' ? ' · ' + (LOADER_LABEL[inst.minecraft.loader] || inst.minecraft.loader) : ''; let tip = inst.name + '\n' + (inst.minecraft.version || 'ohne Version') + loaderTxt; if (inst.notes && inst.notes.trim()) tip += '\n\n' + inst.notes.trim(); card.title = tip; card.innerHTML = ` ${iconHtml}
${escapeHtml(inst.name)}
`; // einfacher Klick = auswählen (MultiMC), Doppelklick = starten card.addEventListener('click', (e) => { if (e.ctrlKey || e.metaKey) toggleInstanceSelection(inst.id); else selectInstance(inst.id); }); card.addEventListener('dblclick', () => launch(inst.id)); // Rechtsklick: Instanz auswählen und Kontextmenü öffnen card.addEventListener('contextmenu', async (e) => { e.preventDefault(); if (!selectedInstanceIds.has(inst.id)) await selectInstance(inst.id); openContextMenu(e.clientX, e.clientY); // sofort anzeigen }); // Drag & Drop in Gruppen card.draggable = true; card.addEventListener('dragstart', (e) => { e.dataTransfer.setData('text/plain', inst.id); card.classList.add('dragging'); }); card.addEventListener('dragend', () => card.classList.remove('dragging')); return card; } function render() { const q = el('search').value.trim().toLowerCase(); const grid = el('grid'); grid.innerHTML = ''; const filtered = instances.filter((i) => !q || (i.name + ' ' + i.minecraft.version + ' ' + i.minecraft.loader + ' ' + (i.group || '')).toLowerCase().includes(q)); el('empty').classList.toggle('hidden', instances.length !== 0); grid.classList.toggle('hidden', instances.length === 0); // nach Gruppen bündeln const groups = new Map(); for (const inst of filtered) { const g = (inst.group && inst.group.trim()) || ''; if (!groups.has(g)) groups.set(g, []); groups.get(g).push(inst); } // benannte Gruppen alphabetisch, "ohne Gruppe" zuletzt const names = [...groups.keys()].sort((a, b) => (a === '' ? 1 : b === '' ? -1 : a.localeCompare(b))); const showHeaders = names.some((n) => n !== ''); for (const g of names) { const list = groups.get(g); const section = document.createElement('div'); section.className = 'group-section' + (collapsedGroups.has(g) ? ' collapsed' : ''); if (showHeaders) { const header = document.createElement('div'); header.className = 'group-header' + (collapsedGroups.has(g) ? ' collapsed' : ''); header.innerHTML = `${collapsedGroups.has(g) ? '▸' : '▾'} ${escapeHtml(g || 'Ohne Gruppe')} (${list.length})`; header.addEventListener('click', () => { if (collapsedGroups.has(g)) collapsedGroups.delete(g); else collapsedGroups.add(g); render(); }); section.appendChild(header); } const gridEl = document.createElement('div'); gridEl.className = 'group-grid'; for (const inst of list) gridEl.appendChild(buildCard(inst)); section.appendChild(gridEl); // Drop-Ziel: Instanz per Drag&Drop in diese Gruppe verschieben section.addEventListener('dragover', (e) => { e.preventDefault(); section.classList.add('drop-hover'); }); section.addEventListener('dragleave', (e) => { if (!section.contains(e.relatedTarget)) section.classList.remove('drop-hover'); }); section.addEventListener('drop', async (e) => { e.preventDefault(); section.classList.remove('drop-hover'); const dropId = e.dataTransfer.getData('text/plain'); const dragged = instances.find((i) => i.id === dropId); if (dragged && (dragged.group || '') !== g) { await window.api.updateInstance(dropId, { group: g }); await reload(); toast('Verschoben nach: ' + (g || 'Ohne Gruppe')); } }); grid.appendChild(section); } status(`${instances.length} Instanz${instances.length === 1 ? '' : 'en'}`); } function fillGroupDatalist() { const groups = [...new Set(instances.map((i) => i.group).filter(Boolean))].sort(); el('group-list').innerHTML = groups.map((g) => ``).join(''); } async function reload() { instances = await window.api.listInstances(); const valid = new Set(instances.map((inst) => inst.id)); selectedInstanceIds = new Set([...selectedInstanceIds].filter((id) => valid.has(id))); render(); await refreshSelectionPanel(); } // ---------- Minecraft-Versionen ---------- async function loadVersions() { if (mcVersionData) return mcVersionData; try { mcVersionData = await window.api.mcVersions(); } catch (e) { mcVersionData = { offline: true, versions: [], latest: {} }; } return mcVersionData; } function fillVersionSelect(selectedId) { const type = el('f-version-type').value; const sel = el('f-version'); const hint = el('f-version-hint'); sel.innerHTML = ''; const data = mcVersionData || { versions: [], latest: {} }; let list = data.versions || []; if (type !== 'all') list = list.filter((v) => v.type === type); // gewählte Version muss vorhanden sein (auch wenn Filter sie ausblendet) if (selectedId && !list.some((v) => v.id === selectedId)) { const found = (data.versions || []).find((v) => v.id === selectedId); list = [found || { id: selectedId, type: 'unknown' }, ...list]; } if (!list.length) { const o = document.createElement('option'); o.value = selectedId || ''; o.textContent = selectedId || '(keine Versionsliste – offline?)'; sel.appendChild(o); } else { for (const v of list.slice(0, 1200)) { const o = document.createElement('option'); o.value = v.id; o.textContent = v.id; if (v.id === selectedId) o.selected = true; sel.appendChild(o); } } if (data.offline) hint.textContent = '⚠ Offline – Versionsliste evtl. veraltet.'; else if (data.latest) hint.textContent = `Neuestes Release: ${data.latest.release || '?'} · Snapshot: ${data.latest.snapshot || '?'}`; } // Loader-Versionen (Fabric/Quilt) passend zur MC-Version laden async function updateLoaderVersions(preselect) { const loader = el('f-loader').value; const mc = el('f-version').value; const sel = el('f-loader-version'); const hint = el('f-loader-hint'); sel.innerHTML = ''; const ofTools = el('f-optifine-tools'); if (ofTools) ofTools.classList.toggle('hidden', loader !== 'optifine'); if (loader === 'vanilla') { sel.disabled = true; hint.textContent = 'Vanilla hat keinen Loader.'; return; } if (loader === 'optifine') { // Kein Loader-API bei OptiFine – die Liste zeigt bereits eingerichtete // Fassungen (siehe f-optifine-tools weiter unten), keine automatisch // ladbaren Versionen. sel.innerHTML = ''; sel.disabled = false; if (el('f-optifine-status')) el('f-optifine-status').textContent = ''; if (el('f-optifine-confirm')) el('f-optifine-confirm').classList.add('hidden'); if (!editingId) { hint.textContent = 'Erst speichern, dann lässt sich OptiFine im Bearbeiten-Dialog einrichten.'; if (el('f-optifine-install')) el('f-optifine-install').disabled = true; return; } if (el('f-optifine-install')) el('f-optifine-install').disabled = false; if (!mc) { hint.textContent = 'Erst Minecraft-Version wählen.'; return; } hint.textContent = 'Suche bereits eingerichtete OptiFine-Fassungen …'; try { const found = await window.api.optifineFind(mc); for (const v of found) { const o = document.createElement('option'); o.value = v; o.textContent = v; if (v === preselect) o.selected = true; sel.appendChild(o); } hint.textContent = found.length ? `${found.length} eingerichtete OptiFine-Fassung(en) für ${mc}.` : 'Noch keine OptiFine-Installation für diese Version – über den Knopf unten einrichten.'; } catch { hint.textContent = 'Suche fehlgeschlagen.'; } return; } sel.disabled = false; if (!mc) { hint.textContent = 'Erst Minecraft-Version wählen.'; return; } hint.textContent = 'Lade Loader-Versionen …'; try { const versions = await window.api.loaderVersions(loader, mc); for (const v of versions.slice(0, 80)) { const o = document.createElement('option'); o.value = v.version; o.textContent = v.version + (v.stable ? '' : ' (beta)'); if (v.version === preselect) o.selected = true; sel.appendChild(o); } hint.textContent = versions.length ? `${versions.length} Version(en) verfügbar` : 'Keine Loader-Version für diese MC-Version.'; } catch { hint.textContent = 'Loader-Versionen konnten nicht geladen werden.'; } } // ---------- Instanz-Dialog ---------- // alle wählbaren Icons: echte Minecraft-Texturen zuerst, dann die eigenen function availableIcons() { const real = Object.values(mcIcons).map((i) => ({ key: i.key, label: i.label, real: true })); if (real.length) return real; const own = ICON_KEYS .filter((k) => !mcIcons[k]) .map((k) => ({ key: k, label: ICON_LABELS[k] || k, real: false })); return real.concat(own); } function fillIconSelect(selected) { const sel = el('f-icon'); sel.innerHTML = ''; for (const it of availableIcons()) { const o = document.createElement('option'); o.value = it.key; o.textContent = it.label; if (it.key === selected) o.selected = true; sel.appendChild(o); } renderIconChoices(selected); } // klickbare Item-Vorschau unter der Auswahl function renderIconChoices(selected) { const box = el('f-icon-grid'); if (!box) return; box.innerHTML = ''; for (const it of availableIcons()) { const b = document.createElement('button'); b.type = 'button'; b.className = 'icon-choice' + (it.key === selected ? ' on' : ''); b.title = it.label; b.innerHTML = iconOf(it.key, 24); b.addEventListener('click', () => { el('f-icon').value = it.key; renderIconChoices(it.key); }); box.appendChild(b); } } // zeigt/versteckt die Bildvorschau im Dialog function setIconPreview(dataUri) { const box = el('f-icon-preview'); const clr = el('f-clear-image'); if (dataUri) { box.innerHTML = ``; box.classList.remove('hidden'); clr.classList.remove('hidden'); } else { box.innerHTML = ''; box.classList.add('hidden'); clr.classList.add('hidden'); } } function resetIconControls() { pendingImagePath = null; pendingClearImage = false; setIconPreview(null); } async function openCreate() { if (POPOUT !== 'instance') { window.api.popoutOpen('instance'); return; } editingId = null; el('instance-modal-title').textContent = 'Neue Instanz'; el('f-name').value = ''; el('f-group').value = ''; fillGroupDatalist(); el('f-loader').value = 'vanilla'; el('f-loader-version').value = ''; el('f-notes').value = ''; fillIconSelect('grass'); resetIconControls(); el('f-advanced').classList.add('hidden'); show('modal-instance'); el('f-name').focus(); el('f-version-hint').textContent = 'Lade Versionsliste …'; await loadVersions(); el('f-version-type').value = 'release'; fillVersionSelect(mcVersionData.latest && mcVersionData.latest.release); updateLoaderVersions(); } async function openEdit(inst) { if (POPOUT !== 'instance') { window.api.popoutOpen('instance', { instanceId: inst.id }); return; } editingId = inst.id; el('instance-modal-title').textContent = 'Instanz bearbeiten'; el('f-name').value = inst.name; el('f-group').value = inst.group || ''; fillGroupDatalist(); el('f-loader').value = inst.minecraft.loader || 'vanilla'; el('f-loader-version').value = inst.minecraft.loaderVersion || ''; el('f-notes').value = inst.notes || ''; fillIconSelect(inst.icon); resetIconControls(); if (inst.customImage) { const data = await window.api.instanceIcon(inst.id); if (data) setIconPreview(data); } el('f-min-mem').value = inst.java.minMemMb || ''; el('f-max-mem').value = inst.java.maxMemMb || ''; zeigeSpeicherHinweis(); fuelleInstanzJava(inst); el('f-extra-args').value = inst.java.extraArgs || ''; el('f-prelaunch').value = inst.preLaunchCommand || ''; el('f-postexit').value = inst.postExitCommand || ''; el('f-advanced').classList.remove('hidden'); show('modal-instance'); el('f-name').focus(); el('f-version-hint').textContent = 'Lade Versionsliste …'; await loadVersions(); el('f-version-type').value = inst.minecraft.version ? 'all' : 'release'; fillVersionSelect(inst.minecraft.version || (mcVersionData.latest && mcVersionData.latest.release)); await updateLoaderVersions(inst.minecraft.loaderVersion); } async function saveInstance() { const name = el('f-name').value.trim(); if (!name) { toast('Bitte einen Namen angeben.', true); el('f-name').focus(); return; } const payload = { name, group: el('f-group').value.trim(), icon: el('f-icon').value, notes: el('f-notes').value.trim(), minecraft: { version: el('f-version').value.trim(), loader: el('f-loader').value, loaderVersion: el('f-loader-version').value.trim(), }, }; // Speicherangaben prüfen, bevor gespeichert wird const minMem = numOrNull(el('f-min-mem').value); const maxMem = numOrNull(el('f-max-mem').value); if (minMem || maxMem) { const pruefung = await window.api.checkMemory(minMem, maxMem); if (!pruefung.ok) { toast(pruefung.message, true); return; } if (pruefung.warnung) toast(pruefung.warnung, false); } try { let targetId = editingId; if (editingId) { payload.java = { path: el('f-java').value.trim(), minMemMb: minMem, maxMemMb: maxMem, extraArgs: el('f-extra-args').value.trim(), }; payload.preLaunchCommand = el('f-prelaunch').value.trim(); payload.postExitCommand = el('f-postexit').value.trim(); await window.api.updateInstance(editingId, payload); } else { const created = await window.api.createInstance(payload); targetId = created.id; } // eigenes Bild anwenden bzw. entfernen if (pendingImagePath) await window.api.setInstanceImage(targetId, pendingImagePath); else if (pendingClearImage && editingId) await window.api.clearInstanceImage(targetId); toast(editingId ? 'Instanz gespeichert.' : 'Instanz angelegt.'); hide('modal-instance'); await reload(); await selectInstance(targetId); } catch (e) { toast('Fehler: ' + e.message, true); } } // ---------- Auswahl + rechte Aktionsleiste (MultiMC-Stil) ---------- async function selectInstance(id) { await applySelection(id ? [id] : []); } let launching = false; async function launch(id, accountId, launchOpts) { if (launching) return; launching = true; status('Vorbereiten …'); el('status-right').textContent = ''; appendConsole('\n=== Start: ' + new Date().toLocaleTimeString('de-DE') + ' ===\n'); try { const res = await window.api.launchInstance(id, accountId, launchOpts || null); if (res.ok) { status('Läuft (PID ' + res.pid + ')'); toast('Minecraft gestartet.'); const s = await window.api.getSettings(); if (s.showConsoleOnLaunch) openConsole(); // nur wenn gewünscht } else if (res.needAuth) { status('Bereit'); el('status-right').textContent = ''; toast(res.message, false); } else { status('Bereit'); el('status-right').textContent = ''; toast(res.message || 'Start fehlgeschlagen.', true); } } catch (e) { status('Bereit'); toast('Fehler: ' + e.message, true); } finally { launching = false; } } // ---------- Java je Instanz ---------- async function fuelleInstanzJava(inst) { const feld = el('f-java'); if (!feld) return; feld.value = (inst && inst.java && inst.java.path) || ''; const sel = el('f-java-detected'); sel.innerHTML = ''; try { for (const j of await window.api.listJava()) { const o = document.createElement('option'); o.value = j.path; o.textContent = `Java ${j.major} (${j.arch}) – ${j.path}`; sel.appendChild(o); } } catch { /* Liste bleibt leer */ } sel.value = feld.value && [...sel.options].some((o) => o.value === feld.value) ? feld.value : ''; // Der häufigste Stolperstein: global ist eine feste Fassung eingetragen, // die dann auch für alte Minecraft-Versionen genommen wird. try { const s = await window.api.getSettings(); el('f-java-hint').textContent = s.javaPath ? `Achtung: In den Einstellungen ist ${s.javaPath} fest vorgegeben und gilt für alle Instanzen. ` + 'Trage hier etwas ein, wenn diese Instanz eine andere Fassung braucht – ältere Minecraft-Versionen verlangen oft Java 8.' : 'Leer lassen: Der Launcher nimmt die Fassung, die diese Minecraft-Version verlangt, und lädt sie bei Bedarf selbst.'; } catch { /* egal */ } } // ---------- Arbeitsspeicher ---------- let systemSpeicher = null; async function zeigeSpeicherHinweis() { const feld = el('f-mem-hint'); if (!feld) return; try { if (!systemSpeicher) systemSpeicher = await window.api.systemMemory(); } catch { return; } const gesamt = systemSpeicher.totalMb; const empfohlen = Math.floor(gesamt * 0.5 / 256) * 256; feld.textContent = `Im Rechner stecken ${gesamt} MB. Leer lassen übernimmt die globale Vorgabe. ` + `Für die meisten Modpacks reichen 4096–8192 MB; mehr als ${Math.floor(gesamt * 0.8 / 256) * 256} MB sollten es nicht sein ` + `(Vorschlag: ${empfohlen} MB).`; } // ---------- Modpack aktualisieren ---------- let modpackNeueVersion = null; async function openModpackUpdate() { if (!currentDetailId) return; modpackNeueVersion = null; el('mp-status').textContent = 'Suche nach einer neueren Fassung …'; el('mp-kern').classList.add('hidden'); el('mp-do').classList.add('hidden'); show('modal-modpack'); const res = await window.api.modpackCheck(currentDetailId); if (!res.ok) { el('mp-status').textContent = '✖ ' + (res.message || 'Suche fehlgeschlagen.'); return; } if (!res.verfuegbar) { el('mp-status').textContent = `${res.name || 'Das Modpack'} ist auf dem neuesten Stand (${res.aktuelleNummer}).`; return; } modpackNeueVersion = res.neueVersionId; el('mp-status').textContent = `Für ${res.name || 'dieses Modpack'} gibt es eine neuere Fassung.`; const zeile = (t, w) => (w ? `
${t}
${escapeHtml(w)}
` : ''); el('mp-kern').innerHTML = zeile('Installiert', res.aktuelleNummer) + zeile('Neu', res.neueNummer) + zeile('Minecraft', res.spielVersion) + (res.hinweise ? zeile('Änderungen', res.hinweise.slice(0, 400)) : ''); el('mp-kern').classList.remove('hidden'); el('mp-do').classList.remove('hidden'); } async function doModpackUpdate() { if (!modpackNeueVersion) return; const btn = el('mp-do'); btn.disabled = true; el('mp-status').textContent = 'Wird aktualisiert – das kann einige Minuten dauern …'; const res = await window.api.modpackUpdate(currentDetailId, modpackNeueVersion); btn.disabled = false; if (!res.ok) { el('mp-status').textContent = '✖ ' + (res.message || 'Aktualisierung fehlgeschlagen.'); return; } el('mp-status').textContent = `✔ Auf ${res.neueNummer} aktualisiert: ${res.eingespielt} Dateien eingespielt` + (res.entfernt ? `, ${res.entfernt} entfernt` : '') + '.'; el('mp-do').classList.add('hidden'); toast('Modpack aktualisiert.'); await reload(); await selectInstance(currentDetailId); } // ---------- Serverliste ---------- let zuUebertragenderServer = null; async function openServers() { if (!currentDetailId) return; if (POPOUT !== 'servers') { window.api.popoutOpen('servers', { instanceId: currentDetailId }); return; } const inst = await window.api.getInstance(currentDetailId); el('servers-title').textContent = 'Server – ' + (inst ? inst.name : ''); el('srv-name').value = ''; el('srv-ip').value = ''; show('modal-servers'); await renderServers(); } async function renderServers() { const box = el('srv-list'); box.innerHTML = '
Lade …
'; const res = await window.api.serversList(currentDetailId); box.innerHTML = ''; if (!res.ok) { box.innerHTML = `
${escapeHtml(res.message || 'Liste nicht lesbar.')}
`; return; } if (!res.server.length) { box.innerHTML = '
Noch keine Server eingetragen.
'; return; } for (const s of res.server) { const item = document.createElement('div'); item.className = 'mod-item'; item.innerHTML = `
🖥
${escapeHtml(s.name || s.ip)}
${escapeHtml(s.ip)}
Wird geprüft …
`; const aktionen = item.querySelector('.mod-action'); pingServerRow(s.ip, item.querySelector('.srv-status')); const starten = document.createElement('button'); starten.className = 'btn btn-primary btn-mini'; starten.textContent = 'Starten'; starten.title = 'Instanz starten und direkt diesem Server beitreten (Quick Play, ab MC 1.20)'; starten.addEventListener('click', () => { hide('modal-servers'); launch(currentDetailId, null, { quickPlayServer: s.ip }); }); aktionen.appendChild(starten); const favorit = document.createElement('button'); favorit.className = 'btn btn-mini'; favorit.textContent = '★ Verknüpfung'; favorit.title = 'Desktop-Verknüpfung erstellen, die Minecraft startet und direkt mit diesem Server verbindet (Quick Play, ab MC 1.20)'; favorit.addEventListener('click', async () => { const inst = await window.api.getInstance(currentDetailId); const vorschlag = `${inst ? inst.name : 'Instanz'} - ${s.name || s.ip}`; const eingabe = await askPrompt({ title: 'Server-Verknüpfung erstellen', text: 'Name der Desktop-Verknüpfung (ohne .lnk):', label: 'Name', value: vorschlag, okLabel: 'Erstellen', }); if (eingabe == null) return; // abgebrochen const res = await window.api.serversShortcut(currentDetailId, s.ip, s.name, eingabe.trim()); if (res.ok) toast('Verknüpfung auf dem Desktop erstellt: ' + (s.name || s.ip)); else toast(res.message || 'Verknüpfung fehlgeschlagen.', true); }); aktionen.appendChild(favorit); const umbenennen = document.createElement('button'); umbenennen.className = 'btn btn-mini'; umbenennen.textContent = 'Umbenennen'; umbenennen.title = 'Anzeigenamen ändern'; umbenennen.addEventListener('click', async () => { const aktuell = s.name || s.ip; const eingabe = await askPrompt({ title: 'Server umbenennen', text: 'Adresse: ' + s.ip, label: 'Name', value: aktuell, okLabel: 'Speichern', }); if (eingabe == null) return; // abgebrochen const res = await window.api.serversRename(currentDetailId, s.ip, eingabe); if (!res.ok) { toast(res.message || 'Umbenennen fehlgeschlagen.', true); return; } if (!res.unveraendert) toast('Umbenannt: ' + eingabe.trim()); await renderServers(); }); aktionen.appendChild(umbenennen); const kopieren = document.createElement('button'); kopieren.className = 'btn btn-mini'; kopieren.textContent = 'Übertragen…'; kopieren.title = 'Diesen Server in andere Instanzen eintragen'; kopieren.addEventListener('click', () => openServerCopy(s)); aktionen.appendChild(kopieren); const weg = document.createElement('button'); weg.className = 'btn btn-danger btn-mini'; weg.textContent = 'Entfernen'; weg.addEventListener('click', async () => { const r = await window.api.serversRemove(currentDetailId, s.ip); if (r.ok) toast('Entfernt: ' + (s.name || s.ip)); else toast(r.message || 'Entfernen fehlgeschlagen.', true); await renderServers(); }); aktionen.appendChild(weg); box.appendChild(item); } } // Fragt Live-Status (MOTD/Spieler/Version) ab und trägt ihn in die Zeile ein. // Läuft unabhängig für jede Zeile, ohne die Liste selbst zu blockieren. async function pingServerRow(ip, statusEl) { if (!statusEl) return; const res = await window.api.serversPing(ip); // Zeile kann inzwischen aus dem DOM entfernt worden sein (Liste neu aufgebaut) if (!statusEl.isConnected) return; if (!res.ok) { statusEl.textContent = '⚠ ' + (res.message || 'nicht erreichbar'); statusEl.classList.add('status-bad'); return; } const spieler = res.max != null ? `${res.online ?? '?'}/${res.max} Spieler` : ''; statusEl.textContent = [res.motd, spieler, res.version, res.latencyMs + ' ms'] .filter(Boolean).join(' · '); statusEl.classList.add('status-ok'); } async function addServer() { const eintrag = { name: el('srv-name').value.trim(), ip: el('srv-ip').value.trim() }; const res = await window.api.serversAdd(currentDetailId, eintrag); if (!res.ok) { toast(res.message || 'Hinzufügen fehlgeschlagen.', true); return; } el('srv-name').value = ''; el('srv-ip').value = ''; toast('Server hinzugefügt.'); await renderServers(); } async function openServerCopy(server) { zuUebertragenderServer = server; el('srv-copy-sub').textContent = `„${server.name || server.ip}" (${server.ip}) in diese Instanzen eintragen:`; const box = el('srv-copy-list'); box.innerHTML = ''; const alle = await window.api.listInstances(); const andere = alle.filter((i) => i.id !== currentDetailId); if (!andere.length) { box.innerHTML = '
Es gibt keine weitere Instanz.
'; } for (const i of andere) { const zeile = document.createElement('label'); zeile.className = 'checkbox-field'; zeile.style.padding = '6px 2px'; zeile.innerHTML = ` ${escapeHtml(i.name)}`; box.appendChild(zeile); } show('modal-srv-copy'); } async function doServerCopy() { if (!zuUebertragenderServer) return; const ids = [...el('srv-copy-list').querySelectorAll('input:checked')].map((c) => c.value); if (!ids.length) { toast('Keine Instanz ausgewählt.', true); return; } const res = await window.api.serversCopyTo(ids, zuUebertragenderServer); hide('modal-srv-copy'); if (res.erledigt.length) toast(`Eingetragen in: ${res.erledigt.join(', ')}`); for (const f of res.fehler) toast(`${f.name || f.id}: ${f.message}`, true); } // ---------- Skin ändern ---------- let gewaehlterSkin = null; // Textur einer Seite merken, damit bei einem Wechsel der Armform beide // Figuren neu gerendert werden können let skinTexturAktuell = ''; let skinTexturNeu = ''; // laufende Viewer je Canvas -- vor einem Neuaufbau erst abräumen, sonst // stapeln sich bei jedem Wechsel (Datei, Armform, ...) weitere WebGL-Kontexte const skinViewers = {}; function schliesseSkinViewer(bildId) { if (skinViewers[bildId]) { try { skinViewers[bildId].dispose(); } catch { /* egal */ } delete skinViewers[bildId]; } } /* * Skin-Textur als frei mit der Maus drehbare 3D-Figur zeigen (Ziehen dreht, * Mausrad zoomt -- kommt von skinview3d selbst). Klappt das Rendern nicht, * wird stattdessen ein Hinweistext gezeigt. */ async function zeigeSkinFigur(bildId, leerId, textur, leerText) { schliesseSkinViewer(bildId); const canvas = el(bildId); const leer = el(leerId); canvas.classList.add('hidden'); if (!textur) { leer.textContent = leerText; leer.classList.remove('hidden'); return; } if (typeof skinview3d === 'undefined') { leer.textContent = 'Vorschau nicht verfügbar'; leer.classList.remove('hidden'); return; } leer.textContent = 'wird gerendert …'; leer.classList.remove('hidden'); const modell = el('skin-variante').value === 'slim' ? 'slim' : 'default'; try { const viewer = new skinview3d.SkinViewer({ canvas, width: canvas.clientWidth || 260, height: canvas.clientHeight || 288, skin: textur, model: modell, }); // Auf das Laden der Skin-Textur warten, bevor Pose/Kamera gesetzt werden await new Promise((resolve, reject) => { const t = setTimeout(reject, 8000); const check = () => { if (viewer.playerObject && viewer.playerObject.skin && viewer.playerObject.skin.map) { clearTimeout(t); resolve(); } else setTimeout(check, 60); }; check(); }); const pose = SKIN_POSES.standing; viewer.fov = pose.fov; viewer.zoom = pose.zoom; viewer.autoRotate = false; viewer.animation = null; pose.apply(viewer.playerObject); viewer.controls.enableRotate = true; viewer.controls.enableZoom = true; viewer.controls.enablePan = false; skinViewers[bildId] = viewer; canvas.classList.remove('hidden'); leer.classList.add('hidden'); } catch { leer.textContent = 'Vorschau nicht ladbar'; leer.classList.remove('hidden'); } } async function openSkin() { if (POPOUT !== 'skin') { window.api.popoutOpen('skin'); return; } gewaehlterSkin = null; skinTexturAktuell = ''; skinTexturNeu = ''; el('skin-pfad').value = ''; el('skin-apply').disabled = true; el('skin-status').textContent = ''; await zeigeSkinFigur('skin-neu', 'skin-neu-leer', '', 'noch keine Datei gewählt'); await zeigeSkinFigur('skin-aktuell', 'skin-aktuell-leer', '', 'wird geladen …'); el('skin-konto').textContent = 'Profil wird geladen …'; show('modal-skin'); const p = await window.api.skinProfile(); if (!p.ok) { el('skin-konto').textContent = p.message || 'Profil konnte nicht geladen werden.'; await zeigeSkinFigur('skin-aktuell', 'skin-aktuell-leer', '', 'kein Profil geladen'); return; } el('skin-konto').textContent = `Angemeldet als ${p.name}. Die Änderung gilt für dieses Konto.`; // Armform zuerst setzen – davon hängt ab, wie die Figur gerendert wird if (p.variante) el('skin-variante').value = p.variante === 'slim' ? 'slim' : 'classic'; skinTexturAktuell = p.skinUrl || ''; await zeigeSkinFigur('skin-aktuell', 'skin-aktuell-leer', skinTexturAktuell, 'Standard-Skin (keine eigene Datei hinterlegt)'); } // Armform gewechselt -> beide Figuren neu zeichnen async function skinVarianteGewechselt() { if (skinTexturAktuell) await zeigeSkinFigur('skin-aktuell', 'skin-aktuell-leer', skinTexturAktuell, ''); if (skinTexturNeu) await zeigeSkinFigur('skin-neu', 'skin-neu-leer', skinTexturNeu, ''); } async function waehleSkinDatei() { const res = await window.api.skinChoose(); if (res.canceled) return; if (!res.ok) { el('skin-status').textContent = '✖ ' + res.message; return; } gewaehlterSkin = res.pfad; el('skin-pfad').value = res.pfad; skinTexturNeu = res.dataUri || ''; await zeigeSkinFigur('skin-neu', 'skin-neu-leer', skinTexturNeu, 'Vorschau nicht verfügbar'); el('skin-status').textContent = `${res.breite}×${res.hoehe} Pixel – sieht gut aus.`; el('skin-apply').disabled = false; } async function setzeSkin() { if (!gewaehlterSkin) return; const btn = el('skin-apply'); btn.disabled = true; el('skin-status').textContent = 'Wird hochgeladen …'; const res = await window.api.skinApply(gewaehlterSkin, el('skin-variante').value); if (res.ok) { el('skin-status').textContent = '✔ Skin gesetzt. Im Spiel kann es einen Moment dauern.'; toast('Skin geändert.'); await openSkin(); // Vorschau mit dem neuen Stand auffrischen await refreshSkinBackground(); // auch der Hintergrund zeigt jetzt den neuen Skin } else { el('skin-status').textContent = '✖ ' + (res.message || 'Fehlgeschlagen.'); btn.disabled = false; } } async function setzeSkinZurueck() { const btn = el('skin-reset'); btn.disabled = true; el('skin-status').textContent = 'Wird zurückgesetzt …'; const res = await window.api.skinReset(); if (res.ok) { toast('Skin auf Standard zurückgesetzt.'); await openSkin(); await refreshSkinBackground(); } else { el('skin-status').textContent = '✖ ' + (res.message || 'Fehlgeschlagen.'); } btn.disabled = false; } // ---------- Logdateien ---------- let aktuellesLog = ''; async function openLogs() { if (!currentDetailId) return; if (POPOUT !== 'logs') { window.api.popoutOpen('logs', { instanceId: currentDetailId }); return; } const inst = await window.api.getInstance(currentDetailId); el('logs-title').textContent = 'Logdateien – ' + (inst ? inst.name : ''); const auswahl = el('logs-select'); auswahl.innerHTML = ''; el('logs-text').textContent = 'Lade …'; el('logs-info').textContent = ''; aktuellesLog = ''; show('modal-logs'); const dateien = await window.api.logsList(currentDetailId); if (!dateien.length) { el('logs-text').textContent = 'Für diese Instanz gibt es noch keine Logdateien. ' + 'Sie entstehen beim ersten Spielstart.'; return; } for (const d of dateien) { const o = document.createElement('option'); o.value = d.name; const kb = Math.max(1, Math.round(d.groesse / 1024)); o.textContent = `${d.name} · ${kb} KB · ${new Date(d.geaendert).toLocaleString('de-DE')}` + (d.gepackt ? ' · gepackt' : ''); auswahl.appendChild(o); } await ladeLog(dateien[0].name); } async function ladeLog(name) { el('logs-text').textContent = 'Lade …'; const res = await window.api.logsRead(currentDetailId, name); if (!res.ok) { el('logs-text').textContent = 'Datei konnte nicht gelesen werden.'; el('logs-info').textContent = ''; aktuellesLog = ''; return; } aktuellesLog = res.text; el('logs-text').textContent = res.text || '(leer)'; el('logs-info').textContent = res.gekuerzt ? `${res.zeilenGesamt} Zeilen – die letzten 5000 werden gezeigt` : `${res.zeilenGesamt} Zeilen`; // ans Ende springen: dort steht, was zuletzt passierte const feld = el('logs-text'); feld.scrollTop = feld.scrollHeight; } // ---------- Absturzberichte ---------- let letzterStartMs = 0; let aktuellerCrash = null; async function zeigeAbsturzbericht(instanzId) { const id = instanzId || currentDetailId; if (!id) return; if (POPOUT !== 'crash') { window.api.popoutOpen('crash', { instanceId: id }); return; } // Minecraft schreibt den Bericht erst kurz nach dem Ende await new Promise((r) => setTimeout(r, 900)); let res; try { res = await window.api.crashLatest(id, letzterStartMs); } catch { return; } if (!res || !res.ok) return; // kein Bericht -> Konsole muss reichen aktuellerCrash = res; const inst = await window.api.getInstance(id); el('crash-sub').textContent = `${inst ? inst.name + ' · ' : ''}${res.datei} · ` + new Date(res.zeitpunkt).toLocaleString('de-DE'); const zeile = (titel, wert) => wert ? `
${titel}
${escapeHtml(wert)}
` : ''; let html = zeile('Diagnose', res.diagnose) + zeile('Empfohlene Lösung', res.empfehlung) + zeile('Beschreibung', res.beschreibung) + zeile('Fehler', res.fehler) + zeile('Verdächtige Mods', res.verdaechtigeMods); if (res.verlauf && res.verlauf.length) { html += `
Aufrufverlauf
` + `
${escapeHtml(res.verlauf.join('\n'))}
`; } el('crash-kern').innerHTML = html || '
Der Bericht enthält keine der üblichen Kennzeichen.
'; el('crash-voll').textContent = res.text || ''; show('modal-crash'); } // Fortschrittsmeldungen der Download-/Start-Pipeline function onLaunchEvt(evt) { const labels = { version: 'Version', client: 'Client', libraries: 'Libraries', assets: 'Assets', loader: 'Loader', java: 'Java', launch: 'Start', modpack: 'Modpack', backup: 'Sicherung' }; if (evt.phase === 'log') { appendConsole(evt.line); return; } if (evt.phase === 'exit') { status('Minecraft beendet (Code ' + evt.code + ')'); el('status-right').textContent = ''; appendConsole('\n[Prozess beendet, Code ' + evt.code + ']\n'); // War vorher "Stopping!" im Log (regulärer Client-Shutdown), zählt ein // nachträglicher harter Exit-Code nicht als Absturz während des Spiels – // z. B. wenn Minecrafts interner Shutdown-Watchdog wegen hängender // Mod-Threads (z. B. JourneyMap WebMap) die JVM nach Ablauf einer // Wartezeit zwangsweise beendet. Der Bericht bleibt trotzdem gespeichert. const crashed = evt.code !== 0 && evt.code !== null && !evt.cleanShutdown; if (evt.cleanShutdown && evt.code !== 0 && evt.code !== null) { appendConsole('[Hinweis] Der Client wurde regulär beendet (Stopping!), aber danach vom ' + 'internen Shutdown-Watchdog hart terminiert (z. B. durch einen Mod, dessen Threads ' + 'nicht rechtzeitig beenden – etwa JourneyMap WebMap). Kein Absturz während des Spiels.\n'); } // Absturzbericht suchen, bevor über die Konsole entschieden wird – ein // Bericht sagt mehr als der Java-Auswurf. Das entscheidet nur das // Hauptfenster – sonst würde z. B. das Konsolen-Popup-Fenster bei einem // Absturz zusätzlich einen eigenen Absturzbericht aufmachen. if (!POPOUT) { if (crashed) zeigeAbsturzbericht(evt.id); window.api.getSettings().then((s) => { if (crashed && s.showConsoleOnCrash !== false) { openConsole(); return; } if (s.autoCloseConsole) hide('modal-console'); }).catch(() => {}); } return; } // Startzeitpunkt merken: nur Berichte ab hier gehören zu diesem Lauf if (evt.phase === 'launch') letzterStartMs = Date.now(); const label = labels[evt.phase] || evt.phase; if (evt.total) { status(`${label} … ${evt.current}/${evt.total}`); el('status-right').textContent = Math.round((evt.current / evt.total) * 100) + '%'; } else { status(evt.detail || label + ' …'); el('status-right').textContent = ''; appendConsole('[' + label + '] ' + (evt.detail || '') + '\n'); } } // ---------- Konsole ---------- function classifyLine(l) { if (/\/(ERROR|FATAL|SEVERE)\]/.test(l) || /Exception(:| in )|^\s+at\s|Caused by:/.test(l)) return 'log-error'; if (/\/WARN(ING)?\]/.test(l) || /\bWARN\b/.test(l)) return 'log-warn'; if (/\[Pre-Launch\]|\[Post-Exit\]|=== Start:|\[Prozess beendet/.test(l)) return 'log-info'; return ''; } function appendLineEl(out, line) { const div = document.createElement('div'); div.className = 'log-line ' + classifyLine(line); div.textContent = line; out.appendChild(div); } let consoleMaxChars = 300000; // aus den Einstellungen abgeleitet (Zeilen x ~80 Zeichen) function appendConsole(text) { consoleBuffer += text; if (consoleBuffer.length > consoleMaxChars) consoleBuffer = consoleBuffer.slice(-Math.round(consoleMaxChars * 0.75)); consolePending += text; const lines = consolePending.split('\n'); consolePending = lines.pop(); const out = el('console-out'); if (!el('modal-console').classList.contains('hidden')) { for (const line of lines) appendLineEl(out, line); out.scrollTop = out.scrollHeight; } } function openConsole() { if (POPOUT !== 'console') { window.api.popoutOpen('console'); return; } const out = el('console-out'); out.innerHTML = ''; const lines = (consoleBuffer || '(noch keine Ausgabe)').split('\n'); for (const line of lines) appendLineEl(out, line); consolePending = ''; show('modal-console'); out.scrollTop = out.scrollHeight; } // ---------- Welten & Ressourcenpakete ---------- async function openContent() { if (POPOUT !== 'content') { window.api.popoutOpen('content', { instanceId: currentDetailId }); return; } const inst = await window.api.getInstance(currentDetailId); el('content-title').textContent = 'Welten, Ressourcen- & Datenpakete – ' + inst.name; await renderContentLists(); show('modal-content'); } async function renderContentLists() { renderContentList('content-worlds', await window.api.worlds(currentDetailId), 'saves', '🌍'); renderContentList('content-rp', await window.api.resourcepacks(currentDetailId), 'resourcepacks', '🎨'); renderContentList('content-dp', await window.api.datapacks(currentDetailId), 'datapacks', '📜'); } // ---------- Screenshots ---------- let screenSelected = new Set(); function updateScreensSelectionUi() { const n = screenSelected.size; const btn = el('screens-delete-sel'); if (btn) { btn.disabled = n === 0; btn.textContent = n ? (n + ' löschen') : 'Auswahl löschen'; } const hint = el('screens-sel-hint'); if (hint) hint.textContent = n ? (n + ' ausgewählt – Doppelklick öffnet das Bild') : 'Klick auf Vorschaubild zum Auswählen · Doppelklick öffnet'; document.querySelectorAll('#screens-grid .screen-thumb').forEach((t) => { const name = t.dataset.name; const on = name && screenSelected.has(name); t.classList.toggle('selected', !!on); const cb = t.querySelector('.screen-check'); if (cb) cb.checked = !!on; }); } // ---------- Spielaufzeichnung ---------- let mcVideoStream = null; let mcSearchTimer = null; let mcSearchElapsed = 0; let mcCurrentSource = null; // zuletzt erfasste Fensterquelle - für ein Neu-Erfassen bei Auflösungswechsel // Auflösung/Qualität/Bildrate der Aufnahme (bleiben über mehrere Aufnahmen in // diesem Fenster erhalten, wie schon bei den Audioeinstellungen) let mcResolution = 'native'; let mcQuality = 'medium'; let mcFramerate = 30; let mcActualSize = null; // {width,height} der zuletzt tatsächlich erfassten Auflösung let mcMicSelectedIds = []; // gespeicherte Mikrofon-Auswahl (deviceIds) - für den Aufbau der Mikrofonliste let mcCursorAusgeblendet = false; // true, wenn die zuletzt erfasste Vorschau ohne Mauszeiger läuft let mcRohStream = null; // native, unskalierte Bildschirm-Erfassung, falls mcVideoStream ein daraus erzeugter Canvas-Stream ist let mcCanvasRAF = null; // requestAnimationFrame-Kennung der Zuschnitt-/Skalierungs-Zeichenschleife let mcCanvasVideoEl = null; // verstecktes