'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; }
// Designs: 'dark'/'light' = Klassisch (MultiMC-Look), 'aero' = wie die Webseite,
// 'lila' = Aero in dunklem Lila, 'minecraft' = Block-Optik. Alle außer Klassisch
// bauen auf den dunklen Regeln auf (data-theme="dark") und überschreiben sie über data-design.
const DESIGNS = ['dark', 'light', 'aero', 'lila', 'minecraft'];
// ---------- Bewegte Oberfläche (Optionen → Darstellung → Animationen) ----------
let uiAnimationen = true;
const MIT_STARTANIMATION = new URLSearchParams(location.search).get('startanimation') === '1';
function bewegungErlaubt() {
return uiAnimationen && !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
function setzeUiAnimationen(an) {
uiAnimationen = an !== false;
document.documentElement.classList.toggle('ui-animiert', bewegungErlaubt());
if (!bewegungErlaubt()) beendeSkinLive();
}
// Design weich überblenden statt hart umzuschalten (View Transitions von Chromium)
function designUeberblenden(theme) {
const root = document.documentElement;
const vorher = root.getAttribute('data-design') + '/' + root.getAttribute('data-theme');
const t = DESIGNS.includes(theme) ? theme : 'dark';
const nachher = (['aero', 'lila', 'minecraft'].includes(t) ? t : 'klassisch') + '/' + (t === 'light' ? 'light' : 'dark');
// versteckte Fenster (z. B. Hauptfenster im Infobereich) zeichnen nicht –
// dort liefe der Übergang nie an, also gleich umschalten
if (vorher === nachher || !bewegungErlaubt() || !document.startViewTransition || document.hidden) { applyTheme(theme); return; }
document.startViewTransition(() => applyTheme(theme));
setTimeout(() => applyTheme(theme), 600); // Sicherheitsnetz, falls der Übergang hängt
}
function applyTheme(theme) {
const t = DESIGNS.includes(theme) ? theme : 'dark';
const root = document.documentElement;
root.setAttribute('data-theme', t === 'light' ? 'light' : 'dark');
root.setAttribute('data-design', ['aero', 'lila', 'minecraft'].includes(t) ? t : 'klassisch');
if (!POPOUT && window.api.setTitleBarDesign) window.api.setTitleBarDesign(t);
document.querySelectorAll('.design-wahl').forEach((b) => b.classList.toggle('on', b.dataset.design === t));
}
// ---------- Instanzen aus MultiMC übernehmen ----------
let mmcFound = [];
let cfFound = [];
let mrFound = [];
let mcFound = [];
let technicFound = [];
let importKind = 'zip';
// Nach der Installation: im Setup ausgewählte Launcher, die noch angeboten werden
let importWarteschlange = [];
// Nach einem Import nicht gleich schließen, solange weitere vorbereitete Launcher warten
// ---------- Fortschritt großer Importe ----------
function zuGroesse(b) {
const gb = b / 1073741824;
if (gb >= 1) return gb.toLocaleString('de-DE', { maximumFractionDigits: 1, minimumFractionDigits: 1 }) + ' GB';
return Math.max(0, Math.round(b / 1048576)).toLocaleString('de-DE') + ' MB';
}
function zuDauer(s) {
if (s == null || !isFinite(s)) return '';
if (s < 60) return 'noch wenige Sekunden';
const m = Math.round(s / 60);
if (m < 60) return `noch ca. ${m} Min.`;
return `noch ca. ${Math.floor(m / 60)} Std. ${m % 60} Min.`;
}
function zeigeImportFortschritt(p) {
const balken = el('import-lauf-balken');
const leiste = balken.parentElement;
el('import-lauf').classList.remove('hidden');
if (p.phase === 'zaehlen') {
el('import-lauf-titel').textContent = 'Größe wird ermittelt …';
leiste.classList.add('unbestimmt');
el('import-lauf-zeile').textContent = `${p.dateien.toLocaleString('de-DE')} ${p.dateien === 1 ? 'Datei' : 'Dateien'} · ${zuGroesse(p.bytes)} gefunden`;
el('import-lauf-datei').textContent = '';
return;
}
leiste.classList.remove('unbestimmt');
const titel = p.von > 1 ? `Instanz ${p.nr} von ${p.von}: ${p.instanz}` : `Übernehme ${p.instanz}`;
if (p.phase === 'modpack') {
el('import-lauf-titel').textContent = `${titel} – Modpack-Verknüpfung …`;
return;
}
el('import-lauf-titel').textContent = titel;
const anteil = p.gesamtBytes > 0 ? Math.min(1, p.bytes / p.gesamtBytes) : (p.gesamtDateien ? p.dateien / p.gesamtDateien : 0);
balken.style.width = (anteil * 100).toFixed(1) + '%';
const teile = [`${zuGroesse(p.bytes)} von ${zuGroesse(p.gesamtBytes)} (${Math.round(anteil * 100)} %)`];
if (p.tempo > 0) teile.push(zuGroesse(p.tempo) + '/s');
const rest = zuDauer(p.restSekunden);
if (rest) teile.push(rest);
el('import-lauf-zeile').textContent = teile.join(' · ');
el('import-lauf-datei').textContent = p.datei ? `${p.dateien.toLocaleString('de-DE')} / ${p.gesamtDateien.toLocaleString('de-DE')} Dateien · ${p.datei}` : '';
}
/*
* Führt einen Launcher-Import mit Fortschrittsanzeige aus. Gibt das Ergebnis
* zurück – oder null, wenn er gar nicht erst lief (z. B. zu wenig Platz).
*/
async function importMitFortschritt(aufruf) {
el('import-lauf-titel').textContent = 'Wird vorbereitet …';
el('import-lauf-zeile').textContent = '';
el('import-lauf-datei').textContent = '';
el('import-lauf-balken').style.width = '0';
el('import-lauf-abbrechen').disabled = false;
el('import-lauf').classList.remove('hidden');
let res;
try { res = await aufruf(); }
catch (err) { res = { ok: false, message: err.message }; }
el('import-lauf').classList.add('hidden');
if (!res || !res.ok) {
toast(res && res.message ? res.message : 'Import fehlgeschlagen.', true);
return null;
}
if (res.uebersprungen && res.uebersprungen.length) {
// nach der Zusammenfassung zeigen, sonst überschreibt diese den Hinweis
setTimeout(() => toast(`${res.uebersprungen.length} Datei${res.uebersprungen.length === 1 ? '' : 'en'} konnte${res.uebersprungen.length === 1 ? '' : 'n'} nicht kopiert werden (z. B. gerade geöffnet) und wurde${res.uebersprungen.length === 1 ? '' : 'n'} übersprungen.`, true), 3000);
}
return res;
}
function importAbgeschlossen(kind) {
importWarteschlange = importWarteschlange.filter((k) => k !== kind);
if (importWarteschlange.length) {
setImportKind(importWarteschlange[0]);
return;
}
hide('modal-import');
}
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', 'mr', 'mmc', 'technic', 'code'].includes(kind) ? kind : 'zip';
['zip', 'modpack', 'mc', 'cf', 'mr', 'mmc', 'technic', 'code'].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('mr-do').classList.toggle('hidden', importKind !== 'mr');
el('mmc-do').classList.toggle('hidden', importKind !== 'mmc');
el('technic-do').classList.toggle('hidden', importKind !== 'technic');
el('code-do').classList.toggle('hidden', importKind !== 'code');
el('code-progress').classList.toggle('hidden', importKind !== 'code' || !el('code-progress').textContent);
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('mr-progress').classList.toggle('hidden', importKind !== 'mr' || !el('mr-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', vorauswahl = null) {
if (POPOUT !== 'import') { window.api.popoutOpen('import', { kind }); return; }
importWarteschlange = [];
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';
mrFound = [];
el('mr-result').classList.add('hidden');
el('mr-do').disabled = true;
el('mr-progress').textContent = '';
el('mr-progress').classList.add('hidden');
el('mr-status').textContent = 'Leer lassen nutzt automatisch den Standardort der Modrinth App.';
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();
// Modrinth App: Standardort gleich durchsuchen, wie beim Minecraft Launcher
if (importKind === 'mr' && !(Array.isArray(vorauswahl) && vorauswahl.length)) scanMr();
if (importKind === 'mmc') el('mmc-path').focus();
if (Array.isArray(vorauswahl) && vorauswahl.length) uebernehmeSetupVorauswahl(vorauswahl);
}
/*
* Vom Setup vorbereitet: die dort angekreuzten Launcher (Art + Ordner) gleich
* eintragen und durchsuchen, damit man beim ersten Start nur noch auswählt.
*/
function uebernehmeSetupVorauswahl(quellen) {
const scanner = { mc: scanMc, cf: scanCf, mr: scanMr, mmc: scanMmc, technic: scanTechnic };
importWarteschlange = quellen.map((q) => q && q.kind).filter((k) => scanner[k]);
for (const q of quellen) {
if (!q || !scanner[q.kind]) continue;
if (q.path) el(`${q.kind}-path`).value = q.path;
scanner[q.kind]().catch(() => { /* Status zeigt den Fehler im jeweiligen Reiter */ });
}
if (importWarteschlange.length) {
setImportKind(importWarteschlange[0]);
toast('Aus deinen anderen Launchern gefunden – wähle aus, was AeroMC übernehmen soll.');
}
}
async function runZipImport() {
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 importMitFortschritt(() => window.api.mcImport(entries, copy));
if (!res) { el('mc-do').disabled = false; el('mc-progress').classList.add('hidden'); return; }
await reload();
importAbgeschlossen('mc');
const n = res.imported.length;
const f = res.failed.length;
toast(`${n} Profil${n === 1 ? '' : 'e'} aus dem Minecraft Launcher übernommen${f ? ` · ${f} fehlgeschlagen` : ''}${res.abgebrochen ? ' – Rest abgebrochen' : ''}.`, 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 importMitFortschritt(() => window.api.cfImport(entries, copy));
if (!res) { el('cf-do').disabled = false; el('cf-progress').classList.add('hidden'); return; }
await reload();
importAbgeschlossen('cf');
const n = res.imported.length;
const f = res.failed.length;
toast(`${n} CurseForge-Instanz${n === 1 ? '' : 'en'} übernommen${f ? ` · ${f} fehlgeschlagen` : ''}${res.abgebrochen ? ' – Rest abgebrochen' : ''}.`, f > 0);
if (instances.length) await selectInstance(instances[0].id);
}
async function scanMr() {
const folder = el('mr-path').value.trim();
el('mr-status').textContent = 'Suche Instanzen der Modrinth App …';
const res = await window.api.mrScan(folder);
if (!res.ok) {
el('mr-status').textContent = folder
? '✖ Dort wurden keine Daten der Modrinth App gefunden. Gemeint ist der Ordner mit „app.db“ und „profiles“.'
: '✖ Die Modrinth App wurde am Standardort nicht gefunden. Ist sie installiert? Sonst den Datenordner wählen.';
el('mr-result').classList.add('hidden');
el('mr-do').disabled = true;
return;
}
mrFound = res.instances;
el('mr-status').textContent = `${res.count} Instanz${res.count === 1 ? '' : 'en'} gefunden (${res.basis}).`
+ (res.hinweis ? ' ⚠ ' + res.hinweis : '');
el('mr-result').classList.remove('hidden');
el('mr-all').checked = true;
renderMrList();
}
function renderMrList() {
const box = el('mr-list');
box.innerHTML = '';
if (!mrFound.length) { box.innerHTML = 'Keine Instanzen gefunden.
'; return; }
mrFound.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 : '',
inst.modpack ? 'Modrinth-Modpack' : '', inst.playSeconds ? fmtDuration(inst.playSeconds) + ' gespielt' : '',
inst.installiert === false ? 'nicht fertig installiert' : '']
.filter(Boolean).join(' · ');
row.innerHTML =
`
${escapeHtml(inst.name)}
${escapeHtml(meta)}
`;
box.appendChild(row);
});
updateMrButton();
}
function selectedMr() {
return [...document.querySelectorAll('#mr-list input[type=checkbox]')]
.filter((c) => c.checked)
.map((c) => mrFound[Number(c.dataset.idx)]);
}
function updateMrButton() {
const n = selectedMr().length;
el('mr-do').disabled = n === 0;
el('mr-do').textContent = n ? `${n} übernehmen` : 'Übernehmen';
}
async function runMrImport() {
const entries = selectedMr();
if (!entries.length) return;
const copy = el('mr-copy').checked;
el('mr-do').disabled = true;
el('mr-progress').textContent = 'Übernehme …';
el('mr-progress').classList.remove('hidden');
const res = await importMitFortschritt(() => window.api.mrImport(entries, copy));
if (!res) { el('mr-do').disabled = false; el('mr-progress').classList.add('hidden'); return; }
await reload();
importAbgeschlossen('mr');
const n = res.imported.length;
const f = res.failed.length;
const ohneLink = res.imported.filter((i) => i.modpack === false).length;
toast(`${n} Instanz${n === 1 ? '' : 'en'} aus der Modrinth App übernommen${f ? ` · ${f} fehlgeschlagen` : ''}${res.abgebrochen ? ' – Rest abgebrochen' : ''}`
+ (ohneLink ? ` · ${ohneLink}× Modpack-Verknüpfung nicht möglich (offline?)` : '') + '.', 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 importMitFortschritt(() => window.api.mmcImport(entries, copy));
if (!res) { el('mmc-do').disabled = false; el('mmc-progress').classList.add('hidden'); return; }
await reload();
importAbgeschlossen('mmc');
const n = res.imported.length;
const f = res.failed.length;
toast(`${n} Instanz${n === 1 ? '' : 'en'} übernommen${f ? ` · ${f} fehlgeschlagen` : ''}${res.abgebrochen ? ' – Rest abgebrochen' : ''}.`, 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 importMitFortschritt(() => window.api.technicImport(entries, copy));
if (!res) { el('technic-do').disabled = false; el('technic-progress').classList.add('hidden'); return; }
await reload();
importAbgeschlossen('technic');
const n = res.imported.length;
const f = res.failed.length;
toast(`${n} Technic-Modpack${n === 1 ? '' : 's'} übernommen${f ? ` · ${f} fehlgeschlagen` : ''}${res.abgebrochen ? ' – Rest abgebrochen' : ''}.`, 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);
}
}
// ---------- Auswahl-Dialog ----------
// waehleAus({ titel, text, optionen: [{ wert, label, sub, bild, aus }], textfeld, okLabel })
// -> Promise<{ wert, text } | null>
let wahlAufloesen = null;
let wahlGewaehlt = null;
function waehleAus({ titel, text, optionen, textfeld, okLabel }) {
if (wahlAufloesen) wahlAufloesen(null);
return new Promise((resolve) => {
wahlAufloesen = resolve;
wahlGewaehlt = null;
el('wahl-titel').textContent = titel;
el('wahl-text').textContent = text || '';
el('wahl-text').classList.toggle('hidden', !text);
const liste = el('wahl-liste');
liste.innerHTML = '';
for (const o of optionen) {
const b = document.createElement('button');
b.className = 'wahl-option';
b.disabled = !!o.aus;
b.innerHTML = `${o.bild ? `
` : ''}`
+ `${escapeHtml(o.label)}${o.sub ? `${escapeHtml(o.sub)}` : ''}`;
const bild = b.querySelector('img');
if (bild) bild.addEventListener('error', () => bild.remove());
b.addEventListener('click', () => {
wahlGewaehlt = o.wert;
liste.querySelectorAll('.wahl-option').forEach((x) => x.classList.toggle('an', x === b));
el('wahl-ok').disabled = false;
});
b.addEventListener('dblclick', () => { wahlGewaehlt = o.wert; schliesseWahl(true); });
liste.appendChild(b);
}
el('wahl-eingabe-feld').classList.toggle('hidden', !textfeld);
el('wahl-eingabe').value = '';
if (textfeld) {
el('wahl-eingabe-label').textContent = textfeld.label || 'Text';
el('wahl-eingabe').placeholder = textfeld.placeholder || '';
}
el('wahl-ok').textContent = okLabel || 'OK';
el('wahl-ok').disabled = true;
show('modal-wahl');
});
}
function schliesseWahl(bestaetigt) {
const aufloesen = wahlAufloesen;
wahlAufloesen = null;
hide('modal-wahl');
if (aufloesen) aufloesen(bestaetigt && wahlGewaehlt !== null ? { wert: wahlGewaehlt, text: el('wahl-eingabe').value.trim() } : null);
}
// ---------- Schnellstart-Tasten (Strg+1 … Strg+5) ----------
let schnellstartBelegung = {};
function schnellstartTasteVon(id) {
return Object.keys(schnellstartBelegung).find((t) => schnellstartBelegung[t] === id) || null;
}
async function setzeSchnellstart(id) {
const inst = instances.find((i) => i.id === id);
if (!inst) return;
const s = await window.api.getSettings();
const belegung = Object.assign({}, s.schnellstart || {});
const aktuell = Object.keys(belegung).find((t) => belegung[t] === id);
const optionen = ['1', '2', '3', '4', '5'].map((t) => {
const belegt = belegung[t] && belegung[t] !== id ? instances.find((i) => i.id === belegung[t]) : null;
return {
wert: t,
label: `Strg+${t}`,
sub: belegung[t] === id ? 'aktuell diese Instanz' : (belegt ? `ersetzt „${belegt.name}“` : 'frei'),
};
});
if (aktuell) optionen.push({ wert: '0', label: 'Keine Taste', sub: 'Schnellstart-Taste entfernen' });
const wahl = await waehleAus({
titel: `Schnellstart für „${inst.name}“`,
text: 'Mit der Taste startest du diese Instanz direkt aus dem Launcher-Fenster. Im Infobereich (Rechtsklick auf das Symbol neben der Uhr) steht sie ganz oben.',
optionen,
okLabel: 'Übernehmen',
});
if (!wahl) return;
for (const t of Object.keys(belegung)) if (belegung[t] === id) delete belegung[t];
if (wahl.wert !== '0') belegung[wahl.wert] = id;
await window.api.saveSettings({ schnellstart: belegung });
schnellstartBelegung = belegung;
render();
toast(wahl.wert === '0' ? 'Schnellstart-Taste entfernt.' : `„${inst.name}“ startet jetzt mit Strg+${wahl.wert}.`);
}
// ---------- 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');
}
// ---------- Konto-Menü (Klick auf den Kontoknopf) ----------
function schliesseKontoMenue() {
el('konto-menu').classList.add('hidden');
}
async function oeffneKontoMenue() {
const menu = el('konto-menu');
if (!menu.classList.contains('hidden')) { schliesseKontoMenue(); return; }
closeContextMenu();
// feste, alphabetische Reihenfolge – sonst springt das aktive Konto nach oben
const konten = (await window.api.authAccounts()).slice().sort((a, b) => a.name.localeCompare(b.name, 'de', { sensitivity: 'base' }));
menu.innerHTML = '';
const eintrag = (inhalt, aktion, klasse = '') => {
const b = document.createElement('button');
b.className = 'ctx-item konto-eintrag ' + klasse;
b.innerHTML = inhalt;
b.addEventListener('click', async () => { schliesseKontoMenue(); await aktion(); });
menu.appendChild(b);
return b;
};
for (const k of konten) {
const b = eintrag(
`${k.active ? '✓' : ''}
${escapeHtml(k.name)}${k.playing ? 'spielt' : ''}`,
async () => {
if (k.active) return;
await window.api.authSetActive(k.id);
await refreshAccountButton();
await refreshAllAccountLists();
await refreshSkinBackground();
toast('Aktives Konto: ' + k.name);
},
k.active ? 'aktiv' : '');
b.title = k.active ? 'Wird beim nächsten Start verwendet' : 'Für den nächsten Start verwenden';
const img = b.querySelector('img');
kontoKopf(k.id).then((src) => { img.src = src; });
}
if (konten.length) menu.appendChild(Object.assign(document.createElement('div'), { className: 'ctx-sep' }));
eintrag(`${svgIcon('plus')}Konto hinzufügen…`, () => openLogin({ addAccount: true }));
if (konten.some((k) => k.active)) eintrag(`${svgIcon('user')}Skin ändern…`, () => openSkin());
eintrag(`${svgIcon('users')}Konten verwalten…`, () => openLogin());
// unter dem Knopf, rechtsbündig – aber im Fenster bleiben
const r = el('btn-account').getBoundingClientRect();
menu.classList.remove('hidden');
const breite = menu.offsetWidth;
menu.style.left = Math.max(6, Math.min(r.right - breite, innerWidth - breite - 6)) + 'px';
menu.style.top = (r.bottom + 4) + 'px';
}
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) { beendeSkinLive(); 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
// Armform: fest gewählt oder (Standard) die des Kontos – sonst sieht ein
// schmaler Alex-Skin mit breiten Armen falsch aus (grauer Streifen am Arm)
let modell = s.bgSkinModel === 'slim' || s.bgSkinModel === 'classic' ? s.bgSkinModel : '';
if (!modell) { try { modell = await window.api.skinModelFor(uuid); } catch { modell = 'classic'; } }
let umhang = null;
try { const r = await window.api.capeForUuid(uuid); umhang = r ? r.dataUri : null; } catch { /* ohne Umhang */ }
// 1a) bewegt (Atmen, Kopf folgt der Maus) – sonst wie bisher als Standbild
if (await zeigeLebendigeFigur(`https://crafthead.net/skin/${uuid}`, s.bgSkinPose || 'walking', { modell, umhang })) {
root.style.setProperty('--bg-skin', 'none');
return;
}
const local = await renderSkin3D(`https://crafthead.net/skin/${uuid}`, s.bgSkinPose || 'walking', { modell, umhang });
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');
}
// ---------- Lebendige Skin-Figur im Hintergrund ----------
// Statt eines starren Bildes läuft eine echte 3D-Figur in der gewählten Pose:
// sie atmet, wiegt sich leicht, der Umhang schwingt, und der Kopf folgt dem
// Mauszeiger. Pausiert, solange das Fenster minimiert/im Hintergrund ist.
let skinLive = null;
function skinLiveGroesse() {
const r = document.querySelector('.list-area').getBoundingClientRect();
// wie der frühere Hintergrund: 52 % Breite × 88 % Höhe, Seitenverhältnis 420:700
const w = Math.max(60, Math.min(r.width * 0.52, r.height * 0.88 * 0.6));
return { w: Math.round(w), h: Math.round(w / 0.6) };
}
function beendeSkinLive() {
if (!skinLive) return;
const { viewer, abmelden } = skinLive;
skinLive = null;
abmelden();
try { viewer.dispose(); } catch { /* egal */ }
const c = el('skin-live');
if (c) c.classList.add('hidden');
const flaeche = document.querySelector('.list-area');
if (flaeche) flaeche.classList.remove('skin-lebt');
}
async function zeigeLebendigeFigur(quelle, poseKey, opts = {}) {
beendeSkinLive();
const canvas = el('skin-live');
if (POPOUT || !canvas || typeof skinview3d === 'undefined' || !bewegungErlaubt()) return false;
const { w, h } = skinLiveGroesse();
let viewer;
try {
viewer = new skinview3d.SkinViewer({ canvas, width: w, height: h, skin: quelle, model: opts.modell === 'slim' ? 'slim' : 'default' });
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();
});
} catch {
try { if (viewer) viewer.dispose(); } catch { /* egal */ }
return false;
}
const pose = SKIN_POSES[poseKey] || SKIN_POSES.walking;
viewer.fov = pose.fov;
viewer.zoom = pose.zoom;
viewer.autoRotate = false;
viewer.controls.enabled = false;
if (opts.umhang) { try { await viewer.loadCape(opts.umhang); } catch { /* ohne Umhang */ } }
// Grundhaltung merken: Pose einmal anwenden und Kopf/Umhang/Höhe festhalten
const p = viewer.playerObject;
pose.apply(p);
const kopfBasis = p.skin.head.rotation.clone();
const umhangBasis = p.cape ? p.cape.rotation.x : 0;
const hoeheBasis = p.position.y;
const koerperBasis = p.rotation.y;
const ziel = { yaw: 0, pitch: 0 };
const kopf = { yaw: 0, pitch: 0 };
const maus = (e) => {
const r = canvas.getBoundingClientRect();
const kx = r.left + r.width * 0.5;
const ky = r.top + r.height * 0.22; // Kopf sitzt im oberen Fünftel der Figur
ziel.yaw = Math.max(-0.75, Math.min(0.75, (e.clientX - kx) / 520));
ziel.pitch = Math.max(-0.4, Math.min(0.45, (e.clientY - ky) / 650));
};
const weg = () => { ziel.yaw = 0; ziel.pitch = 0; };
viewer.animation = new skinview3d.FunctionAnimation((pl, t) => {
pose.apply(pl); // Arme/Beine jedes Mal frisch aus der Pose
const atem = Math.sin(t * 1.8);
pl.position.y = hoeheBasis + atem * 0.18; // Brustkorb hebt und senkt sich
pl.rotation.y = koerperBasis + Math.sin(t * 0.55) * 0.05;
pl.skin.leftArm.rotation.z += atem * 0.03;
pl.skin.rightArm.rotation.z -= atem * 0.03;
if (pl.cape) pl.cape.rotation.x = umhangBasis + 0.06 + Math.sin(t * 1.3) * 0.05;
kopf.yaw += (ziel.yaw - kopf.yaw) * 0.07; // weich nachführen
kopf.pitch += (ziel.pitch - kopf.pitch) * 0.07;
// ohne Maus schaut die Figur halb zum Betrachter, sonst dem Zeiger hinterher
pl.skin.head.rotation.set(kopfBasis.x + kopf.pitch, kopfBasis.y - koerperBasis * 0.45 + kopf.yaw, kopfBasis.z);
});
const sicht = () => { viewer.renderPaused = document.hidden || !document.hasFocus(); };
const ro = new ResizeObserver(() => {
const g = skinLiveGroesse();
viewer.setSize(g.w, g.h);
});
ro.observe(document.querySelector('.list-area'));
window.addEventListener('mousemove', maus);
document.documentElement.addEventListener('mouseleave', weg);
document.addEventListener('visibilitychange', sicht);
window.addEventListener('blur', sicht);
window.addEventListener('focus', sicht);
sicht();
skinLive = {
viewer,
abmelden: () => {
ro.disconnect();
window.removeEventListener('mousemove', maus);
document.documentElement.removeEventListener('mouseleave', weg);
document.removeEventListener('visibilitychange', sicht);
window.removeEventListener('blur', sicht);
window.removeEventListener('focus', sicht);
},
};
canvas.classList.remove('hidden');
document.querySelector('.list-area').classList.add('skin-lebt');
return true;
}
// ---------- 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);
} },
kopf: { label: 'Büste', fov: 28, zoom: 2.15, apply: (p) => {
p.rotation.set(0.08, -0.5, 0);
p.position.y = -9.5; // Kopf in die Bildmitte
p.skin.head.rotation.set(0.05, 0.22, 0); // Blick leicht zum Betrachter
p.skin.leftArm.rotation.set(0, 0, 0.06);
p.skin.rightArm.rotation.set(0, 0, -0.06);
p.skin.leftLeg.rotation.set(0, 0, 0);
p.skin.rightLeg.rotation.set(0, 0, 0);
} },
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)
*/
let renderWarteschlange = Promise.resolve();
function renderSkin3D(quelle, poseKey, opts = {}) {
// nacheinander: alle Aufrufe zeichnen auf dieselbe Leinwand (#skin-canvas)
const auftrag = renderWarteschlange.then(() => renderSkin3DJetzt(quelle, poseKey, opts));
renderWarteschlange = auftrag.catch(() => null);
return auftrag;
}
async function renderSkin3DJetzt(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);
if (opts.umhang) { try { await viewer.loadCape(opts.umhang); } catch { /* ohne Umhang */ } }
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: '', // Zahnrad (8 Zähne)
play: '',
blocks: '',
globe: '',
image: '',
edit: '',
copy: '',
export: '',
trash: '',
chart: '',
search: '',
};
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: ${gruppenAnzeige(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;
const taste = schnellstartTasteVon(inst.id);
if (taste) tip += `\nSchnellstart: Strg+${taste}`;
if (inst.notes && inst.notes.trim()) tip += '\n\n' + inst.notes.trim();
card.title = tip;
card.innerHTML = `
${iconHtml}
${escapeHtml(inst.name)}
${taste ? `Strg+${taste}` : ''}`;
// 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;
}
// Suchfeld in der Werkzeugleiste: standardmäßig nur eine Lupe
function sucheOeffnen() {
el('search-wrap').classList.add('offen');
el('search').focus();
el('search').select();
}
function sucheSchliessen() {
el('search-wrap').classList.remove('offen');
if (document.activeElement === el('search')) el('search').blur();
}
let instanzenEingeblendet = false;
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 (und Untergruppen, "Modpacks/Survival") bündeln
const baum = baueGruppenbaum(filtered);
const showHeaders = baum.kinder.size > 0;
for (const kind of sortierteKinder(baum)) grid.appendChild(baueGruppenSektion(kind, 0, showHeaders));
// Instanzen ohne Gruppe zuletzt
if (baum.instanzen.length) {
grid.appendChild(baueGruppenSektion({ name: 'Ohne Gruppe', pfad: '', kinder: new Map(), instanzen: baum.instanzen }, 0, showHeaders));
}
// Beim ersten Anzeigen kurz nacheinander einblenden – nicht nach der
// Startanimation (da ist der Fensteraufbau schon der Auftritt)
if (!instanzenEingeblendet && instances.length) {
instanzenEingeblendet = true;
if (bewegungErlaubt() && !MIT_STARTANIMATION && !POPOUT) {
grid.querySelectorAll('.group-header, .card').forEach((k, i) => k.style.setProperty('--i', String(Math.min(i, 24))));
grid.classList.add('einblenden');
setTimeout(() => grid.classList.remove('einblenden'), 1400);
}
}
status(`${instances.length} Instanz${instances.length === 1 ? '' : 'en'}`);
}
// ---------- Gruppen und Untergruppen ----------
// Eine Untergruppe steckt im Gruppennamen, getrennt mit "/": "Modpacks/Survival".
// Ohne "/" bleibt alles wie bisher (eine Ebene).
function gruppenPfad(g) {
return String(g || '').split(/[\/\\]/).map((t) => t.trim()).filter(Boolean).join('/');
}
function gruppenAnzeige(g) {
return gruppenPfad(g).split('/').join(' › ');
}
function baueGruppenbaum(liste) {
const wurzel = { name: '', pfad: '', kinder: new Map(), instanzen: [] };
for (const inst of liste) {
let knoten = wurzel;
const pfad = gruppenPfad(inst.group);
if (pfad) {
for (const teil of pfad.split('/')) {
if (!knoten.kinder.has(teil)) {
knoten.kinder.set(teil, { name: teil, pfad: knoten.pfad ? knoten.pfad + '/' + teil : teil, kinder: new Map(), instanzen: [] });
}
knoten = knoten.kinder.get(teil);
}
}
knoten.instanzen.push(inst);
}
return wurzel;
}
const anzahlImZweig = (k) => k.instanzen.length + [...k.kinder.values()].reduce((s, c) => s + anzahlImZweig(c), 0);
const sortierteKinder = (k) => [...k.kinder.values()].sort((a, b) => a.name.localeCompare(b.name, 'de', { numeric: true }));
// Abschnitt für eine Gruppe samt Untergruppen (rekursiv)
function baueGruppenSektion(knoten, tiefe, showHeaders) {
const g = knoten.pfad;
const zu = collapsedGroups.has(g);
const section = document.createElement('div');
section.className = 'group-section' + (tiefe ? ' group-sub' : '') + (zu ? ' collapsed' : '');
if (showHeaders) {
const header = document.createElement('div');
header.className = 'group-header' + (zu ? ' collapsed' : '');
header.title = g ? gruppenAnzeige(g) : 'Instanzen ohne Gruppe';
header.innerHTML = `${zu ? '▸' : '▾'} ${escapeHtml(knoten.name)} (${anzahlImZweig(knoten)})`;
header.addEventListener('click', () => {
if (collapsedGroups.has(g)) collapsedGroups.delete(g); else collapsedGroups.add(g);
render();
});
section.appendChild(header);
}
const inhalt = document.createElement('div');
inhalt.className = 'group-body';
if (knoten.instanzen.length) {
const gridEl = document.createElement('div');
gridEl.className = 'group-grid';
for (const inst of knoten.instanzen) gridEl.appendChild(buildCard(inst));
inhalt.appendChild(gridEl);
}
for (const kind of sortierteKinder(knoten)) inhalt.appendChild(baueGruppenSektion(kind, tiefe + 1, showHeaders));
section.appendChild(inhalt);
// Drop-Ziel: Instanz per Drag&Drop in diese (Unter-)Gruppe verschieben –
// stopPropagation, damit die innerste Gruppe gewinnt, nicht die äußere
section.addEventListener('dragover', (e) => { e.preventDefault(); e.stopPropagation(); 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();
e.stopPropagation();
section.classList.remove('drop-hover');
const dropId = e.dataTransfer.getData('text/plain');
const dragged = instances.find((i) => i.id === dropId);
if (dragged && gruppenPfad(dragged.group) !== g) {
await window.api.updateInstance(dropId, { group: g });
await reload();
toast('Verschoben nach: ' + (g ? gruppenAnzeige(g) : 'Ohne Gruppe'));
}
});
return section;
}
function fillGroupDatalist() {
// alle Gruppen samt ihrer übergeordneten Ebenen vorschlagen
const alle = new Set();
for (const i of instances) {
const teile = gruppenPfad(i.group).split('/').filter(Boolean);
for (let n = 1; n <= teile.length; n++) alle.add(teile.slice(0, n).join('/'));
}
const groups = [...alle].sort((a, b) => a.localeCompare(b, 'de', { numeric: true }));
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 = '';
el('f-perf').checked = false;
aktualisierePerformanceZeile();
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 || '';
aktualisierePerformanceZeile();
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);
}
// Performance-Paket nur beim Anlegen und nur für Loader, für die es die Mods gibt
const PERFORMANCE_LOADER = ['fabric', 'quilt', 'neoforge'];
function aktualisierePerformanceZeile() {
el('f-perf-row').classList.toggle('hidden', !!editingId || !PERFORMANCE_LOADER.includes(el('f-loader').value));
}
async function saveInstance() {
const name = el('f-name').value.trim();
if (!name) { toast('Bitte einen Namen angeben.', true); el('f-name').focus(); return; }
const payload = {
name,
group: gruppenPfad(el('f-group').value),
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;
// läuft im Hauptprozess weiter, auch wenn dieses Fenster gleich zugeht –
// das Ergebnis meldet das Hauptfenster
if (el('f-perf').checked && PERFORMANCE_LOADER.includes(payload.minecraft.loader)) {
window.api.installPerformancePack(targetId).catch(() => {});
}
}
// eigenes Bild anwenden bzw. entfernen
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(res.modUpdates ? `Minecraft gestartet – ${res.modUpdates} Mod${res.modUpdates === 1 ? '' : 's'} vorher aktualisiert.` : '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, umhang) {
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);
if (umhang) { try { await viewer.loadCape(umhang); } catch { /* ohne Umhang weiter */ } }
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 ladeUmhangInfo();
await zeigeSkinFigur('skin-aktuell', 'skin-aktuell-leer', skinTexturAktuell,
'Standard-Skin (keine eigene Datei hinterlegt)', skinUmhang);
}
// ---------- Umhänge im Skin-Fenster ----------
let skinUmhang = null; // data:-Adresse des angezeigten Umhangs (AeroMC vor offiziell)
async function ladeUmhangInfo() {
const sel = el('skin-cape-offiziell');
const info = await window.api.capeInfo();
sel.innerHTML = '';
if (!info.ok) {
el('skin-cape-aero').textContent = info.message || '–';
el('skin-cape-remove').classList.add('hidden');
skinUmhang = null;
return;
}
for (const c of info.offiziell) {
const o = document.createElement('option');
o.value = c.id;
o.textContent = c.name;
if (c.aktiv) o.selected = true;
sel.appendChild(o);
}
sel.disabled = !info.offiziell.length;
sel.title = info.offiziell.length ? 'Nur Umhänge, die dein Konto bei Mojang besitzt' : 'Dein Konto besitzt keinen offiziellen Umhang';
if (info.aeromc) {
el('skin-cape-aero').textContent = `Hochgeladen (${info.aeromc.width}×${info.aeromc.height})`;
el('skin-cape-remove').classList.remove('hidden');
el('skin-cape-edit').textContent = 'Bearbeiten…';
} else {
el('skin-cape-aero').textContent = info.aeromcFehlt ? 'Server noch nicht bereit' : info.aeromcFehler ? 'Umhang-Server nicht erreichbar' : 'Noch keiner';
el('skin-cape-aero').title = info.aeromcFehler || '';
el('skin-cape-remove').classList.add('hidden');
el('skin-cape-edit').textContent = 'Gestalten…';
}
const konto = await window.api.authStatus();
const r = konto && konto.profile ? await window.api.capeForUuid(konto.profile.id) : null;
skinUmhang = r ? r.dataUri : null;
}
async function waehleOffiziellenUmhang() {
const sel = el('skin-cape-offiziell');
sel.disabled = true;
el('skin-cape-status').textContent = 'Umhang wird gesetzt …';
const res = await window.api.capeSetOfficial(sel.value || null);
sel.disabled = false;
if (res.ok) {
el('skin-cape-status').textContent = sel.value ? '✔ Offizieller Umhang gesetzt.' : '✔ Kein offizieller Umhang mehr.';
await ladeUmhangInfo();
await zeigeSkinFigur('skin-aktuell', 'skin-aktuell-leer', skinTexturAktuell, 'Standard-Skin (keine eigene Datei hinterlegt)', skinUmhang);
} else {
el('skin-cape-status').textContent = '✖ ' + (res.message || 'Fehlgeschlagen.');
}
}
function entferneAeroUmhang() {
askConfirm('AeroMC-Umhang entfernen?', 'Dein eigener Umhang wird vom AeroMC-Umhang-Server gelöscht. Hast du ihn nicht als PNG gespeichert, ist er danach weg.', 'Entfernen', async () => {
const res = await window.api.capeRemove();
el('skin-cape-status').textContent = res.ok ? '✔ AeroMC-Umhang entfernt.' : '✖ ' + (res.message || 'Fehlgeschlagen.');
if (res.ok) {
await ladeUmhangInfo();
await zeigeSkinFigur('skin-aktuell', 'skin-aktuell-leer', skinTexturAktuell, 'Standard-Skin (keine eigene Datei hinterlegt)', skinUmhang);
}
});
}
// Armform gewechselt -> beide Figuren neu zeichnen
async function skinVarianteGewechselt() {
if (skinTexturAktuell) await zeigeSkinFigur('skin-aktuell', 'skin-aktuell-leer', skinTexturAktuell, '', skinUmhang);
if (skinTexturNeu) await zeigeSkinFigur('skin-neu', 'skin-neu-leer', skinTexturNeu, '', skinUmhang);
}
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', skinUmhang);
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;
let aktuellerCrashId = null;
let aktuellerCrashSeit = 0;
const CRASH_AKTION_LABEL = {
'mod-installieren': (a) => `„${a.name}“ installieren`,
'mod-deaktivieren': (a) => `„${a.name}“ abschalten`,
ram: (a) => `RAM auf ${Math.round(a.mb / 102.4) / 10} GB stellen`,
'java-auto': () => 'Passendes Java wählen',
'mods-ordner': () => 'Mods-Ordner öffnen',
};
// Funde des Absturz-Assistenten als Karten mit Lösungs-Knopf
function zeigeAbsturzHilfe(id, funde) {
const box = el('crash-hilfe');
box.innerHTML = '';
if (!funde.length) {
box.innerHTML = 'AeroMC konnte die Ursache nicht eindeutig erkennen. Die Angaben unten helfen beim Suchen – oder beim Nachfragen, z. B. im Forum des betroffenen Mods.
';
return;
}
const titel = document.createElement('div');
titel.className = 'crash-hilfe-titel';
titel.textContent = funde.length === 1 ? 'Das ist wahrscheinlich passiert' : `Das ist wahrscheinlich passiert (${funde.length} Punkte)`;
box.appendChild(titel);
for (const f of funde) {
const karte = document.createElement('div');
karte.className = 'crash-fund';
karte.innerHTML = `${escapeHtml(f.titel)}${escapeHtml(f.text)}
`;
const label = f.aktion && CRASH_AKTION_LABEL[f.aktion.typ];
if (label) {
const knopf = document.createElement('button');
knopf.className = 'btn btn-mini btn-primary';
knopf.textContent = label(f.aktion);
knopf.addEventListener('click', async () => {
knopf.disabled = true;
const vorher = knopf.textContent;
knopf.textContent = 'Einen Moment …';
const r = await window.api.crashFix(id, f.aktion).catch((err) => ({ ok: false, message: err.message }));
if (r.ok) {
knopf.textContent = '✓ Erledigt';
karte.classList.add('erledigt');
toast(r.text || 'Erledigt.');
if (f.aktion.typ !== 'mods-ordner') el('crash-retry').classList.remove('hidden');
else knopf.disabled = false;
} else {
knopf.textContent = vorher;
knopf.disabled = false;
toast(r.message || 'Das hat nicht geklappt.', true);
}
});
karte.appendChild(knopf);
}
box.appendChild(karte);
}
}
async function zeigeAbsturzbericht(instanzId, seit) {
const id = instanzId || currentDetailId;
if (!id) return;
const startMs = seit !== undefined ? seit : letzterStartMs;
if (POPOUT !== 'crash') { window.api.popoutOpen('crash', { instanceId: id, seit: startMs }); return; }
// Minecraft schreibt den Bericht erst kurz nach dem Ende
await new Promise((r) => setTimeout(r, 900));
// Absturz-Assistent: Bericht + Spielprotokoll + Konsolenausgabe auswerten
let analyse;
try { analyse = await window.api.crashAnalyze(id, startMs); }
catch { return; }
if (!analyse || !analyse.ok) return;
const res = analyse.bericht;
// weder Bericht noch erkannte Ursache -> die Konsole muss reichen
if (!res && !analyse.funde.length) return;
aktuellerCrash = res || { text: '' };
aktuellerCrashId = id;
aktuellerCrashSeit = startMs;
el('crash-send').disabled = false;
el('crash-send').textContent = 'An M_Viper senden';
const inst = await window.api.getInstance(id);
el('crash-sub').textContent = res
? `${inst ? inst.name + ' · ' : ''}${res.datei} · ` + new Date(res.zeitpunkt).toLocaleString('de-DE')
: `${inst ? inst.name + ' · ' : ''}ausgewertet aus dem Spielprotokoll`;
zeigeAbsturzHilfe(id, analyse.funde);
el('crash-retry').classList.add('hidden');
el('crash-details').classList.toggle('hidden', !res);
el('crash-kern').classList.toggle('hidden', !res);
el('crash-copy').classList.toggle('hidden', !res);
if (!res) {
el('crash-kern').innerHTML = '';
el('crash-voll').textContent = '';
show('modal-crash');
return;
}
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', 'mod-updates': 'Mod-Updates' };
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