'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 ``;
}
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 ? `
})
` : '📦'}
`;
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 ``;
}
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 ? `` : '');
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