5608 lines
232 KiB
JavaScript
5608 lines
232 KiB
JavaScript
'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 += `<rect x="${offsetX + (x * scale)}" y="${offsetY + (y * scale)}" width="${scale}" height="${scale}" fill="${col}"/>`;
|
||
}
|
||
});
|
||
return rects;
|
||
}
|
||
|
||
function pixelIconSvg(key, size) {
|
||
const rects = pixelIconRects(key);
|
||
return `<svg viewBox="0 0 16 16" width="${size}" height="${size}">${rects}</svg>`;
|
||
}
|
||
|
||
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 `
|
||
<path d="M32 18 44 24 32 30 20 24Z"/>
|
||
<path d="M20 24v14l12 7 12-7V24"/>
|
||
<path d="M32 30v15"/>
|
||
`;
|
||
case 'loop':
|
||
return `
|
||
<path d="M22 33c0-4 3-7 7-7 5 0 7 6 11 6 3 0 6-3 6-7s-3-7-7-7c-5 0-7 6-11 6-3 0-6-3-6-7s3-7 7-7"/>
|
||
`;
|
||
case 'crate':
|
||
return `
|
||
<rect x="19" y="20" width="26" height="24" rx="5"/>
|
||
<path d="M19 28h26M32 20v24M24 24h16M24 40h16"/>
|
||
`;
|
||
case 'grid':
|
||
return `
|
||
<rect x="18" y="18" width="28" height="28" rx="7"/>
|
||
<path d="M27 18v28M37 18v28M18 27h28M18 37h28"/>
|
||
`;
|
||
case 'forge':
|
||
return `
|
||
<rect x="18" y="18" width="28" height="28" rx="7"/>
|
||
<path d="M25 27h14v9H25zM24 40h16"/>
|
||
`;
|
||
case 'books':
|
||
return `
|
||
<rect x="18" y="17" width="9" height="30" rx="3"/>
|
||
<rect x="28" y="15" width="8" height="32" rx="3"/>
|
||
<rect x="37" y="19" width="9" height="28" rx="3"/>
|
||
`;
|
||
case 'gem':
|
||
return `
|
||
<path d="M32 17 45 29 32 46 19 29Z"/>
|
||
<path d="M24 22h16M20 29h24M32 17v29"/>
|
||
`;
|
||
case 'ingot':
|
||
return `
|
||
<path d="M22 26h20l5 12H17Z"/>
|
||
<path d="M26 21h12l5 5H21Z"/>
|
||
`;
|
||
case 'spark':
|
||
return `
|
||
<path d="M32 17 35 26 45 28 37 34 39 44 32 39 25 44 27 34 19 28 29 26Z"/>
|
||
`;
|
||
case 'coal':
|
||
return `
|
||
<path d="M24 19h16l7 11-7 15H24l-7-15Z"/>
|
||
<path d="M24 19 32 30 40 19"/>
|
||
`;
|
||
case 'sword':
|
||
return `
|
||
<path d="M37 18 46 27 35 38 26 29Z"/>
|
||
<path d="M28 31 18 41M24 45l-4 4M27 42l-7 2"/>
|
||
`;
|
||
case 'pickaxe':
|
||
return `
|
||
<path d="M19 26c7-7 16-9 26-6"/>
|
||
<path d="M33 20 22 45"/>
|
||
`;
|
||
case 'axe':
|
||
return `
|
||
<path d="M25 19c8 0 15 5 17 13-8 2-14-1-18-8"/>
|
||
<path d="M31 19 22 46"/>
|
||
`;
|
||
case 'bow':
|
||
return `
|
||
<path d="M24 18c9 8 9 20 0 28"/>
|
||
<path d="M39 18v28M27 22 39 32 27 42"/>
|
||
`;
|
||
case 'apple':
|
||
return `
|
||
<path d="M32 23c7 0 13 5 13 12s-6 13-13 13-13-6-13-13 6-12 13-12Z"/>
|
||
<path d="M32 19c0-3 2-5 5-6M36 18c3 0 5 1 7 3"/>
|
||
`;
|
||
case 'book':
|
||
return `
|
||
<path d="M20 19h12c3 0 5 2 5 5v21H24c-2 0-4 1-4 2Z"/>
|
||
<path d="M44 19H32c-3 0-5 2-5 5v21h13c2 0 4 1 4 2Z"/>
|
||
`;
|
||
case 'bookSpark':
|
||
return `
|
||
<path d="M20 19h12c3 0 5 2 5 5v21H24c-2 0-4 1-4 2Z"/>
|
||
<path d="M44 19H32c-3 0-5 2-5 5v21h13c2 0 4 1 4 2Z"/>
|
||
<path d="M45 17 47 22 52 24 47 26 45 31 43 26 38 24 43 22Z"/>
|
||
`;
|
||
case 'map':
|
||
return `
|
||
<path d="M18 20 28 16 38 20 46 17v27l-8 3-10-4-10 4Z"/>
|
||
<path d="M28 16v27M38 20v27"/>
|
||
`;
|
||
case 'star':
|
||
return `
|
||
<path d="M32 17 36 26 46 27 38 34 40 44 32 39 24 44 26 34 18 27 28 26Z"/>
|
||
`;
|
||
case 'eye':
|
||
return `
|
||
<path d="M16 32c5-8 10-12 16-12s11 4 16 12c-5 8-10 12-16 12s-11-4-16-12Z"/>
|
||
<circle cx="32" cy="32" r="5"/>
|
||
`;
|
||
case 'pearl':
|
||
return `
|
||
<circle cx="32" cy="32" r="13"/>
|
||
<path d="M26 27c2-2 4-3 7-3"/>
|
||
`;
|
||
case 'flame':
|
||
return `
|
||
<path d="M34 17c5 8 9 12 9 19 0 7-4 13-11 13s-11-5-11-12c0-7 5-11 10-17 0 4 1 6 4 10 2-6 1-9-1-13Z"/>
|
||
`;
|
||
case 'bottle':
|
||
return `
|
||
<path d="M28 18h8v6l4 6v12c0 5-3 8-8 8s-8-3-8-8V30l4-6Z"/>
|
||
<path d="M25 39h14"/>
|
||
`;
|
||
case 'cake':
|
||
return `
|
||
<path d="M18 30h28v14H18Z"/>
|
||
<path d="M18 30c3-4 7-6 14-6s11 2 14 6M26 24v6M32 23v7M38 24v6"/>
|
||
`;
|
||
case 'bucket':
|
||
return `
|
||
<path d="M22 22h20l-3 22H25Z"/>
|
||
<path d="M26 22c0-4 2-7 6-7s6 3 6 7"/>
|
||
`;
|
||
case 'powder':
|
||
return `
|
||
<circle cx="24" cy="34" r="4"/>
|
||
<circle cx="33" cy="28" r="5"/>
|
||
<circle cx="41" cy="36" r="3"/>
|
||
`;
|
||
case 'creeper':
|
||
return `
|
||
<rect x="21" y="18" width="22" height="28" rx="6"/>
|
||
<rect x="25" y="24" width="4" height="5" rx="1" fill="currentColor" stroke="none"/>
|
||
<rect x="35" y="24" width="4" height="5" rx="1" fill="currentColor" stroke="none"/>
|
||
<path d="M26 40h12l-2-7h-8Z" fill="currentColor" stroke="none"/>
|
||
`;
|
||
case 'chest':
|
||
return `
|
||
<rect x="18" y="22" width="28" height="22" rx="5"/>
|
||
<path d="M18 29h28M32 29v8"/>
|
||
`;
|
||
case 'potion':
|
||
return `
|
||
<path d="M28 17h8v5l4 6v13c0 6-3 9-8 9s-8-3-8-9V28l4-6Z"/>
|
||
<path d="M25 39h14"/>
|
||
`;
|
||
case 'letter':
|
||
return `<text x="32" y="40" text-anchor="middle" font-size="22" font-weight="700" font-family="Segoe UI, sans-serif" fill="currentColor" stroke="none">${escapeHtml((key || '?').slice(0, 1).toUpperCase())}</text>`;
|
||
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 `
|
||
<svg viewBox="0 0 64 64" width="${size}" height="${size}" aria-hidden="true">
|
||
<defs>
|
||
<clipPath id="${gid}-clip">
|
||
<rect x="8" y="8" width="48" height="48" rx="12"/>
|
||
</clipPath>
|
||
<filter id="${gid}-shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||
<feDropShadow dx="0" dy="2" stdDeviation="2" flood-color="rgba(0,0,0,.28)"/>
|
||
</filter>
|
||
</defs>
|
||
<g filter="url(#${gid}-shadow)">
|
||
<g clip-path="url(#${gid}-clip)">${pixelIconRects('grass', 3, 8, 8)}</g>
|
||
<rect x="8" y="8" width="48" height="48" rx="12" fill="rgba(18,28,16,.10)"/>
|
||
<rect x="8" y="8" width="48" height="48" rx="12" fill="none" stroke="rgba(255,255,255,.18)" stroke-width="1.5"/>
|
||
</g>
|
||
<g fill="none" stroke="${theme.glow}" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round">
|
||
${hdIconGlyph(key, theme)}
|
||
</g>
|
||
</svg>`;
|
||
}
|
||
|
||
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)
|
||
? `<rect x="8" y="8" width="48" height="48" rx="15" fill="${theme.bg}" opacity=".16"/>`
|
||
: `<circle cx="32" cy="32" r="22" fill="${theme.bg}" opacity=".14"/>`;
|
||
return `
|
||
<svg class="mc-item-icon" viewBox="0 0 64 64" width="${size}" height="${size}" aria-hidden="true">
|
||
${plate}
|
||
<image href="${src}" x="10" y="10" width="44" height="44" preserveAspectRatio="xMidYMid meet"/>
|
||
</svg>`;
|
||
}
|
||
|
||
// erzeugt die Würfel-Darstellungen einmalig im Voraus
|
||
async function buildIsoCubes() {
|
||
const jobs = Object.values(mcIcons)
|
||
.filter((i) => BLOCK_KEYS.has(i.key))
|
||
.map((i) => new Promise((resolve) => {
|
||
const img = new Image();
|
||
img.onload = () => { isoSources[i.key] = img; resolve(); };
|
||
img.onerror = () => resolve();
|
||
img.src = i.dataUri;
|
||
}));
|
||
await Promise.all(jobs);
|
||
for (const key of Object.keys(isoSources)) renderIsoCube(key);
|
||
}
|
||
|
||
// Block-Texturen, die als räumlicher Würfel dargestellt werden
|
||
const BLOCK_KEYS = new Set(['grass', 'dirt', 'stone', 'planks', 'tnt', 'obsidian', 'netherrack',
|
||
'crafting', 'furnace', 'bookshelf', 'diamondblock', 'goldblock', 'emeraldblock', 'redstoneblock']);
|
||
let iso3dEnabled = true;
|
||
const isoCache = {}; // key -> fertiges Würfelbild
|
||
const isoSources = {}; // key -> geladenes Textur-Bild
|
||
|
||
// zeichnet aus einer 16x16-Textur einen isometrischen Würfel (Canvas -> data:-URI)
|
||
function renderIsoCube(key) {
|
||
const img = isoSources[key];
|
||
if (!img) return null;
|
||
|
||
const S = 96, c = document.createElement('canvas');
|
||
c.width = S; c.height = S;
|
||
const g = c.getContext('2d');
|
||
g.imageSmoothingEnabled = true;
|
||
if ('imageSmoothingQuality' in g) g.imageSmoothingQuality = 'high';
|
||
const w = 26, h = 15, cx = S / 2, top = 8;
|
||
|
||
const face = (m, bright) => {
|
||
g.save();
|
||
g.setTransform(m[0], m[1], m[2], m[3], m[4], m[5]);
|
||
g.drawImage(img, 0, 0, 16, 16);
|
||
g.restore();
|
||
if (bright !== 1) { // Fläche abdunkeln/aufhellen
|
||
g.save();
|
||
g.setTransform(m[0], m[1], m[2], m[3], m[4], m[5]);
|
||
g.globalCompositeOperation = bright < 1 ? 'source-atop' : 'lighter';
|
||
g.fillStyle = bright < 1 ? `rgba(0,0,0,${(1 - bright).toFixed(2)})` : 'rgba(255,255,255,.12)';
|
||
g.fillRect(0, 0, 16, 16);
|
||
g.restore();
|
||
}
|
||
};
|
||
// Oberseite (Raute), linke und rechte Seitenfläche
|
||
face([w / 16, h / 16, -w / 16, h / 16, cx, top], 1);
|
||
face([w / 16, h / 16, 0, 30 / 16, cx - w, top + h], 0.72);
|
||
face([w / 16, -h / 16, 0, 30 / 16, cx, top + h * 2], 0.55);
|
||
|
||
const out = c.toDataURL('image/png');
|
||
isoCache[key] = out;
|
||
return out;
|
||
}
|
||
|
||
// liefert das Icon-Markup als skalierbares HD-SVG; eigene Bilder der Instanz bleiben separat erhalten
|
||
function iconOf(key, size = 44) {
|
||
const real = realItemIconSvg(key, size);
|
||
if (real) return real;
|
||
if (PIX[key]) return pixelIconSvg(key, size);
|
||
return hdIconSvg(key, size);
|
||
}
|
||
|
||
const LOADER_LABEL = {
|
||
vanilla: 'Vanilla', fabric: 'Fabric', forge: 'Forge',
|
||
neoforge: 'NeoForge', quilt: 'Quilt', optifine: 'OptiFine',
|
||
};
|
||
|
||
// Bewährte Garbage-Collector-Flags (bekannt als "Aikar's Flags"), reduzieren
|
||
// bei größeren Modpacks spürbar GC-bedingte Ruckler. Wirkt erst ab genug RAM
|
||
// sinnvoll, schadet aber auch bei wenig RAM nicht.
|
||
const AIKAR_FLAGS = '-XX:+UseG1GC -XX:+ParallelRefProcEnabled -XX:MaxGCPauseMillis=200 '
|
||
+ '-XX:+UnlockExperimentalVMOptions -XX:+DisableExplicitGC -XX:+AlwaysPreTouch '
|
||
+ '-XX:G1NewSizePercent=30 -XX:G1MaxNewSizePercent=40 -XX:G1HeapRegionSize=8M '
|
||
+ '-XX:G1ReservePercent=20 -XX:G1HeapWastePercent=5 -XX:G1MixedGCCountTarget=4 '
|
||
+ '-XX:InitiatingHeapOccupancyPercent=15 -XX:G1MixedGCLiveThresholdPercent=90 '
|
||
+ '-XX:G1RSetUpdatingPauseTimePercent=5 -XX:SurvivorRatio=32 -XX:+PerfDisableSharedMem '
|
||
+ '-XX:MaxTenuringThreshold=1';
|
||
|
||
// ---------- Zustand ----------
|
||
let instances = [];
|
||
let currentDetailId = null;
|
||
let selectedInstanceIds = new Set();
|
||
let editingId = null;
|
||
let confirmAction = null;
|
||
let mcVersionData = null; // gecachte Mojang-Versionsliste
|
||
let currentModsInstalled = new Set(); // projectIds im offenen Mod-Dialog
|
||
let pendingImagePath = null; // im Dialog gewähltes eigenes Bild (noch nicht gespeichert)
|
||
let pendingClearImage = false; // eigenes Bild soll entfernt werden
|
||
let loginVerificationUri = null; // aktueller Device-Code-Link
|
||
const collapsedGroups = new Set(); // eingeklappte Gruppen
|
||
let consoleBuffer = ''; // gesammelte Log-Ausgabe (roh, für Kopieren)
|
||
let consolePending = ''; // noch nicht abgeschlossene Zeile
|
||
|
||
// ---------- Popup-Fenster ----------
|
||
// In einem eigenen Popup-Fenster (aktuell nur Einstellungen) steht in der URL
|
||
// "?popout=settings" – dann blendet die Oberfläche die Haupt-UI aus und zeigt
|
||
// nur dieses eine Modal vollflächig (siehe styles.css ".is-popout"/".popout-host").
|
||
const POPOUT = new URLSearchParams(location.search).get('popout');
|
||
|
||
// ---------- DOM-Helfer ----------
|
||
const el = (id) => document.getElementById(id);
|
||
function show(id) { el(id).classList.remove('hidden'); }
|
||
function hide(id) { el(id).classList.add('hidden'); }
|
||
|
||
function toast(msg, isError = false) {
|
||
const t = el('toast');
|
||
t.textContent = msg;
|
||
t.classList.toggle('err', isError);
|
||
t.classList.remove('hidden');
|
||
clearTimeout(toast._t);
|
||
toast._t = setTimeout(() => t.classList.add('hidden'), 2800);
|
||
}
|
||
|
||
function fmtDate(iso) {
|
||
if (!iso) return '–';
|
||
try { return new Date(iso).toLocaleString('de-DE'); } catch { return iso; }
|
||
}
|
||
function status(text) { el('status-left').textContent = text; }
|
||
|
||
function fmtDuration(s) {
|
||
s = s || 0;
|
||
if (s < 60) return s + ' Sek.';
|
||
const h = Math.floor(s / 3600);
|
||
const m = Math.floor((s % 3600) / 60);
|
||
return h ? `${h} Std. ${m} Min.` : `${m} Min.`;
|
||
}
|
||
|
||
function escapeHtml(s) {
|
||
return String(s).replace(/[&<>"']/g, (c) => (
|
||
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]
|
||
));
|
||
}
|
||
function numOrNull(v) { const n = parseInt(v, 10); return Number.isFinite(n) ? n : null; }
|
||
|
||
function applyTheme(theme) {
|
||
document.documentElement.setAttribute('data-theme', theme === 'light' ? 'light' : 'dark');
|
||
}
|
||
|
||
// ---------- Instanzen aus MultiMC übernehmen ----------
|
||
let mmcFound = [];
|
||
let cfFound = [];
|
||
let mcFound = [];
|
||
let technicFound = [];
|
||
let importKind = 'zip';
|
||
|
||
async function ensureDefaultMcScan() {
|
||
if (importKind !== 'mc') return;
|
||
if (el('mc-path').value.trim()) return;
|
||
if (mcFound.length) return;
|
||
await scanMc();
|
||
}
|
||
|
||
function setImportKind(kind) {
|
||
importKind = ['zip', 'modpack', 'mc', 'cf', 'mmc', 'technic'].includes(kind) ? kind : 'zip';
|
||
['zip', 'modpack', 'mc', 'cf', 'mmc', 'technic'].forEach((entryKind) => {
|
||
el(`import-tab-${entryKind}`).classList.toggle('on', entryKind === importKind);
|
||
el(`import-pane-${entryKind}`).classList.toggle('hidden', entryKind !== importKind);
|
||
});
|
||
el('import-zip-do').classList.toggle('hidden', importKind !== 'zip');
|
||
el('import-modpack-do').classList.toggle('hidden', importKind !== 'modpack');
|
||
el('mc-do').classList.toggle('hidden', importKind !== 'mc');
|
||
el('cf-do').classList.toggle('hidden', importKind !== 'cf');
|
||
el('mmc-do').classList.toggle('hidden', importKind !== 'mmc');
|
||
el('technic-do').classList.toggle('hidden', importKind !== 'technic');
|
||
el('mc-progress').classList.toggle('hidden', importKind !== 'mc' || !el('mc-progress').textContent);
|
||
el('cf-progress').classList.toggle('hidden', importKind !== 'cf' || !el('cf-progress').textContent);
|
||
el('mmc-progress').classList.toggle('hidden', importKind !== 'mmc' || !el('mmc-progress').textContent);
|
||
el('technic-progress').classList.toggle('hidden', importKind !== 'technic' || !el('technic-progress').textContent);
|
||
}
|
||
|
||
function openImportDialog(kind = 'zip') {
|
||
if (POPOUT !== 'import') { window.api.popoutOpen('import', { kind }); return; }
|
||
mcFound = [];
|
||
el('mc-result').classList.add('hidden');
|
||
el('mc-do').disabled = true;
|
||
el('mc-progress').textContent = '';
|
||
el('mc-progress').classList.add('hidden');
|
||
el('mc-status').textContent = 'Leer lassen nutzt automatisch den Standardpfad unter Windows.';
|
||
el('cf-result').classList.add('hidden');
|
||
el('cf-do').disabled = true;
|
||
el('cf-progress').textContent = '';
|
||
el('cf-progress').classList.add('hidden');
|
||
el('cf-status').textContent = 'Standardpfad unter Windows: C:\\Users\\DeinName\\curseforge\\minecraft\\Instances';
|
||
el('mmc-result').classList.add('hidden');
|
||
el('mmc-do').disabled = true;
|
||
el('mmc-progress').textContent = '';
|
||
el('mmc-progress').classList.add('hidden');
|
||
el('mmc-status').textContent = 'Gib den Ordner an, in dem MultiMC.exe oder PrismLauncher.exe liegt.';
|
||
setImportKind(kind);
|
||
show('modal-import');
|
||
if (importKind === 'mc') {
|
||
el('mc-path').focus();
|
||
ensureDefaultMcScan();
|
||
}
|
||
if (importKind === 'cf') el('cf-path').focus();
|
||
if (importKind === 'mmc') el('mmc-path').focus();
|
||
}
|
||
|
||
async function runZipImport() {
|
||
el('import-zip-do').disabled = true;
|
||
try {
|
||
const res = await window.api.importInstance();
|
||
if (res.canceled) return;
|
||
if (res.ok) {
|
||
await reload();
|
||
hide('modal-import');
|
||
toast('Importiert: ' + res.instance.name);
|
||
return;
|
||
}
|
||
toast(res.message || 'Import fehlgeschlagen.', true);
|
||
} finally {
|
||
el('import-zip-do').disabled = false;
|
||
}
|
||
}
|
||
|
||
async function runModpackImport() {
|
||
el('import-modpack-do').disabled = true;
|
||
status('Importiere Modpack …');
|
||
try {
|
||
const res = await window.api.importModpack();
|
||
status('Bereit'); el('status-right').textContent = '';
|
||
if (res.canceled) return;
|
||
if (res.ok) {
|
||
await reload();
|
||
hide('modal-import');
|
||
toast(`Modpack importiert: ${res.instance.name} (${res.fileCount} Dateien)`);
|
||
// CurseForge gibt nicht jede Datei heraus – ehrlich benennen statt verschweigen
|
||
if (res.fehlend) toast(`${res.fehlend} Datei(en) konnten nicht geladen werden und fehlen in der Instanz.`, true);
|
||
return;
|
||
}
|
||
toast(res.message || 'Modpack-Import fehlgeschlagen.', true);
|
||
if (res.needsKey) { hide('modal-import'); openSettings('pane-network'); }
|
||
} finally {
|
||
status('Bereit'); el('status-right').textContent = '';
|
||
el('import-modpack-do').disabled = false;
|
||
}
|
||
}
|
||
|
||
function fmtDownloads(n) {
|
||
n = Number(n) || 0;
|
||
if (n >= 1e6) return (n / 1e6).toFixed(1).replace(/\.0$/, '') + 'M';
|
||
if (n >= 1e3) return (n / 1e3).toFixed(1).replace(/\.0$/, '') + 'k';
|
||
return String(n);
|
||
}
|
||
|
||
async function searchModpacks() {
|
||
const query = el('mp-query').value.trim();
|
||
const gameVersion = el('mp-mc').value.trim();
|
||
const sort = el('mp-sort').value || 'relevance';
|
||
const source = (el('mp-source') && el('mp-source').value) || 'modrinth';
|
||
const box = el('mp-results');
|
||
const st = el('mp-status');
|
||
st.textContent = 'Suche …';
|
||
box.innerHTML = '<div class="mods-empty">Suche läuft …</div>';
|
||
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 = '<div class="mods-empty">Suche fehlgeschlagen.</div>';
|
||
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 = '<div class="mods-empty">Keine Modpacks gefunden.</div>';
|
||
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 =
|
||
`<div class="mod-icon">${h.iconUrl ? `<img src="${escapeHtml(h.iconUrl)}" alt="" style="width:32px;height:32px;border-radius:4px;object-fit:cover" />` : '📦'}</div>
|
||
<div class="mod-info">
|
||
<div class="mod-name">${escapeHtml(h.title || h.slug || h.projectId)}</div>
|
||
<div class="bd-meta">${escapeHtml(meta)}${cats ? ' · ' + escapeHtml(cats) : ''}</div>
|
||
<div class="bd-meta" style="opacity:.85">${escapeHtml((h.description || '').slice(0, 120))}${(h.description || '').length > 120 ? '…' : ''}</div>
|
||
</div>
|
||
<div class="mod-action"></div>`;
|
||
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 = '<div class="mods-empty">Suche fehlgeschlagen.</div>';
|
||
}
|
||
}
|
||
|
||
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 = '<div class="mods-empty">Keine Profile gefunden.</div>'; 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 =
|
||
`<input type="checkbox" data-idx="${idx}" checked />
|
||
<div class="mod-info">
|
||
<div class="mmc-name">${escapeHtml(inst.name)}</div>
|
||
<div class="mmc-meta">${escapeHtml(meta)}</div>
|
||
</div>`;
|
||
box.appendChild(row);
|
||
});
|
||
updateMcButton();
|
||
}
|
||
|
||
function selectedMc() {
|
||
return [...document.querySelectorAll('#mc-list input[type=checkbox]')]
|
||
.filter((c) => c.checked)
|
||
.map((c) => mcFound[Number(c.dataset.idx)]);
|
||
}
|
||
|
||
function updateMcButton() {
|
||
const n = selectedMc().length;
|
||
el('mc-do').disabled = n === 0;
|
||
el('mc-do').textContent = n ? `${n} übernehmen` : 'Übernehmen';
|
||
}
|
||
|
||
async function runMcImport() {
|
||
const entries = selectedMc();
|
||
if (!entries.length) return;
|
||
const copy = el('mc-copy').checked;
|
||
el('mc-do').disabled = true;
|
||
el('mc-progress').textContent = 'Übernehme …';
|
||
el('mc-progress').classList.remove('hidden');
|
||
|
||
const res = await window.api.mcImport(entries, copy);
|
||
await reload();
|
||
hide('modal-import');
|
||
const n = res.imported.length;
|
||
const f = res.failed.length;
|
||
toast(`${n} Profil${n === 1 ? '' : 'e'} aus dem Minecraft Launcher übernommen${f ? ` · ${f} fehlgeschlagen` : ''}.`, f > 0);
|
||
if (instances.length) await selectInstance(instances[0].id);
|
||
}
|
||
|
||
async function scanCf() {
|
||
const folder = el('cf-path').value.trim();
|
||
if (!folder) { el('cf-status').textContent = 'Bitte zuerst einen Ordner angeben.'; return; }
|
||
el('cf-status').textContent = 'Suche Instanzen …';
|
||
const res = await window.api.cfScan(folder);
|
||
if (!res.ok) {
|
||
el('cf-status').textContent = '✖ Dort wurde kein CurseForge-Instances-Ordner gefunden.';
|
||
el('cf-result').classList.add('hidden');
|
||
el('cf-do').disabled = true;
|
||
return;
|
||
}
|
||
cfFound = res.instances;
|
||
el('cf-status').textContent = `${res.count} Instanz${res.count === 1 ? '' : 'en'} gefunden.`;
|
||
el('cf-result').classList.remove('hidden');
|
||
el('cf-all').checked = true;
|
||
renderCfList();
|
||
}
|
||
|
||
function renderCfList() {
|
||
const box = el('cf-list');
|
||
box.innerHTML = '';
|
||
if (!cfFound.length) { box.innerHTML = '<div class="mods-empty">Keine Instanzen gefunden.</div>'; 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 =
|
||
`<input type="checkbox" data-idx="${idx}" checked />
|
||
<div class="mod-info">
|
||
<div class="mmc-name">${escapeHtml(inst.name)}</div>
|
||
<div class="mmc-meta">${escapeHtml(meta)}</div>
|
||
</div>`;
|
||
box.appendChild(row);
|
||
});
|
||
updateCfButton();
|
||
}
|
||
|
||
function selectedCf() {
|
||
return [...document.querySelectorAll('#cf-list input[type=checkbox]')]
|
||
.filter((c) => c.checked)
|
||
.map((c) => cfFound[Number(c.dataset.idx)]);
|
||
}
|
||
|
||
function updateCfButton() {
|
||
const n = selectedCf().length;
|
||
el('cf-do').disabled = n === 0;
|
||
el('cf-do').textContent = n ? `${n} übernehmen` : 'Übernehmen';
|
||
}
|
||
|
||
async function runCfImport() {
|
||
const entries = selectedCf();
|
||
if (!entries.length) return;
|
||
const copy = el('cf-copy').checked;
|
||
el('cf-do').disabled = true;
|
||
el('cf-progress').textContent = 'Übernehme …';
|
||
el('cf-progress').classList.remove('hidden');
|
||
|
||
const res = await window.api.cfImport(entries, copy);
|
||
await reload();
|
||
hide('modal-import');
|
||
const n = res.imported.length;
|
||
const f = res.failed.length;
|
||
toast(`${n} CurseForge-Instanz${n === 1 ? '' : 'en'} übernommen${f ? ` · ${f} fehlgeschlagen` : ''}.`, f > 0);
|
||
if (instances.length) await selectInstance(instances[0].id);
|
||
}
|
||
|
||
async function scanMmc() {
|
||
const folder = el('mmc-path').value.trim();
|
||
if (!folder) { el('mmc-status').textContent = 'Bitte zuerst einen Ordner angeben.'; return; }
|
||
el('mmc-status').textContent = 'Suche Instanzen …';
|
||
const res = await window.api.mmcScan(folder);
|
||
if (!res.ok) {
|
||
el('mmc-status').textContent = '✖ Dort wurde kein instances-Ordner gefunden.';
|
||
el('mmc-result').classList.add('hidden');
|
||
el('mmc-do').disabled = true;
|
||
return;
|
||
}
|
||
mmcFound = res.instances;
|
||
el('mmc-status').textContent = `${res.count} Instanz${res.count === 1 ? '' : 'en'} gefunden.`;
|
||
el('mmc-result').classList.remove('hidden');
|
||
el('mmc-all').checked = true;
|
||
renderMmcList();
|
||
}
|
||
|
||
function renderMmcList() {
|
||
const box = el('mmc-list');
|
||
box.innerHTML = '';
|
||
if (!mmcFound.length) { box.innerHTML = '<div class="mods-empty">Keine Instanzen gefunden.</div>'; 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 =
|
||
`<input type="checkbox" data-idx="${idx}" checked />
|
||
<div class="mod-info">
|
||
<div class="mmc-name">${escapeHtml(inst.name)}</div>
|
||
<div class="mmc-meta">${escapeHtml(meta)}</div>
|
||
</div>`;
|
||
box.appendChild(row);
|
||
});
|
||
updateMmcButton();
|
||
}
|
||
|
||
function selectedMmc() {
|
||
return [...document.querySelectorAll('#mmc-list input[type=checkbox]')]
|
||
.filter((c) => c.checked)
|
||
.map((c) => mmcFound[Number(c.dataset.idx)]);
|
||
}
|
||
|
||
function updateMmcButton() {
|
||
const n = selectedMmc().length;
|
||
el('mmc-do').disabled = n === 0;
|
||
el('mmc-do').textContent = n ? `${n} übernehmen` : 'Übernehmen';
|
||
}
|
||
|
||
async function runMmcImport() {
|
||
const entries = selectedMmc();
|
||
if (!entries.length) return;
|
||
const copy = el('mmc-copy').checked;
|
||
el('mmc-do').disabled = true;
|
||
el('mmc-progress').textContent = 'Übernehme …';
|
||
el('mmc-progress').classList.remove('hidden');
|
||
|
||
const res = await window.api.mmcImport(entries, copy);
|
||
await reload();
|
||
hide('modal-import');
|
||
const n = res.imported.length;
|
||
const f = res.failed.length;
|
||
toast(`${n} Instanz${n === 1 ? '' : 'en'} übernommen${f ? ` · ${f} fehlgeschlagen` : ''}.`, f > 0);
|
||
if (instances.length) await selectInstance(instances[0].id);
|
||
}
|
||
|
||
// ---------- Technic-Plattform: Modpack-Katalog ----------
|
||
|
||
async function searchTechnicPlatform() {
|
||
const query = el('tp-query').value.trim();
|
||
const box = el('tp-results');
|
||
const st = el('tp-status');
|
||
st.textContent = 'Suche …';
|
||
box.innerHTML = '<div class="mods-empty">Suche läuft …</div>';
|
||
const res = await window.api.technicPlatformSearch(query);
|
||
if (!res.ok) {
|
||
st.textContent = 'Fehler: ' + (res.message || 'Suche fehlgeschlagen.');
|
||
box.innerHTML = '<div class="mods-empty">Suche fehlgeschlagen.</div>';
|
||
return;
|
||
}
|
||
const hits = res.hits || [];
|
||
st.textContent = hits.length ? `${hits.length} Treffer` : 'Keine Treffer.';
|
||
box.innerHTML = '';
|
||
if (!hits.length) { box.innerHTML = '<div class="mods-empty">Keine Modpacks gefunden.</div>'; return; }
|
||
for (const h of hits) {
|
||
const item = document.createElement('div');
|
||
item.className = 'mod-item';
|
||
item.innerHTML =
|
||
`<div class="mod-icon">${h.iconUrl ? `<img src="${escapeHtml(h.iconUrl)}" alt="" style="width:32px;height:32px;border-radius:4px;object-fit:cover" />` : '📦'}</div>
|
||
<div class="mod-info"><div class="mod-name">${escapeHtml(h.name)}</div></div>
|
||
<div class="mod-action"></div>`;
|
||
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 = '<div class="mods-empty">Keine Modpacks gefunden.</div>'; 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 =
|
||
`<input type="checkbox" data-idx="${idx}" checked />
|
||
<div class="mod-info">
|
||
<div class="mmc-name">${escapeHtml(inst.name)}</div>
|
||
<div class="mmc-meta">${escapeHtml(meta)}</div>
|
||
</div>`;
|
||
box.appendChild(row);
|
||
});
|
||
updateTechnicButton();
|
||
}
|
||
|
||
function selectedTechnic() {
|
||
return [...document.querySelectorAll('#technic-list input[type=checkbox]')]
|
||
.filter((c) => c.checked)
|
||
.map((c) => technicFound[Number(c.dataset.idx)]);
|
||
}
|
||
|
||
function updateTechnicButton() {
|
||
const n = selectedTechnic().length;
|
||
el('technic-do').disabled = n === 0;
|
||
el('technic-do').textContent = n ? `${n} übernehmen` : 'Übernehmen';
|
||
}
|
||
|
||
async function runTechnicImport() {
|
||
const entries = selectedTechnic();
|
||
if (!entries.length) return;
|
||
const copy = el('technic-copy').checked;
|
||
el('technic-do').disabled = true;
|
||
el('technic-progress').textContent = 'Übernehme …';
|
||
el('technic-progress').classList.remove('hidden');
|
||
|
||
const res = await window.api.technicImport(entries, copy);
|
||
await reload();
|
||
hide('modal-import');
|
||
const n = res.imported.length;
|
||
const f = res.failed.length;
|
||
toast(`${n} Technic-Modpack${n === 1 ? '' : 's'} übernommen${f ? ` · ${f} fehlgeschlagen` : ''}.`, f > 0);
|
||
if (instances.length) await selectInstance(instances[0].id);
|
||
}
|
||
|
||
// ---------- Bedrock (Windows-Store-Ausgabe) ----------
|
||
let bedrockKind = 'worlds';
|
||
|
||
// Bedrock-Knopf nur zeigen, wenn aktiviert UND tatsächlich installiert
|
||
async function applyBedrockVisibility() {
|
||
const btn = el('btn-bedrock');
|
||
let show = false;
|
||
try {
|
||
const s = await window.api.getSettings();
|
||
if (s.bedrockEnabled !== false) {
|
||
const info = await window.api.bedrockDetect();
|
||
show = !!info.installed;
|
||
const hint = el('s-bedrock-hint');
|
||
if (hint) {
|
||
hint.textContent = info.installed
|
||
? 'Bedrock erkannt (Version ' + info.version + ').'
|
||
: 'Bedrock ist auf diesem PC nicht installiert – der Bereich bleibt ausgeblendet.';
|
||
}
|
||
}
|
||
} catch { /* im Zweifel ausblenden */ }
|
||
btn.classList.toggle('hidden', !show);
|
||
}
|
||
|
||
async function openBedrock() {
|
||
if (POPOUT !== 'bedrock') { window.api.popoutOpen('bedrock'); return; }
|
||
show('modal-bedrock');
|
||
el('bd-sub').textContent = 'Wird geprüft …';
|
||
const info = await window.api.bedrockDetect();
|
||
if (!info.installed) {
|
||
el('bd-sub').textContent = 'nicht installiert';
|
||
el('bd-missing').classList.remove('hidden');
|
||
el('bd-content').classList.add('hidden');
|
||
return;
|
||
}
|
||
el('bd-missing').classList.add('hidden');
|
||
el('bd-content').classList.remove('hidden');
|
||
el('bd-sub').textContent = 'Version ' + info.version;
|
||
switchBedrockTab('worlds');
|
||
}
|
||
|
||
function switchBedrockTab(kind) {
|
||
bedrockKind = kind;
|
||
document.querySelectorAll('.btab').forEach((t) => t.classList.toggle('on', t.dataset.kind === kind));
|
||
renderBedrockList();
|
||
}
|
||
|
||
function fmtBytes(n) {
|
||
if (!n) return '';
|
||
if (n < 1024) return n + ' B';
|
||
if (n < 1048576) return (n / 1024).toFixed(0) + ' KB';
|
||
return (n / 1048576).toFixed(1) + ' MB';
|
||
}
|
||
|
||
async function renderBedrockList() {
|
||
const box = el('bd-list');
|
||
box.innerHTML = '<div class="mods-empty">Lade …</div>';
|
||
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 = `<div class="mods-empty">${leer[kind] || 'Nichts vorhanden.'}</div>`;
|
||
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 =
|
||
`<div class="mod-icon">${kind === 'worlds' ? '🌍' : kind === 'screenshots' ? '🖼' : '📦'}</div>
|
||
<div class="mod-info">
|
||
<div class="mod-name">${escapeHtml(name)}</div>
|
||
<div class="bd-meta">${escapeHtml(meta)}</div>
|
||
</div>
|
||
<div class="mod-action"></div>`;
|
||
|
||
const actions = item.querySelector('.mod-action');
|
||
if (kind === 'worlds') {
|
||
const open = document.createElement('button');
|
||
open.className = 'btn btn-mini';
|
||
open.textContent = 'Ordner';
|
||
open.addEventListener('click', () => window.api.bedrockOpenWorld(it.dir));
|
||
actions.appendChild(open);
|
||
}
|
||
const del = document.createElement('button');
|
||
del.className = 'btn btn-danger btn-mini';
|
||
del.textContent = 'Löschen';
|
||
del.addEventListener('click', () => askConfirm(
|
||
'Löschen', `„${name}" wird unwiderruflich gelöscht. Fortfahren?`, 'Endgültig löschen',
|
||
async () => {
|
||
await window.api.bedrockRemove(kind, kind === 'worlds' ? it.id : it.name);
|
||
await renderBedrockList();
|
||
toast('Gelöscht: ' + name);
|
||
}));
|
||
actions.appendChild(del);
|
||
box.appendChild(item);
|
||
}
|
||
}
|
||
|
||
// ---------- Kontextmenü (Rechtsklick auf eine Instanz) ----------
|
||
function openContextMenu(x, y) {
|
||
const menu = el('ctx-menu');
|
||
menu.classList.remove('hidden');
|
||
// erst anzeigen, dann Größe messen und am Fensterrand ausrichten
|
||
const r = menu.getBoundingClientRect();
|
||
const px = Math.min(x, window.innerWidth - r.width - 6);
|
||
const py = Math.min(y, window.innerHeight - r.height - 6);
|
||
menu.style.left = Math.max(6, px) + 'px';
|
||
menu.style.top = Math.max(6, py) + 'px';
|
||
}
|
||
|
||
function closeContextMenu() {
|
||
el('ctx-menu').classList.add('hidden');
|
||
}
|
||
|
||
function formatVersionLabel(version) {
|
||
return String(version || '')
|
||
.replace(/^v/i, '')
|
||
.replace(/-beta$/i, ' Beta')
|
||
.trim() || '–';
|
||
}
|
||
|
||
// ---------- Automatische Updates ----------
|
||
let pendingUpdate = null;
|
||
|
||
const CHANNEL_LABEL = { portable: 'Portable', setup: 'Setup' };
|
||
|
||
function showUpdateDialog(info) {
|
||
if (POPOUT !== 'update') { window.api.popoutOpen('update'); return; }
|
||
if (!info) return; // Popup geöffnet, aber (aktuell) kein Update-Ergebnis vorhanden
|
||
pendingUpdate = info;
|
||
const channel = CHANNEL_LABEL[info.installKind] || null;
|
||
el('upd-current').textContent = formatVersionLabel(info.currentVersion);
|
||
el('upd-new').textContent = formatVersionLabel(info.latestVersion);
|
||
el('upd-channel').textContent = channel
|
||
? `Update-Kanal: ${channel}`
|
||
: 'Update-Kanal: unbekannt – automatisches Update nicht möglich';
|
||
el('upd-notes').textContent = (info.notes || '').trim() || 'Keine Änderungshinweise hinterlegt.';
|
||
el('upd-progress').classList.add('hidden');
|
||
el('upd-bar-fill').style.width = '0%';
|
||
|
||
// Ohne passendes Paket wird bewusst nichts installiert – ein Portable-Launcher
|
||
// darf nie einen Installer bekommen und umgekehrt. Statt eines toten Knopfes
|
||
// führen wir dann zum Release, damit der Nutzer nicht in der Sackgasse steht.
|
||
const hatPaket = !!info.asset;
|
||
el('upd-install').classList.toggle('hidden', !hatPaket);
|
||
el('upd-install').disabled = !hatPaket;
|
||
el('upd-install').textContent = 'Installieren';
|
||
el('upd-open').classList.toggle('hidden', hatPaket || !info.url);
|
||
el('upd-hint').textContent = hatPaket
|
||
? 'Der Launcher lädt das Update herunter und startet die Installation automatisch.'
|
||
: (channel
|
||
? `Für den Kanal ${channel} liegt in diesem Release keine passende Datei bereit. Bitte im Release nachsehen und von Hand aktualisieren.`
|
||
: 'Die Paketart dieses Launchers lässt sich nicht sicher bestimmen. Bitte das Update von Hand aus dem Release laden.');
|
||
show('modal-update');
|
||
}
|
||
|
||
function openReleasePage() {
|
||
if (pendingUpdate && pendingUpdate.url) window.api.openExternal(pendingUpdate.url);
|
||
}
|
||
|
||
async function startUpdate() {
|
||
if (!pendingUpdate || !pendingUpdate.asset) return;
|
||
const btn = el('upd-install');
|
||
btn.disabled = true;
|
||
btn.textContent = 'Wird installiert …';
|
||
el('upd-progress').classList.remove('hidden');
|
||
el('upd-status').textContent = 'Wird heruntergeladen …';
|
||
|
||
const res = await window.api.updateInstall(pendingUpdate.asset);
|
||
if (res.ok) {
|
||
el('upd-status').textContent = res.kind === 'portable'
|
||
? 'Launcher schließt sich, wird ersetzt und startet automatisch neu.'
|
||
: 'Installation wird gestartet – der Launcher schließt sich.';
|
||
} else {
|
||
el('upd-status').textContent = '✖ ' + (res.message || 'Update fehlgeschlagen.');
|
||
btn.disabled = false;
|
||
btn.textContent = 'Erneut versuchen';
|
||
}
|
||
}
|
||
|
||
function onUpdateProgress(p) {
|
||
if (p.phase === 'download') {
|
||
el('upd-bar-fill').style.width = p.percent + '%';
|
||
const mb = (n) => (n / 1048576).toFixed(1);
|
||
el('upd-status').textContent = `Wird heruntergeladen … ${p.percent}% (${mb(p.loaded)} / ${mb(p.total)} MB)`;
|
||
} else if (p.phase === 'ready') {
|
||
el('upd-bar-fill').style.width = '100%';
|
||
el('upd-status').textContent = 'Download fertig – Installation startet …';
|
||
}
|
||
}
|
||
|
||
// Aktualisiert die Statuszeile im Info-Bereich – genutzt sowohl vom manuellen
|
||
// "Nach Updates suchen"-Knopf als auch vom automatischen Check beim Start.
|
||
function renderUpdateStatus(info) {
|
||
const st = el('s-update-status');
|
||
if (!st) return;
|
||
st.classList.remove('status-ok', 'status-bad');
|
||
if (info.checking) {
|
||
st.textContent = 'Suche nach Updates …';
|
||
} else if (info.error) {
|
||
st.classList.add('status-bad');
|
||
st.textContent = '✖ ' + info.error;
|
||
} else if (info.noReleases) {
|
||
st.textContent = 'Noch keine Veröffentlichung vorhanden.';
|
||
} else if (info.available) {
|
||
st.textContent = `Version ${formatVersionLabel(info.latestVersion)} verfügbar.`;
|
||
} else {
|
||
st.classList.add('status-ok');
|
||
st.textContent = `✓ Du hast bereits die neueste Version (${formatVersionLabel(info.currentVersion)}).`;
|
||
}
|
||
}
|
||
|
||
// ---------- Zustand der Anmelde-Dienste in der Statusleiste ----------
|
||
function showAuthHealth(h) {
|
||
const box = el('status-auth');
|
||
if (!h || h.ok) { box.classList.add('hidden'); box.textContent = ''; return; }
|
||
const list = (h.down || []).join(', ');
|
||
box.textContent = (h.down && h.down.length === AUTH_SERVICE_COUNT)
|
||
? 'Anmelde-Server nicht erreichbar – Login/Start derzeit nicht möglich'
|
||
: 'Anmelde-Dienst gestört: ' + list;
|
||
box.title = 'Zuletzt geprüft: ' + (h.checked ? new Date(h.checked).toLocaleTimeString('de-DE') : '–') + ' · Klicken zum erneut prüfen';
|
||
box.classList.remove('hidden');
|
||
}
|
||
const AUTH_SERVICE_COUNT = 3;
|
||
|
||
// ---------- Skin-Hintergrund (wie MultiMCs Katze, nur mit eigenem Skin) ----------
|
||
// uuidOverride: wird vom Konten-Wechsler (siehe unten) genutzt, um gezielt
|
||
// einen bestimmten Skin zu zeigen, ohne die eigentliche Einstellung anzufassen
|
||
async function applySkinBackground(uuidOverride) {
|
||
const s = await window.api.getSettings();
|
||
const root = document.documentElement;
|
||
let uuid = '';
|
||
if (uuidOverride) {
|
||
uuid = uuidOverride;
|
||
} else if (s.bgSkin === 'active') {
|
||
const acc = await window.api.authStatus();
|
||
uuid = acc && acc.profile ? acc.profile.id : '';
|
||
} else if (s.bgSkin) {
|
||
uuid = s.bgSkin;
|
||
}
|
||
if (!uuid) { root.style.setProperty('--bg-skin', 'none'); return; }
|
||
|
||
root.style.setProperty('--bg-skin-opacity', String((s.bgSkinOpacity ?? 55) / 100));
|
||
|
||
// Jede Pose hat auch eine Entsprechung beim 3D-Dienst (Kamerawinkel/Ansicht),
|
||
// damit die Auswahl immer sichtbar wirkt – auch wenn der Pose-Dienst offline ist.
|
||
// 1) Skin lokal in 3D rendern – unabhängig von Render-Diensten
|
||
const local = await renderSkin3D(`https://crafthead.net/skin/${uuid}`, s.bgSkinPose || 'walking');
|
||
if (local) { root.style.setProperty('--bg-skin', `url("${local}")`); return; }
|
||
|
||
// 2) Rückfall: externe Render-Dienste
|
||
for (const url of [
|
||
`https://nmsr.nickac.dev/fullbody/${uuid}?yaw=25&pitch=8`,
|
||
`https://visage.surgeplay.com/full/512/${uuid}`,
|
||
`https://crafthead.net/armor/body/${uuid}?size=512`,
|
||
]) {
|
||
const ok = await new Promise((resolve) => {
|
||
const img = new Image();
|
||
img.onload = () => resolve(true);
|
||
img.onerror = () => resolve(false);
|
||
img.src = url;
|
||
});
|
||
if (ok) { root.style.setProperty('--bg-skin', `url("${url}")`); return; }
|
||
}
|
||
root.style.setProperty('--bg-skin', 'none');
|
||
}
|
||
|
||
// ---------- Skin-Hintergrund: automatischer Kontenwechsel ----------
|
||
let skinCycleTimer = null;
|
||
let skinCycleIndex = 0;
|
||
|
||
function stopSkinCycle() {
|
||
if (skinCycleTimer) { clearInterval(skinCycleTimer); skinCycleTimer = null; }
|
||
}
|
||
|
||
async function skinCycleTick() {
|
||
const accs = await window.api.authAccounts();
|
||
if (accs.length < 2) { stopSkinCycle(); await applySkinBackground(); return; }
|
||
skinCycleIndex = (skinCycleIndex + 1) % accs.length; // am Ende wieder von vorn
|
||
await applySkinBackground(accs[skinCycleIndex].id);
|
||
}
|
||
|
||
/*
|
||
* Einstiegspunkt für alles, was den Skin-Hintergrund neu anzeigen soll.
|
||
* Ersetzt die frühere direkte applySkinBackground()-Aufrufe: kümmert sich
|
||
* zusätzlich darum, den Kontenwechsel-Timer je nach Einstellung zu
|
||
* starten/stoppen, statt dass jede Aufrufstelle das selbst tun müsste.
|
||
*/
|
||
async function refreshSkinBackground() {
|
||
stopSkinCycle();
|
||
const s = await window.api.getSettings();
|
||
if (s.bgSkinCycle) {
|
||
const accs = await window.api.authAccounts();
|
||
if (accs.length >= 2) {
|
||
skinCycleIndex = 0;
|
||
await applySkinBackground(accs[0].id);
|
||
const seconds = Math.max(5, Number(s.bgSkinCycleSeconds) || 30);
|
||
skinCycleTimer = setInterval(skinCycleTick, seconds * 1000);
|
||
return;
|
||
}
|
||
// weniger als 2 Konten angemeldet -> normal weiter wie ohne Wechsel
|
||
}
|
||
await applySkinBackground();
|
||
}
|
||
|
||
// Pose-Vorlagen für den lokalen 3D-Renderer (eigene Nachbildungen bekannter Motive)
|
||
const SKIN_POSES = {
|
||
walking: { label: 'Laufend', fov: 36, zoom: 0.62, apply: (p) => {
|
||
p.rotation.set(0.05, -0.62, 0);
|
||
p.skin.leftArm.rotation.set(-0.95, 0, 0.06);
|
||
p.skin.rightArm.rotation.set(0.85, 0, -0.06);
|
||
p.skin.leftLeg.rotation.set(0.8, 0, 0);
|
||
p.skin.rightLeg.rotation.set(-0.8, 0, 0);
|
||
} },
|
||
standing: { label: 'Stehend', fov: 36, zoom: 0.6, apply: (p) => {
|
||
p.rotation.set(0, -0.35, 0);
|
||
p.skin.leftArm.rotation.set(0, 0, 0.08);
|
||
p.skin.rightArm.rotation.set(0, 0, -0.08);
|
||
p.skin.leftLeg.rotation.set(0, 0, 0.02);
|
||
p.skin.rightLeg.rotation.set(0, 0, -0.02);
|
||
} },
|
||
legends: { label: 'Heldenhaft', fov: 34, zoom: 0.58, apply: (p) => {
|
||
p.rotation.set(-0.10, -0.5, 0);
|
||
p.skin.leftArm.rotation.set(-0.25, 0, 0.35);
|
||
p.skin.rightArm.rotation.set(-0.15, 0, -0.42);
|
||
p.skin.leftLeg.rotation.set(0.12, 0, 0.16);
|
||
p.skin.rightLeg.rotation.set(-0.12, 0, -0.16);
|
||
} },
|
||
bedwars: { label: 'Kampfhaltung', fov: 36, zoom: 0.6, apply: (p) => {
|
||
p.rotation.set(0.06, -0.85, 0);
|
||
p.skin.rightArm.rotation.set(-1.45, 0, -0.18); // Arm vorgestreckt
|
||
p.skin.leftArm.rotation.set(0.45, 0, 0.30);
|
||
p.skin.leftLeg.rotation.set(0.45, 0, 0.10);
|
||
p.skin.rightLeg.rotation.set(-0.35, 0, -0.10);
|
||
} },
|
||
hoverboard: { label: 'Schwebend', fov: 38, zoom: 0.6, apply: (p) => {
|
||
p.rotation.set(0.28, -0.7, 0.12); // vorgebeugt, gekippt
|
||
p.skin.leftArm.rotation.set(-0.35, 0, 1.05); // Arme zum Ausbalancieren
|
||
p.skin.rightArm.rotation.set(-0.35, 0, -1.05);
|
||
p.skin.leftLeg.rotation.set(0.5, 0, 0.18);
|
||
p.skin.rightLeg.rotation.set(0.15, 0, -0.18);
|
||
} },
|
||
sitting: { label: 'Sitzend', fov: 36, zoom: 0.62, apply: (p) => {
|
||
p.rotation.set(0.05, -0.55, 0);
|
||
p.skin.leftArm.rotation.set(-0.55, 0, 0.18);
|
||
p.skin.rightArm.rotation.set(-0.55, 0, -0.18);
|
||
p.skin.leftLeg.rotation.set(-1.5, 0, 0.12); // Beine angewinkelt
|
||
p.skin.rightLeg.rotation.set(-1.5, 0, -0.12);
|
||
} },
|
||
spooky: { label: 'Unheimlich', fov: 32, zoom: 0.6, apply: (p) => {
|
||
p.rotation.set(-0.06, -0.45, 0); // gedreht + leichte Untersicht
|
||
p.skin.head.rotation.set(0.22, -0.2, 0.28); // Kopf schief gelegt
|
||
p.skin.leftArm.rotation.set(-1.35, 0, 0.12); // Arme nach vorn ausgestreckt
|
||
p.skin.rightArm.rotation.set(-1.25, 0, -0.16);
|
||
p.skin.leftLeg.rotation.set(0.18, 0, 0.06);
|
||
p.skin.rightLeg.rotation.set(-0.12, 0, -0.06);
|
||
} },
|
||
};
|
||
|
||
// Rendert den Skin lokal mit WebGL in der gewählten Pose
|
||
/*
|
||
* Rendert eine Skin-Textur als Spielfigur und gibt ein Bild zurück.
|
||
* quelle: Adresse ODER data:-URI – so lässt sich auch eine Datei anzeigen,
|
||
* die noch gar nicht hochgeladen ist.
|
||
* modell: 'default' (breite Arme) oder 'slim' (Alex)
|
||
*/
|
||
async function renderSkin3D(quelle, poseKey, opts = {}) {
|
||
if (typeof skinview3d === 'undefined') return null;
|
||
const canvas = el('skin-canvas');
|
||
const breite = opts.breite || 420;
|
||
const hoehe = opts.hoehe || 700;
|
||
try {
|
||
const viewer = new skinview3d.SkinViewer({
|
||
canvas, width: breite, height: hoehe,
|
||
skin: quelle,
|
||
model: opts.modell === 'slim' ? 'slim' : 'default',
|
||
preserveDrawingBuffer: true,
|
||
});
|
||
// Auf das Laden der Skin-Textur warten
|
||
await new Promise((resolve, reject) => {
|
||
const t = setTimeout(reject, 8000);
|
||
const check = () => {
|
||
if (viewer.playerObject && viewer.playerObject.skin && viewer.playerObject.skin.map) { clearTimeout(t); resolve(); }
|
||
else setTimeout(check, 60);
|
||
};
|
||
check();
|
||
});
|
||
|
||
const pose = SKIN_POSES[poseKey] || SKIN_POSES.walking;
|
||
viewer.fov = pose.fov;
|
||
viewer.zoom = pose.zoom;
|
||
viewer.autoRotate = false;
|
||
viewer.animation = null;
|
||
pose.apply(viewer.playerObject);
|
||
|
||
viewer.render();
|
||
const data = canvas.toDataURL('image/png');
|
||
viewer.dispose();
|
||
return data && data.length > 2000 ? data : null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// ---------- Icon-System (SVG statt Emoji) ----------
|
||
const ICONS_SVG = {
|
||
plus: '<path d="M12 5v14M5 12h14"/>',
|
||
import: '<path d="M12 3v12m0 0l-4-4m4 4l4-4M4 20h16"/>',
|
||
package: '<path d="M21 8l-9-5-9 5 9 5 9-5zM3 8v8l9 5 9-5V8M12 13v8"/>',
|
||
folder: '<path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z"/>',
|
||
terminal: '<path d="M4 5h16v14H4zM7 9l3 3-3 3M13 16h4"/>',
|
||
user: '<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2M12 11a4 4 0 100-8 4 4 0 000 8z"/>',
|
||
users: '<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2M9 11a4 4 0 100-8 4 4 0 000 8zM23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75"/>',
|
||
settings: '<circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M19 5l-2 2M7 17l-2 2"/>',
|
||
play: '<path d="M7 5l12 7-12 7z"/>',
|
||
blocks: '<path d="M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z"/>',
|
||
globe: '<circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a15 15 0 010 18M12 3a15 15 0 000 18"/>',
|
||
image: '<path d="M4 5h16v14H4z"/><path d="M8.5 11a1.5 1.5 0 100-3 1.5 1.5 0 000 3zM4 17l5-5 4 4 3-3 4 4"/>',
|
||
edit: '<path d="M4 20h4L18 10l-4-4L4 16v4zM14 6l4 4"/>',
|
||
copy: '<path d="M9 9h10v10H9zM5 15V5h10"/>',
|
||
export: '<path d="M12 21V9m0 0l-4 4m4-4l4 4M4 4h16"/>',
|
||
trash: '<path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/>',
|
||
};
|
||
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 `<svg viewBox="0 0 24 24" ${attrs}>${p}</svg>`;
|
||
}
|
||
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 = `<img src="${iconData}" alt="" />`;
|
||
else { el('p-icon').innerHTML = iconOf(inst.icon, 60); }
|
||
el('p-name').textContent = inst.name;
|
||
el('p-groupline').textContent = inst.group ? `Gruppe: ${inst.group}` : 'Ohne Gruppe';
|
||
|
||
const loaderTxt = inst.minecraft.loader && inst.minecraft.loader !== 'vanilla'
|
||
? ' · ' + (LOADER_LABEL[inst.minecraft.loader] || inst.minecraft.loader) : '';
|
||
el('status-left').textContent = inst.name + ' (' + (inst.minecraft.version || 'ohne Version') + loaderTxt + ')';
|
||
el('status-play').textContent = 'Zuletzt gespielt: ' + (inst.lastPlayed ? fmtDate(inst.lastPlayed) : 'nie');
|
||
el('status-total').textContent = 'Spielzeit: ' + fmtDuration(inst.totalPlaySeconds);
|
||
}
|
||
|
||
async function applySelection(ids) {
|
||
const valid = new Set(instances.map((inst) => inst.id));
|
||
selectedInstanceIds = new Set((ids || []).filter((id) => valid.has(id)));
|
||
await refreshSelectionPanel();
|
||
}
|
||
|
||
async function toggleInstanceSelection(id) {
|
||
const next = new Set(selectedInstanceIds);
|
||
if (next.has(id)) next.delete(id);
|
||
else next.add(id);
|
||
await applySelection([...next]);
|
||
}
|
||
|
||
function buildCard(inst) {
|
||
const card = document.createElement('div');
|
||
card.className = 'card' + (selectedInstanceIds.has(inst.id) ? ' sel' : '');
|
||
card.dataset.id = inst.id;
|
||
|
||
const iconHtml = inst.iconData
|
||
? `<img class="card-icon-img" src="${inst.iconData}" alt="" />`
|
||
: `<span class="card-icon">${iconOf(inst.icon, 60)}</span>`;
|
||
// Tooltip: Name, Version/Loader und – falls vorhanden – die Notizen
|
||
const loaderTxt = inst.minecraft.loader && inst.minecraft.loader !== 'vanilla'
|
||
? ' · ' + (LOADER_LABEL[inst.minecraft.loader] || inst.minecraft.loader) : '';
|
||
let tip = inst.name + '\n' + (inst.minecraft.version || 'ohne Version') + loaderTxt;
|
||
if (inst.notes && inst.notes.trim()) tip += '\n\n' + inst.notes.trim();
|
||
card.title = tip;
|
||
|
||
card.innerHTML = `
|
||
${iconHtml}
|
||
<div class="card-name">${escapeHtml(inst.name)}</div>`;
|
||
|
||
// einfacher Klick = auswählen (MultiMC), Doppelklick = starten
|
||
card.addEventListener('click', (e) => {
|
||
if (e.ctrlKey || e.metaKey) toggleInstanceSelection(inst.id);
|
||
else selectInstance(inst.id);
|
||
});
|
||
card.addEventListener('dblclick', () => launch(inst.id));
|
||
|
||
// Rechtsklick: Instanz auswählen und Kontextmenü öffnen
|
||
card.addEventListener('contextmenu', async (e) => {
|
||
e.preventDefault();
|
||
if (!selectedInstanceIds.has(inst.id)) await selectInstance(inst.id);
|
||
openContextMenu(e.clientX, e.clientY); // sofort anzeigen
|
||
});
|
||
|
||
// Drag & Drop in Gruppen
|
||
card.draggable = true;
|
||
card.addEventListener('dragstart', (e) => { e.dataTransfer.setData('text/plain', inst.id); card.classList.add('dragging'); });
|
||
card.addEventListener('dragend', () => card.classList.remove('dragging'));
|
||
return card;
|
||
}
|
||
|
||
function render() {
|
||
const q = el('search').value.trim().toLowerCase();
|
||
const grid = el('grid');
|
||
grid.innerHTML = '';
|
||
|
||
const filtered = instances.filter((i) =>
|
||
!q || (i.name + ' ' + i.minecraft.version + ' ' + i.minecraft.loader + ' ' + (i.group || '')).toLowerCase().includes(q));
|
||
|
||
el('empty').classList.toggle('hidden', instances.length !== 0);
|
||
grid.classList.toggle('hidden', instances.length === 0);
|
||
|
||
// nach Gruppen bündeln
|
||
const groups = new Map();
|
||
for (const inst of filtered) {
|
||
const g = (inst.group && inst.group.trim()) || '';
|
||
if (!groups.has(g)) groups.set(g, []);
|
||
groups.get(g).push(inst);
|
||
}
|
||
// benannte Gruppen alphabetisch, "ohne Gruppe" zuletzt
|
||
const names = [...groups.keys()].sort((a, b) => (a === '' ? 1 : b === '' ? -1 : a.localeCompare(b)));
|
||
const showHeaders = names.some((n) => n !== '');
|
||
|
||
for (const g of names) {
|
||
const list = groups.get(g);
|
||
const section = document.createElement('div');
|
||
section.className = 'group-section' + (collapsedGroups.has(g) ? ' collapsed' : '');
|
||
|
||
if (showHeaders) {
|
||
const header = document.createElement('div');
|
||
header.className = 'group-header' + (collapsedGroups.has(g) ? ' collapsed' : '');
|
||
header.innerHTML = `<span class="chev">${collapsedGroups.has(g) ? '▸' : '▾'}</span> ${escapeHtml(g || 'Ohne Gruppe')} <span class="group-count">(${list.length})</span>`;
|
||
header.addEventListener('click', () => {
|
||
if (collapsedGroups.has(g)) collapsedGroups.delete(g); else collapsedGroups.add(g);
|
||
render();
|
||
});
|
||
section.appendChild(header);
|
||
}
|
||
const gridEl = document.createElement('div');
|
||
gridEl.className = 'group-grid';
|
||
for (const inst of list) gridEl.appendChild(buildCard(inst));
|
||
section.appendChild(gridEl);
|
||
|
||
// Drop-Ziel: Instanz per Drag&Drop in diese Gruppe verschieben
|
||
section.addEventListener('dragover', (e) => { e.preventDefault(); section.classList.add('drop-hover'); });
|
||
section.addEventListener('dragleave', (e) => { if (!section.contains(e.relatedTarget)) section.classList.remove('drop-hover'); });
|
||
section.addEventListener('drop', async (e) => {
|
||
e.preventDefault();
|
||
section.classList.remove('drop-hover');
|
||
const dropId = e.dataTransfer.getData('text/plain');
|
||
const dragged = instances.find((i) => i.id === dropId);
|
||
if (dragged && (dragged.group || '') !== g) {
|
||
await window.api.updateInstance(dropId, { group: g });
|
||
await reload();
|
||
toast('Verschoben nach: ' + (g || 'Ohne Gruppe'));
|
||
}
|
||
});
|
||
|
||
grid.appendChild(section);
|
||
}
|
||
status(`${instances.length} Instanz${instances.length === 1 ? '' : 'en'}`);
|
||
}
|
||
|
||
function fillGroupDatalist() {
|
||
const groups = [...new Set(instances.map((i) => i.group).filter(Boolean))].sort();
|
||
el('group-list').innerHTML = groups.map((g) => `<option value="${escapeHtml(g)}"></option>`).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 = '<option value="">neueste (empfohlen)</option>';
|
||
|
||
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 = '<option value="">– keine gewählt –</option>';
|
||
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 = `<img src="${dataUri}" alt="" />`;
|
||
box.classList.remove('hidden');
|
||
clr.classList.remove('hidden');
|
||
} else {
|
||
box.innerHTML = '';
|
||
box.classList.add('hidden');
|
||
clr.classList.add('hidden');
|
||
}
|
||
}
|
||
|
||
function resetIconControls() {
|
||
pendingImagePath = null;
|
||
pendingClearImage = false;
|
||
setIconPreview(null);
|
||
}
|
||
|
||
async function openCreate() {
|
||
if (POPOUT !== 'instance') { window.api.popoutOpen('instance'); return; }
|
||
editingId = null;
|
||
el('instance-modal-title').textContent = 'Neue Instanz';
|
||
el('f-name').value = '';
|
||
el('f-group').value = '';
|
||
fillGroupDatalist();
|
||
el('f-loader').value = 'vanilla';
|
||
el('f-loader-version').value = '';
|
||
el('f-notes').value = '';
|
||
fillIconSelect('grass');
|
||
resetIconControls();
|
||
el('f-advanced').classList.add('hidden');
|
||
show('modal-instance');
|
||
el('f-name').focus();
|
||
|
||
el('f-version-hint').textContent = 'Lade Versionsliste …';
|
||
await loadVersions();
|
||
el('f-version-type').value = 'release';
|
||
fillVersionSelect(mcVersionData.latest && mcVersionData.latest.release);
|
||
updateLoaderVersions();
|
||
}
|
||
|
||
async function openEdit(inst) {
|
||
if (POPOUT !== 'instance') { window.api.popoutOpen('instance', { instanceId: inst.id }); return; }
|
||
editingId = inst.id;
|
||
el('instance-modal-title').textContent = 'Instanz bearbeiten';
|
||
el('f-name').value = inst.name;
|
||
el('f-group').value = inst.group || '';
|
||
fillGroupDatalist();
|
||
el('f-loader').value = inst.minecraft.loader || 'vanilla';
|
||
el('f-loader-version').value = inst.minecraft.loaderVersion || '';
|
||
el('f-notes').value = inst.notes || '';
|
||
fillIconSelect(inst.icon);
|
||
resetIconControls();
|
||
if (inst.customImage) {
|
||
const data = await window.api.instanceIcon(inst.id);
|
||
if (data) setIconPreview(data);
|
||
}
|
||
el('f-min-mem').value = inst.java.minMemMb || '';
|
||
el('f-max-mem').value = inst.java.maxMemMb || '';
|
||
zeigeSpeicherHinweis();
|
||
fuelleInstanzJava(inst);
|
||
el('f-extra-args').value = inst.java.extraArgs || '';
|
||
el('f-prelaunch').value = inst.preLaunchCommand || '';
|
||
el('f-postexit').value = inst.postExitCommand || '';
|
||
el('f-advanced').classList.remove('hidden');
|
||
show('modal-instance');
|
||
el('f-name').focus();
|
||
|
||
el('f-version-hint').textContent = 'Lade Versionsliste …';
|
||
await loadVersions();
|
||
el('f-version-type').value = inst.minecraft.version ? 'all' : 'release';
|
||
fillVersionSelect(inst.minecraft.version || (mcVersionData.latest && mcVersionData.latest.release));
|
||
await updateLoaderVersions(inst.minecraft.loaderVersion);
|
||
}
|
||
|
||
async function saveInstance() {
|
||
const name = el('f-name').value.trim();
|
||
if (!name) { toast('Bitte einen Namen angeben.', true); el('f-name').focus(); return; }
|
||
|
||
const payload = {
|
||
name,
|
||
group: el('f-group').value.trim(),
|
||
icon: el('f-icon').value,
|
||
notes: el('f-notes').value.trim(),
|
||
minecraft: {
|
||
version: el('f-version').value.trim(),
|
||
loader: el('f-loader').value,
|
||
loaderVersion: el('f-loader-version').value.trim(),
|
||
},
|
||
};
|
||
// Speicherangaben prüfen, bevor gespeichert wird
|
||
const minMem = numOrNull(el('f-min-mem').value);
|
||
const maxMem = numOrNull(el('f-max-mem').value);
|
||
if (minMem || maxMem) {
|
||
const pruefung = await window.api.checkMemory(minMem, maxMem);
|
||
if (!pruefung.ok) { toast(pruefung.message, true); return; }
|
||
if (pruefung.warnung) toast(pruefung.warnung, false);
|
||
}
|
||
|
||
try {
|
||
let targetId = editingId;
|
||
if (editingId) {
|
||
payload.java = {
|
||
path: el('f-java').value.trim(),
|
||
minMemMb: minMem,
|
||
maxMemMb: maxMem,
|
||
extraArgs: el('f-extra-args').value.trim(),
|
||
};
|
||
payload.preLaunchCommand = el('f-prelaunch').value.trim();
|
||
payload.postExitCommand = el('f-postexit').value.trim();
|
||
await window.api.updateInstance(editingId, payload);
|
||
} else {
|
||
const created = await window.api.createInstance(payload);
|
||
targetId = created.id;
|
||
}
|
||
// eigenes Bild anwenden bzw. entfernen
|
||
if (pendingImagePath) await window.api.setInstanceImage(targetId, pendingImagePath);
|
||
else if (pendingClearImage && editingId) await window.api.clearInstanceImage(targetId);
|
||
|
||
toast(editingId ? 'Instanz gespeichert.' : 'Instanz angelegt.');
|
||
hide('modal-instance');
|
||
await reload();
|
||
await selectInstance(targetId);
|
||
} catch (e) {
|
||
toast('Fehler: ' + e.message, true);
|
||
}
|
||
}
|
||
|
||
// ---------- Auswahl + rechte Aktionsleiste (MultiMC-Stil) ----------
|
||
async function selectInstance(id) {
|
||
await applySelection(id ? [id] : []);
|
||
}
|
||
|
||
let launching = false;
|
||
|
||
async function launch(id, accountId, launchOpts) {
|
||
if (launching) return;
|
||
launching = true;
|
||
status('Vorbereiten …');
|
||
el('status-right').textContent = '';
|
||
appendConsole('\n=== Start: ' + new Date().toLocaleTimeString('de-DE') + ' ===\n');
|
||
try {
|
||
const res = await window.api.launchInstance(id, accountId, launchOpts || null);
|
||
if (res.ok) {
|
||
status('Läuft (PID ' + res.pid + ')');
|
||
toast('Minecraft gestartet.');
|
||
const s = await window.api.getSettings();
|
||
if (s.showConsoleOnLaunch) openConsole(); // nur wenn gewünscht
|
||
} else if (res.needAuth) {
|
||
status('Bereit'); el('status-right').textContent = '';
|
||
toast(res.message, false);
|
||
} else {
|
||
status('Bereit'); el('status-right').textContent = '';
|
||
toast(res.message || 'Start fehlgeschlagen.', true);
|
||
}
|
||
} catch (e) {
|
||
status('Bereit'); toast('Fehler: ' + e.message, true);
|
||
} finally {
|
||
launching = false;
|
||
}
|
||
}
|
||
|
||
// ---------- Java je Instanz ----------
|
||
|
||
async function fuelleInstanzJava(inst) {
|
||
const feld = el('f-java');
|
||
if (!feld) return;
|
||
feld.value = (inst && inst.java && inst.java.path) || '';
|
||
|
||
const sel = el('f-java-detected');
|
||
sel.innerHTML = '<option value="">– gefundene Java-Installationen –</option>';
|
||
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 ? `<div class="crash-zeile"><div class="crash-titel">${t}</div><div class="crash-wert">${escapeHtml(w)}</div></div>` : '');
|
||
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 = '<div class="mods-empty">Lade …</div>';
|
||
const res = await window.api.serversList(currentDetailId);
|
||
box.innerHTML = '';
|
||
|
||
if (!res.ok) { box.innerHTML = `<div class="mods-empty">${escapeHtml(res.message || 'Liste nicht lesbar.')}</div>`; return; }
|
||
if (!res.server.length) {
|
||
box.innerHTML = '<div class="mods-empty">Noch keine Server eingetragen.</div>';
|
||
return;
|
||
}
|
||
|
||
for (const s of res.server) {
|
||
const item = document.createElement('div');
|
||
item.className = 'mod-item';
|
||
item.innerHTML =
|
||
`<div class="mod-icon">🖥</div>
|
||
<div class="mod-info">
|
||
<div class="mod-name">${escapeHtml(s.name || s.ip)}</div>
|
||
<div class="mod-meta">${escapeHtml(s.ip)}</div>
|
||
<div class="mod-meta srv-status hint">Wird geprüft …</div>
|
||
</div>
|
||
<div class="mod-action"></div>`;
|
||
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 = '<div class="mods-empty">Es gibt keine weitere Instanz.</div>';
|
||
}
|
||
for (const i of andere) {
|
||
const zeile = document.createElement('label');
|
||
zeile.className = 'checkbox-field';
|
||
zeile.style.padding = '6px 2px';
|
||
zeile.innerHTML = `<input type="checkbox" value="${escapeHtml(i.id)}" /> <span>${escapeHtml(i.name)}</span>`;
|
||
box.appendChild(zeile);
|
||
}
|
||
show('modal-srv-copy');
|
||
}
|
||
|
||
async function doServerCopy() {
|
||
if (!zuUebertragenderServer) return;
|
||
const ids = [...el('srv-copy-list').querySelectorAll('input:checked')].map((c) => c.value);
|
||
if (!ids.length) { toast('Keine Instanz ausgewählt.', true); return; }
|
||
|
||
const res = await window.api.serversCopyTo(ids, zuUebertragenderServer);
|
||
hide('modal-srv-copy');
|
||
if (res.erledigt.length) toast(`Eingetragen in: ${res.erledigt.join(', ')}`);
|
||
for (const f of res.fehler) toast(`${f.name || f.id}: ${f.message}`, true);
|
||
}
|
||
|
||
// ---------- Skin ändern ----------
|
||
|
||
let gewaehlterSkin = null;
|
||
|
||
// Textur einer Seite merken, damit bei einem Wechsel der Armform beide
|
||
// Figuren neu gerendert werden können
|
||
let skinTexturAktuell = '';
|
||
let skinTexturNeu = '';
|
||
|
||
// laufende Viewer je Canvas -- vor einem Neuaufbau erst abräumen, sonst
|
||
// stapeln sich bei jedem Wechsel (Datei, Armform, ...) weitere WebGL-Kontexte
|
||
const skinViewers = {};
|
||
function schliesseSkinViewer(bildId) {
|
||
if (skinViewers[bildId]) {
|
||
try { skinViewers[bildId].dispose(); } catch { /* egal */ }
|
||
delete skinViewers[bildId];
|
||
}
|
||
}
|
||
|
||
/*
|
||
* Skin-Textur als frei mit der Maus drehbare 3D-Figur zeigen (Ziehen dreht,
|
||
* Mausrad zoomt -- kommt von skinview3d selbst). Klappt das Rendern nicht,
|
||
* wird stattdessen ein Hinweistext gezeigt.
|
||
*/
|
||
async function zeigeSkinFigur(bildId, leerId, textur, leerText) {
|
||
schliesseSkinViewer(bildId);
|
||
const canvas = el(bildId);
|
||
const leer = el(leerId);
|
||
canvas.classList.add('hidden');
|
||
if (!textur) { leer.textContent = leerText; leer.classList.remove('hidden'); return; }
|
||
if (typeof skinview3d === 'undefined') {
|
||
leer.textContent = 'Vorschau nicht verfügbar';
|
||
leer.classList.remove('hidden');
|
||
return;
|
||
}
|
||
leer.textContent = 'wird gerendert …';
|
||
leer.classList.remove('hidden');
|
||
const modell = el('skin-variante').value === 'slim' ? 'slim' : 'default';
|
||
try {
|
||
const viewer = new skinview3d.SkinViewer({
|
||
canvas,
|
||
width: canvas.clientWidth || 260,
|
||
height: canvas.clientHeight || 288,
|
||
skin: textur,
|
||
model: modell,
|
||
});
|
||
// Auf das Laden der Skin-Textur warten, bevor Pose/Kamera gesetzt werden
|
||
await new Promise((resolve, reject) => {
|
||
const t = setTimeout(reject, 8000);
|
||
const check = () => {
|
||
if (viewer.playerObject && viewer.playerObject.skin && viewer.playerObject.skin.map) { clearTimeout(t); resolve(); }
|
||
else setTimeout(check, 60);
|
||
};
|
||
check();
|
||
});
|
||
const pose = SKIN_POSES.standing;
|
||
viewer.fov = pose.fov;
|
||
viewer.zoom = pose.zoom;
|
||
viewer.autoRotate = false;
|
||
viewer.animation = null;
|
||
pose.apply(viewer.playerObject);
|
||
viewer.controls.enableRotate = true;
|
||
viewer.controls.enableZoom = true;
|
||
viewer.controls.enablePan = false;
|
||
skinViewers[bildId] = viewer;
|
||
canvas.classList.remove('hidden');
|
||
leer.classList.add('hidden');
|
||
} catch {
|
||
leer.textContent = 'Vorschau nicht ladbar';
|
||
leer.classList.remove('hidden');
|
||
}
|
||
}
|
||
|
||
async function openSkin() {
|
||
if (POPOUT !== 'skin') { window.api.popoutOpen('skin'); return; }
|
||
gewaehlterSkin = null;
|
||
skinTexturAktuell = '';
|
||
skinTexturNeu = '';
|
||
el('skin-pfad').value = '';
|
||
el('skin-apply').disabled = true;
|
||
el('skin-status').textContent = '';
|
||
await zeigeSkinFigur('skin-neu', 'skin-neu-leer', '', 'noch keine Datei gewählt');
|
||
await zeigeSkinFigur('skin-aktuell', 'skin-aktuell-leer', '', 'wird geladen …');
|
||
el('skin-konto').textContent = 'Profil wird geladen …';
|
||
show('modal-skin');
|
||
|
||
const p = await window.api.skinProfile();
|
||
if (!p.ok) {
|
||
el('skin-konto').textContent = p.message || 'Profil konnte nicht geladen werden.';
|
||
await zeigeSkinFigur('skin-aktuell', 'skin-aktuell-leer', '', 'kein Profil geladen');
|
||
return;
|
||
}
|
||
el('skin-konto').textContent = `Angemeldet als ${p.name}. Die Änderung gilt für dieses Konto.`;
|
||
// Armform zuerst setzen – davon hängt ab, wie die Figur gerendert wird
|
||
if (p.variante) el('skin-variante').value = p.variante === 'slim' ? 'slim' : 'classic';
|
||
skinTexturAktuell = p.skinUrl || '';
|
||
await zeigeSkinFigur('skin-aktuell', 'skin-aktuell-leer', skinTexturAktuell,
|
||
'Standard-Skin (keine eigene Datei hinterlegt)');
|
||
}
|
||
|
||
// Armform gewechselt -> beide Figuren neu zeichnen
|
||
async function skinVarianteGewechselt() {
|
||
if (skinTexturAktuell) await zeigeSkinFigur('skin-aktuell', 'skin-aktuell-leer', skinTexturAktuell, '');
|
||
if (skinTexturNeu) await zeigeSkinFigur('skin-neu', 'skin-neu-leer', skinTexturNeu, '');
|
||
}
|
||
|
||
async function waehleSkinDatei() {
|
||
const res = await window.api.skinChoose();
|
||
if (res.canceled) return;
|
||
if (!res.ok) { el('skin-status').textContent = '✖ ' + res.message; return; }
|
||
|
||
gewaehlterSkin = res.pfad;
|
||
el('skin-pfad').value = res.pfad;
|
||
skinTexturNeu = res.dataUri || '';
|
||
await zeigeSkinFigur('skin-neu', 'skin-neu-leer', skinTexturNeu, 'Vorschau nicht verfügbar');
|
||
el('skin-status').textContent = `${res.breite}×${res.hoehe} Pixel – sieht gut aus.`;
|
||
el('skin-apply').disabled = false;
|
||
}
|
||
|
||
async function setzeSkin() {
|
||
if (!gewaehlterSkin) return;
|
||
const btn = el('skin-apply');
|
||
btn.disabled = true;
|
||
el('skin-status').textContent = 'Wird hochgeladen …';
|
||
const res = await window.api.skinApply(gewaehlterSkin, el('skin-variante').value);
|
||
if (res.ok) {
|
||
el('skin-status').textContent = '✔ Skin gesetzt. Im Spiel kann es einen Moment dauern.';
|
||
toast('Skin geändert.');
|
||
await openSkin(); // Vorschau mit dem neuen Stand auffrischen
|
||
await refreshSkinBackground(); // auch der Hintergrund zeigt jetzt den neuen Skin
|
||
} else {
|
||
el('skin-status').textContent = '✖ ' + (res.message || 'Fehlgeschlagen.');
|
||
btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function setzeSkinZurueck() {
|
||
const btn = el('skin-reset');
|
||
btn.disabled = true;
|
||
el('skin-status').textContent = 'Wird zurückgesetzt …';
|
||
const res = await window.api.skinReset();
|
||
if (res.ok) {
|
||
toast('Skin auf Standard zurückgesetzt.');
|
||
await openSkin();
|
||
await refreshSkinBackground();
|
||
} else {
|
||
el('skin-status').textContent = '✖ ' + (res.message || 'Fehlgeschlagen.');
|
||
}
|
||
btn.disabled = false;
|
||
}
|
||
|
||
// ---------- Logdateien ----------
|
||
|
||
let aktuellesLog = '';
|
||
|
||
async function openLogs() {
|
||
if (!currentDetailId) return;
|
||
if (POPOUT !== 'logs') { window.api.popoutOpen('logs', { instanceId: currentDetailId }); return; }
|
||
const inst = await window.api.getInstance(currentDetailId);
|
||
el('logs-title').textContent = 'Logdateien – ' + (inst ? inst.name : '');
|
||
const auswahl = el('logs-select');
|
||
auswahl.innerHTML = '';
|
||
el('logs-text').textContent = 'Lade …';
|
||
el('logs-info').textContent = '';
|
||
aktuellesLog = '';
|
||
show('modal-logs');
|
||
|
||
const dateien = await window.api.logsList(currentDetailId);
|
||
if (!dateien.length) {
|
||
el('logs-text').textContent = 'Für diese Instanz gibt es noch keine Logdateien. '
|
||
+ 'Sie entstehen beim ersten Spielstart.';
|
||
return;
|
||
}
|
||
for (const d of dateien) {
|
||
const o = document.createElement('option');
|
||
o.value = d.name;
|
||
const kb = Math.max(1, Math.round(d.groesse / 1024));
|
||
o.textContent = `${d.name} · ${kb} KB · ${new Date(d.geaendert).toLocaleString('de-DE')}`
|
||
+ (d.gepackt ? ' · gepackt' : '');
|
||
auswahl.appendChild(o);
|
||
}
|
||
await ladeLog(dateien[0].name);
|
||
}
|
||
|
||
async function ladeLog(name) {
|
||
el('logs-text').textContent = 'Lade …';
|
||
const res = await window.api.logsRead(currentDetailId, name);
|
||
if (!res.ok) {
|
||
el('logs-text').textContent = 'Datei konnte nicht gelesen werden.';
|
||
el('logs-info').textContent = '';
|
||
aktuellesLog = '';
|
||
return;
|
||
}
|
||
aktuellesLog = res.text;
|
||
el('logs-text').textContent = res.text || '(leer)';
|
||
el('logs-info').textContent = res.gekuerzt
|
||
? `${res.zeilenGesamt} Zeilen – die letzten 5000 werden gezeigt`
|
||
: `${res.zeilenGesamt} Zeilen`;
|
||
// ans Ende springen: dort steht, was zuletzt passierte
|
||
const feld = el('logs-text');
|
||
feld.scrollTop = feld.scrollHeight;
|
||
}
|
||
|
||
// ---------- Absturzberichte ----------
|
||
|
||
let letzterStartMs = 0;
|
||
let aktuellerCrash = null;
|
||
|
||
async function zeigeAbsturzbericht(instanzId) {
|
||
const id = instanzId || currentDetailId;
|
||
if (!id) return;
|
||
if (POPOUT !== 'crash') { window.api.popoutOpen('crash', { instanceId: id }); return; }
|
||
// Minecraft schreibt den Bericht erst kurz nach dem Ende
|
||
await new Promise((r) => setTimeout(r, 900));
|
||
|
||
let res;
|
||
try { res = await window.api.crashLatest(id, letzterStartMs); }
|
||
catch { return; }
|
||
if (!res || !res.ok) return; // kein Bericht -> Konsole muss reichen
|
||
|
||
aktuellerCrash = res;
|
||
const inst = await window.api.getInstance(id);
|
||
el('crash-sub').textContent = `${inst ? inst.name + ' · ' : ''}${res.datei} · `
|
||
+ new Date(res.zeitpunkt).toLocaleString('de-DE');
|
||
|
||
const zeile = (titel, wert) => wert
|
||
? `<div class="crash-zeile"><div class="crash-titel">${titel}</div><div class="crash-wert">${escapeHtml(wert)}</div></div>`
|
||
: '';
|
||
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 += `<div class="crash-zeile"><div class="crash-titel">Aufrufverlauf</div>`
|
||
+ `<div class="crash-wert"><div class="crash-verlauf">${escapeHtml(res.verlauf.join('\n'))}</div></div></div>`;
|
||
}
|
||
el('crash-kern').innerHTML = html || '<div class="hint">Der Bericht enthält keine der üblichen Kennzeichen.</div>';
|
||
el('crash-voll').textContent = res.text || '';
|
||
show('modal-crash');
|
||
}
|
||
|
||
// Fortschrittsmeldungen der Download-/Start-Pipeline
|
||
function onLaunchEvt(evt) {
|
||
const labels = { version: 'Version', client: 'Client', libraries: 'Libraries', assets: 'Assets', loader: 'Loader', java: 'Java', launch: 'Start', modpack: 'Modpack', backup: 'Sicherung' };
|
||
if (evt.phase === 'log') {
|
||
appendConsole(evt.line);
|
||
return;
|
||
}
|
||
if (evt.phase === 'exit') {
|
||
status('Minecraft beendet (Code ' + evt.code + ')');
|
||
el('status-right').textContent = '';
|
||
appendConsole('\n[Prozess beendet, Code ' + evt.code + ']\n');
|
||
// War vorher "Stopping!" im Log (regulärer Client-Shutdown), zählt ein
|
||
// nachträglicher harter Exit-Code nicht als Absturz während des Spiels –
|
||
// z. B. wenn Minecrafts interner Shutdown-Watchdog wegen hängender
|
||
// Mod-Threads (z. B. JourneyMap WebMap) die JVM nach Ablauf einer
|
||
// Wartezeit zwangsweise beendet. Der Bericht bleibt trotzdem gespeichert.
|
||
const crashed = evt.code !== 0 && evt.code !== null && !evt.cleanShutdown;
|
||
if (evt.cleanShutdown && evt.code !== 0 && evt.code !== null) {
|
||
appendConsole('[Hinweis] Der Client wurde regulär beendet (Stopping!), aber danach vom '
|
||
+ 'internen Shutdown-Watchdog hart terminiert (z. B. durch einen Mod, dessen Threads '
|
||
+ 'nicht rechtzeitig beenden – etwa JourneyMap WebMap). Kein Absturz während des Spiels.\n');
|
||
}
|
||
// Absturzbericht suchen, bevor über die Konsole entschieden wird – ein
|
||
// Bericht sagt mehr als der Java-Auswurf. Das entscheidet nur das
|
||
// Hauptfenster – sonst würde z. B. das Konsolen-Popup-Fenster bei einem
|
||
// Absturz zusätzlich einen eigenen Absturzbericht aufmachen.
|
||
if (!POPOUT) {
|
||
if (crashed) zeigeAbsturzbericht(evt.id);
|
||
window.api.getSettings().then((s) => {
|
||
if (crashed && s.showConsoleOnCrash !== false) { openConsole(); return; }
|
||
if (s.autoCloseConsole) hide('modal-console');
|
||
}).catch(() => {});
|
||
}
|
||
return;
|
||
}
|
||
// Startzeitpunkt merken: nur Berichte ab hier gehören zu diesem Lauf
|
||
if (evt.phase === 'launch') letzterStartMs = Date.now();
|
||
const label = labels[evt.phase] || evt.phase;
|
||
if (evt.total) {
|
||
status(`${label} … ${evt.current}/${evt.total}`);
|
||
el('status-right').textContent = Math.round((evt.current / evt.total) * 100) + '%';
|
||
} else {
|
||
status(evt.detail || label + ' …');
|
||
el('status-right').textContent = '';
|
||
appendConsole('[' + label + '] ' + (evt.detail || '') + '\n');
|
||
}
|
||
}
|
||
|
||
// ---------- Konsole ----------
|
||
function classifyLine(l) {
|
||
if (/\/(ERROR|FATAL|SEVERE)\]/.test(l) || /Exception(:| in )|^\s+at\s|Caused by:/.test(l)) return 'log-error';
|
||
if (/\/WARN(ING)?\]/.test(l) || /\bWARN\b/.test(l)) return 'log-warn';
|
||
if (/\[Pre-Launch\]|\[Post-Exit\]|=== Start:|\[Prozess beendet/.test(l)) return 'log-info';
|
||
return '';
|
||
}
|
||
function appendLineEl(out, line) {
|
||
const div = document.createElement('div');
|
||
div.className = 'log-line ' + classifyLine(line);
|
||
div.textContent = line;
|
||
out.appendChild(div);
|
||
}
|
||
let consoleMaxChars = 300000; // aus den Einstellungen abgeleitet (Zeilen x ~80 Zeichen)
|
||
function appendConsole(text) {
|
||
consoleBuffer += text;
|
||
if (consoleBuffer.length > consoleMaxChars) consoleBuffer = consoleBuffer.slice(-Math.round(consoleMaxChars * 0.75));
|
||
consolePending += text;
|
||
const lines = consolePending.split('\n');
|
||
consolePending = lines.pop();
|
||
const out = el('console-out');
|
||
if (!el('modal-console').classList.contains('hidden')) {
|
||
for (const line of lines) appendLineEl(out, line);
|
||
out.scrollTop = out.scrollHeight;
|
||
}
|
||
}
|
||
function openConsole() {
|
||
if (POPOUT !== 'console') { window.api.popoutOpen('console'); return; }
|
||
const out = el('console-out');
|
||
out.innerHTML = '';
|
||
const lines = (consoleBuffer || '(noch keine Ausgabe)').split('\n');
|
||
for (const line of lines) appendLineEl(out, line);
|
||
consolePending = '';
|
||
show('modal-console');
|
||
out.scrollTop = out.scrollHeight;
|
||
}
|
||
|
||
// ---------- Welten & Ressourcenpakete ----------
|
||
async function openContent() {
|
||
if (POPOUT !== 'content') { window.api.popoutOpen('content', { instanceId: currentDetailId }); return; }
|
||
const inst = await window.api.getInstance(currentDetailId);
|
||
el('content-title').textContent = 'Welten, Ressourcen- & Datenpakete – ' + inst.name;
|
||
await renderContentLists();
|
||
show('modal-content');
|
||
}
|
||
async function renderContentLists() {
|
||
renderContentList('content-worlds', await window.api.worlds(currentDetailId), 'saves', '🌍');
|
||
renderContentList('content-rp', await window.api.resourcepacks(currentDetailId), 'resourcepacks', '🎨');
|
||
renderContentList('content-dp', await window.api.datapacks(currentDetailId), 'datapacks', '📜');
|
||
}
|
||
// ---------- Screenshots ----------
|
||
let screenSelected = new Set();
|
||
|
||
function updateScreensSelectionUi() {
|
||
const n = screenSelected.size;
|
||
const btn = el('screens-delete-sel');
|
||
if (btn) {
|
||
btn.disabled = n === 0;
|
||
btn.textContent = n ? (n + ' löschen') : 'Auswahl löschen';
|
||
}
|
||
const hint = el('screens-sel-hint');
|
||
if (hint) hint.textContent = n ? (n + ' ausgewählt – Doppelklick öffnet das Bild') : 'Klick auf Vorschaubild zum Auswählen · Doppelklick öffnet';
|
||
document.querySelectorAll('#screens-grid .screen-thumb').forEach((t) => {
|
||
const name = t.dataset.name;
|
||
const on = name && screenSelected.has(name);
|
||
t.classList.toggle('selected', !!on);
|
||
const cb = t.querySelector('.screen-check');
|
||
if (cb) cb.checked = !!on;
|
||
});
|
||
}
|
||
|
||
// ---------- Spielaufzeichnung ----------
|
||
let mcVideoStream = null;
|
||
let mcSearchTimer = null;
|
||
let mcSearchElapsed = 0;
|
||
let mcCurrentSource = null; // zuletzt erfasste Fensterquelle - für ein Neu-Erfassen bei Auflösungswechsel
|
||
|
||
// Auflösung/Qualität/Bildrate der Aufnahme (bleiben über mehrere Aufnahmen in
|
||
// diesem Fenster erhalten, wie schon bei den Audioeinstellungen)
|
||
let mcResolution = 'native';
|
||
let mcQuality = 'medium';
|
||
let mcFramerate = 30;
|
||
let mcActualSize = null; // {width,height} der zuletzt tatsächlich erfassten Auflösung
|
||
let mcMicSelectedIds = []; // gespeicherte Mikrofon-Auswahl (deviceIds) - für den Aufbau der Mikrofonliste
|
||
let mcCursorAusgeblendet = false; // true, wenn die zuletzt erfasste Vorschau ohne Mauszeiger läuft
|
||
let mcRohStream = null; // native, unskalierte Bildschirm-Erfassung, falls mcVideoStream ein daraus erzeugter Canvas-Stream ist
|
||
let mcCanvasRAF = null; // requestAnimationFrame-Kennung der Zuschnitt-/Skalierungs-Zeichenschleife
|
||
let mcCanvasVideoEl = null; // verstecktes <video>, das mcRohStream für die Zeichenschleife abspielt
|
||
let mcZuschnittPoller = null; // setInterval-Kennung: fragt Fenstergrenzen alle 500ms erneut ab
|
||
let mcFensterBounds = null; // zuletzt bekannte Grenzen des Minecraft-Fensters (Bildschirmkoordinaten)
|
||
let mcBildschirmBounds = null; // Grenzen des Bildschirms, auf dem das Fenster erfasst wurde
|
||
|
||
// Beendet die aktuelle Vorschau/Erfassung vollständig, inklusive einer
|
||
// eventuell zwischengeschalteten Zuschnitt-/Skalierungs-Zeichenschleife
|
||
// (siehe erzeugeSkaliertenStream) - immer verwenden statt mcVideoStream
|
||
// direkt zu stoppen, sonst laufen Canvas-Zeichenschleife, Fenster-Nachführung
|
||
// und Rohstream unbemerkt weiter
|
||
function stoppeVideoErfassung() {
|
||
if (mcZuschnittPoller) { clearInterval(mcZuschnittPoller); mcZuschnittPoller = null; }
|
||
if (mcCanvasRAF) { cancelAnimationFrame(mcCanvasRAF); mcCanvasRAF = null; }
|
||
if (mcCanvasVideoEl) { mcCanvasVideoEl.pause(); mcCanvasVideoEl.srcObject = null; mcCanvasVideoEl = null; }
|
||
if (mcRohStream) { mcRohStream.getTracks().forEach((t) => t.stop()); mcRohStream = null; }
|
||
if (mcVideoStream) { mcVideoStream.getTracks().forEach((t) => t.stop()); mcVideoStream = null; }
|
||
mcFensterBounds = null;
|
||
mcBildschirmBounds = null;
|
||
}
|
||
|
||
const RESOLUTIONEN = {
|
||
native: null,
|
||
1080: { width: 1920, height: 1080 },
|
||
720: { width: 1280, height: 720 },
|
||
480: { width: 854, height: 480 },
|
||
};
|
||
const QUALITAETEN = { low: 3_000_000, medium: 8_000_000, high: 16_000_000 };
|
||
const RESOLUTION_LABEL = { native: 'Nativ', 1080: '1080p', 720: '720p', 480: '480p' };
|
||
const QUALITAET_LABEL = { low: 'Niedrig', medium: 'Mittel', high: 'Hoch' };
|
||
|
||
// Der zuletzt erkannte Server liegt im Hauptprozess (nicht hier im Renderer),
|
||
// weil er dort live aus dem Minecraft-Log mitverfolgt wird ("Connecting to
|
||
// X, Y" - funktioniert unabhängig davon, ob das Aufzeichnungs-Fenster beim
|
||
// Verbinden schon offen war) und weil Serverliste/Aufzeichnung ohnehin
|
||
// getrennte Popup-Fenster mit jeweils eigenem Renderer-Speicher sind
|
||
async function aktuellerAufnahmeServer() {
|
||
return window.api.recordingGetServer(currentDetailId);
|
||
}
|
||
|
||
async function aktualisiereRecordTitel() {
|
||
const inst = await window.api.getInstance(currentDetailId);
|
||
const server = await aktuellerAufnahmeServer();
|
||
el('record-title').textContent = 'Spielaufzeichnung – ' + (inst ? inst.name : '') + (server ? ' · ' + server : '');
|
||
}
|
||
|
||
// Video-/Audio-Einstellungen der Aufzeichnung aus settings.json laden - bleiben
|
||
// so über einen Neustart des Launchers hinweg erhalten (letzte Wahl je Feld)
|
||
async function ladeRecordEinstellungen() {
|
||
let s;
|
||
try { s = await window.api.getSettings(); } catch { return; }
|
||
mcResolution = s.recordingResolution || 'native';
|
||
mcQuality = s.recordingQuality || 'medium';
|
||
mcFramerate = s.recordingFramerate || 30;
|
||
mcVolSystem = (s.recordingVolSystem ?? 100) / 100;
|
||
mcVolGame = (s.recordingVolGame ?? 100) / 100;
|
||
mcMicSelectedIds = Array.isArray(s.recordingMicSelected) ? s.recordingMicSelected : [];
|
||
mcVolMics.clear();
|
||
for (const [deviceId, vol] of Object.entries(s.recordingMicVolumes || {})) mcVolMics.set(deviceId, vol / 100);
|
||
|
||
el('record-resolution').value = mcResolution;
|
||
el('record-quality').value = mcQuality;
|
||
el('record-fps').value = String(mcFramerate);
|
||
el('record-vol-system').value = Math.round(mcVolSystem * 100);
|
||
el('record-vol-game').value = Math.round(mcVolGame * 100);
|
||
el('record-src-system').checked = s.recordingAudioSystem !== false;
|
||
el('record-src-game').checked = !!s.recordingAudioGame;
|
||
}
|
||
|
||
// Aktuellen Stand sichern - wird bei jeder Änderung eines Feldes aufgerufen
|
||
function speichereRecordEinstellungen() {
|
||
const micVolumes = {};
|
||
for (const [deviceId, vol] of mcVolMics.entries()) micVolumes[deviceId] = Math.round(vol * 100);
|
||
window.api.saveSettings({
|
||
recordingResolution: mcResolution,
|
||
recordingQuality: mcQuality,
|
||
recordingFramerate: mcFramerate,
|
||
recordingAudioSystem: el('record-src-system').checked,
|
||
recordingAudioGame: el('record-src-game').checked,
|
||
recordingVolSystem: Math.round(mcVolSystem * 100),
|
||
recordingVolGame: Math.round(mcVolGame * 100),
|
||
recordingMicSelected: [...document.querySelectorAll('.record-mic-checkbox:checked')].map((c) => c.value),
|
||
recordingMicVolumes: micVolumes,
|
||
});
|
||
}
|
||
|
||
async function openRecording() {
|
||
if (POPOUT !== 'record') { window.api.popoutOpen('record', { instanceId: currentDetailId }); return; }
|
||
await ladeRecordEinstellungen();
|
||
await aktualisiereRecordTitel();
|
||
// beim erneuten Öffnen (popout:reopen) eine evtl. noch offene Vorschau
|
||
// sauber beenden, bevor eine neue gesucht wird
|
||
stoppeVideoErfassung();
|
||
hide('record-picker');
|
||
hide('record-stage');
|
||
show('record-search');
|
||
el('record-search-retry').classList.add('hidden');
|
||
el('record-search-text').textContent = 'Suche Spielfenster …';
|
||
show('modal-record');
|
||
startWindowSearch();
|
||
}
|
||
|
||
function startWindowSearch() {
|
||
clearInterval(mcSearchTimer);
|
||
mcSearchElapsed = 0;
|
||
el('record-search-text').textContent = 'Suche Spielfenster …';
|
||
el('record-search-retry').classList.add('hidden');
|
||
mcSearchTimer = setInterval(pollForMinecraftWindow, 1000);
|
||
pollForMinecraftWindow();
|
||
}
|
||
|
||
async function pollForMinecraftWindow() {
|
||
const sources = await window.api.recordingListWindows(); // schon auf "Minecraft" im Titel gefiltert
|
||
if (sources.length === 1) {
|
||
clearInterval(mcSearchTimer);
|
||
await selectRecordingSource(sources[0]);
|
||
} else if (sources.length > 1) {
|
||
clearInterval(mcSearchTimer);
|
||
showRecordingPicker(sources);
|
||
} else {
|
||
mcSearchElapsed += 1000;
|
||
if (mcSearchElapsed >= 30000) {
|
||
clearInterval(mcSearchTimer);
|
||
el('record-search-text').textContent = 'Kein Spielfenster gefunden. Läuft Minecraft und ist es nicht minimiert?';
|
||
el('record-search-retry').classList.remove('hidden');
|
||
}
|
||
}
|
||
}
|
||
|
||
function showRecordingPicker(sources) {
|
||
hide('record-search');
|
||
const grid = el('record-picker');
|
||
grid.innerHTML = '';
|
||
show('record-picker');
|
||
for (const s of sources) {
|
||
const thumb = document.createElement('div');
|
||
thumb.className = 'screen-thumb';
|
||
thumb.innerHTML = `<img alt="" /><div class="screen-name">${escapeHtml(s.name)}</div>`;
|
||
if (s.thumbnail) thumb.querySelector('img').src = s.thumbnail;
|
||
thumb.addEventListener('click', () => selectRecordingSource(s));
|
||
grid.appendChild(thumb);
|
||
}
|
||
}
|
||
|
||
async function selectRecordingSource(source) {
|
||
hide('record-search');
|
||
hide('record-picker');
|
||
show('record-stage');
|
||
try {
|
||
mcCurrentSource = source;
|
||
mcVideoStream = await erfasseFensterVideo(source);
|
||
merkeTatsaechlicheGroesse(mcVideoStream);
|
||
el('record-preview').srcObject = mcVideoStream;
|
||
await Promise.all([populateMicList(), checkGameAudioAvailability()]);
|
||
updateAudioSummary();
|
||
updateVideoSummary();
|
||
} catch (err) {
|
||
hide('record-stage');
|
||
show('record-search');
|
||
el('record-search-text').textContent = 'Vorschau fehlgeschlagen: ' + err.message;
|
||
el('record-search-retry').classList.remove('hidden');
|
||
}
|
||
}
|
||
|
||
// Löst die gewählte Auflösung (z. B. "1080") in Breite/Höhe auf - "native"
|
||
// lässt das Fenster unverändert
|
||
function aufloesungsConstraints() {
|
||
return RESOLUTIONEN[mcResolution] || null;
|
||
}
|
||
|
||
// Ein frisch entdecktes Fenster liefert beim allerersten Erfassungsversuch in
|
||
// diesem Fenster manchmal ein rein schwarzes Bild (GPU-/Compositor-
|
||
// Aufwärmproblem beim ersten Zugriff) - bisher half nur ein manuelles
|
||
// Schließen+Neuöffnen der Aufzeichnung. Hier stattdessen automatisch geprüft
|
||
// und bei Bedarf neu erfasst, bevor die Vorschau/Aufnahme das schwarze Bild
|
||
// überhaupt zu sehen bekommt.
|
||
//
|
||
// Fenster-Erfassung (WGC, Windows Graphics Capture) unterstützt auf diesem
|
||
// System kein Ausblenden des Mauszeigers, ganz gleich welche Video-Constraint
|
||
// man übergibt - Bildschirm-Erfassung dagegen schon (Standardweg für
|
||
// cursorfreies Bildschirmteilen, z. B. bei Zoom/Teams). Deshalb: ganzer
|
||
// Bildschirm ohne Mauszeiger erfassen (recording:getWindowBounds liefert
|
||
// Fenstergrenzen + passende Bildschirmquelle über node-window-manager), dann
|
||
// per Canvas exakt auf Minecrafts Fensterbereich zuschneiden (und bei Bedarf
|
||
// gleich auf die gewünschte Auflösung skalieren, siehe erzeugeSkaliertenStream).
|
||
// Nur wenn das (Fenster nicht gefunden, oder Bild bleibt trotzdem schwarz)
|
||
// fehlschlägt, Rückfall auf die alte Fenster-Erfassung (Mauszeiger sichtbar,
|
||
// aber garantiert stabiles Bild).
|
||
async function erfasseFensterVideo(source) {
|
||
const grenzen = aufloesungsConstraints();
|
||
const ergebnis = await erfasseBildschirmMitZuschnitt(grenzen);
|
||
if (ergebnis) { mcCursorAusgeblendet = true; return ergebnis; }
|
||
mcCursorAusgeblendet = false;
|
||
mcRohStream = null;
|
||
return erfasseFensterVideoMitCursor(source, grenzen);
|
||
}
|
||
|
||
async function erfasseBildschirmMitZuschnitt(grenzen) {
|
||
const info = await window.api.recordingGetWindowBounds(currentDetailId, mcCurrentSource && mcCurrentSource.name);
|
||
if (!info) return null; // Fenster nicht gefunden - Rückfall auf Fenster-Erfassung
|
||
|
||
const MAX_VERSUCHE = 2;
|
||
for (let versuch = 1; versuch <= MAX_VERSUCHE; versuch++) {
|
||
await window.api.recordingSetCaptureTarget(info.screenSourceId);
|
||
const stream = await navigator.mediaDevices.getDisplayMedia({
|
||
audio: false,
|
||
video: { cursor: 'never', frameRate: { ideal: mcFramerate, max: mcFramerate } },
|
||
});
|
||
if (await zeigtBild(stream)) {
|
||
mcRohStream = stream;
|
||
mcFensterBounds = info.fensterBounds;
|
||
mcBildschirmBounds = info.bildschirmBounds;
|
||
starteZuschnittNachfuehrung();
|
||
const zielBreite = grenzen ? grenzen.width : Math.round(info.fensterBounds.width);
|
||
const zielHoehe = grenzen ? grenzen.height : Math.round(info.fensterBounds.height);
|
||
return erzeugeSkaliertenStream(stream, zielBreite, zielHoehe, mcFramerate);
|
||
}
|
||
stream.getTracks().forEach((t) => t.stop());
|
||
await new Promise((r) => setTimeout(r, 400));
|
||
}
|
||
return null; // dauerhaft schwarz auf diesem System - Rückfall unten
|
||
}
|
||
|
||
// Fragt alle 500ms erneut die Fenstergrenzen ab, damit der Zuschnitt
|
||
// nachgeführt wird, wenn Minecraft verschoben oder in der Größe verändert
|
||
// wird. Verschwindet das Fenster kurz (z. B. minimiert), bleiben die zuletzt
|
||
// bekannten Grenzen stehen, statt den Zuschnitt zu verwerfen.
|
||
function starteZuschnittNachfuehrung() {
|
||
if (mcZuschnittPoller) clearInterval(mcZuschnittPoller);
|
||
mcZuschnittPoller = setInterval(async () => {
|
||
const info = await window.api.recordingGetWindowBounds(currentDetailId, mcCurrentSource && mcCurrentSource.name);
|
||
if (info) { mcFensterBounds = info.fensterBounds; mcBildschirmBounds = info.bildschirmBounds; }
|
||
}, 500);
|
||
}
|
||
|
||
// Schneidet einen Bildschirm-Videostream per <video>+<canvas> auf Minecrafts
|
||
// Fensterbereich zu und skaliert gleichzeitig auf die Zielgröße. Das
|
||
// Zuschnitt-Rechteck wird aus den (laufend nachgeführten) Fenster-/
|
||
// Bildschirmgrenzen empirisch anhand der TATSÄCHLICH erfassten Pixelgröße
|
||
// berechnet, statt einen DPI-Skalierungsfaktor zu erraten - das funktioniert
|
||
// unabhängig davon, ob node-window-manager logische oder physische
|
||
// Pixelkoordinaten liefert. Läuft als eigene Zeichenschleife, solange
|
||
// mcCanvasRAF nicht über stoppeVideoErfassung() beendet wird.
|
||
function erzeugeSkaliertenStream(quellStream, zielBreite, zielHoehe, fps) {
|
||
const video = document.createElement('video');
|
||
video.muted = true;
|
||
video.srcObject = quellStream;
|
||
video.play().catch(() => {});
|
||
mcCanvasVideoEl = video;
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = zielBreite;
|
||
canvas.height = zielHoehe;
|
||
const ctx = canvas.getContext('2d');
|
||
// Windows meldet über GetWindowRect (node-window-manager) auf jeder Seite
|
||
// ca. 8 logische Pixel mehr, als vom Fenster tatsächlich sichtbar ist (der
|
||
// unsichtbare Rahmen für die Größenänderung per Maus) - sonst wäre am Rand
|
||
// ein dünner Streifen Desktop/Nachbarfenster im Zuschnitt zu sehen.
|
||
const UNSICHTBARER_RAND = 8;
|
||
|
||
// captureStream(fps) allein reicht nicht: requestAnimationFrame läuft mit
|
||
// der Bildwiederholrate des Monitors (z. B. 144Hz), nicht mit der
|
||
// gewünschten Aufnahme-Bildrate - ungebremst wurden dadurch weit mehr
|
||
// Bilder gezeichnet/geliefert als angefragt (führte zu einer kaputten
|
||
// Zeitachse im aufgenommenen Video und sichtbarem Ruckeln). Deshalb
|
||
// captureStream(0) (kein automatisches Intervall) und Bilder nur exakt im
|
||
// Takt der Ziel-Bildrate selbst per requestFrame() ausliefern.
|
||
const canvasStream = canvas.captureStream(0);
|
||
const spurAusgabe = canvasStream.getVideoTracks()[0];
|
||
const intervallMs = 1000 / fps;
|
||
let letzteZeichnung = 0;
|
||
|
||
const zeichnen = (jetzt) => {
|
||
if (!letzteZeichnung || jetzt - letzteZeichnung >= intervallMs) {
|
||
letzteZeichnung = jetzt;
|
||
if (video.readyState >= 2) {
|
||
if (mcFensterBounds && mcBildschirmBounds && video.videoWidth && mcBildschirmBounds.width) {
|
||
const skalX = video.videoWidth / mcBildschirmBounds.width;
|
||
const skalY = video.videoHeight / mcBildschirmBounds.height;
|
||
const randX = UNSICHTBARER_RAND * skalX;
|
||
const randY = UNSICHTBARER_RAND * skalY;
|
||
const sx = (mcFensterBounds.x - mcBildschirmBounds.x) * skalX + randX;
|
||
const sy = (mcFensterBounds.y - mcBildschirmBounds.y) * skalY + randY;
|
||
const sw = Math.max(1, mcFensterBounds.width * skalX - randX * 2);
|
||
const sh = Math.max(1, mcFensterBounds.height * skalY - randY * 2);
|
||
ctx.drawImage(video, sx, sy, sw, sh, 0, 0, zielBreite, zielHoehe);
|
||
} else {
|
||
ctx.drawImage(video, 0, 0, zielBreite, zielHoehe); // Übergang, bis Fenstergrenzen bekannt sind
|
||
}
|
||
spurAusgabe.requestFrame();
|
||
}
|
||
}
|
||
mcCanvasRAF = requestAnimationFrame(zeichnen);
|
||
};
|
||
mcCanvasRAF = requestAnimationFrame(zeichnen);
|
||
return canvasStream;
|
||
}
|
||
|
||
async function erfasseFensterVideoMitCursor(source, grenzen) {
|
||
const MAX_VERSUCHE = 4;
|
||
let stream = null;
|
||
for (let versuch = 1; versuch <= MAX_VERSUCHE; versuch++) {
|
||
stream = await navigator.mediaDevices.getUserMedia({
|
||
audio: false,
|
||
video: {
|
||
mandatory: Object.assign(
|
||
{ chromeMediaSource: 'desktop', chromeMediaSourceId: source.id, maxFrameRate: mcFramerate },
|
||
grenzen ? { maxWidth: grenzen.width, maxHeight: grenzen.height } : {},
|
||
),
|
||
},
|
||
});
|
||
if (versuch === MAX_VERSUCHE || await zeigtBild(stream)) return stream;
|
||
stream.getTracks().forEach((t) => t.stop());
|
||
await new Promise((r) => setTimeout(r, 400));
|
||
}
|
||
return stream;
|
||
}
|
||
|
||
// Tatsächlich erfasste Pixelgröße merken (kann von der gewählten Voreinstellung
|
||
// abweichen, z. B. bei "Nativ" oder wenn Windows/Chromium abweichend skaliert)
|
||
// - wird in der Zusammenfassung angezeigt, damit man vorher weiß, wie groß das
|
||
// Video wirklich wird
|
||
function merkeTatsaechlicheGroesse(stream) {
|
||
const settings = stream.getVideoTracks()[0]?.getSettings() || {};
|
||
mcActualSize = settings.width && settings.height ? { width: settings.width, height: settings.height } : null;
|
||
}
|
||
|
||
// Auflösung oder Bildrate wurde geändert, während schon eine Vorschau läuft -
|
||
// Bild mit den neuen Grenzwerten neu erfassen (nur möglich, solange nicht
|
||
// aufgenommen wird, siehe Sperre der Auswahlfelder in updateVideoSummary)
|
||
async function wendeVideoEinstellungenAn() {
|
||
if (!mcCurrentSource || !mcVideoStream || recordingActive()) return;
|
||
stoppeVideoErfassung();
|
||
mcVideoStream = await erfasseFensterVideo(mcCurrentSource);
|
||
merkeTatsaechlicheGroesse(mcVideoStream);
|
||
el('record-preview').srcObject = mcVideoStream;
|
||
updateVideoSummary();
|
||
}
|
||
|
||
function updateVideoSummary() {
|
||
const groesse = mcActualSize ? ` (${mcActualSize.width}×${mcActualSize.height})` : '';
|
||
el('record-video-summary').textContent =
|
||
RESOLUTION_LABEL[mcResolution] + groesse + ' · ' + QUALITAET_LABEL[mcQuality] + ' · ' + mcFramerate + ' fps';
|
||
const aktiv = recordingActive();
|
||
el('record-resolution').disabled = aktiv;
|
||
el('record-quality').disabled = aktiv;
|
||
el('record-fps').disabled = aktiv;
|
||
el('record-video-hint').classList.toggle('hidden', !aktiv);
|
||
}
|
||
|
||
async function zeigtBild(stream) {
|
||
const track = stream.getVideoTracks()[0];
|
||
if (!track || typeof ImageCapture === 'undefined') return true;
|
||
try {
|
||
const bitmap = await new ImageCapture(track).grabFrame();
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = Math.min(bitmap.width, 48) || 1;
|
||
canvas.height = Math.min(bitmap.height, 48) || 1;
|
||
const ctx = canvas.getContext('2d');
|
||
ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
|
||
const { data } = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||
for (let i = 0; i < data.length; i += 4) {
|
||
if (data[i] > 10 || data[i + 1] > 10 || data[i + 2] > 10) return true;
|
||
}
|
||
return false;
|
||
} catch {
|
||
return true; // im Zweifel nicht künstlich verzögern
|
||
}
|
||
}
|
||
|
||
// Mikrofonliste im Aufzeichnungs-Fenster füllen (Labels sind erst nach einer
|
||
// erteilten Freigabe sichtbar -> notfalls einmal kurz "entsperren")
|
||
async function populateMicList() {
|
||
const box = el('record-mic-list');
|
||
box.innerHTML = '<span class="hint">Suche Mikrofone …</span>';
|
||
try {
|
||
let devices = await navigator.mediaDevices.enumerateDevices();
|
||
let mics = devices.filter((d) => d.kind === 'audioinput');
|
||
if (mics.length && mics.every((m) => !m.label)) {
|
||
try {
|
||
const tmp = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||
tmp.getTracks().forEach((t) => t.stop());
|
||
devices = await navigator.mediaDevices.enumerateDevices();
|
||
mics = devices.filter((d) => d.kind === 'audioinput');
|
||
} catch { /* keine Freigabe -> Liste bleibt ggf. ohne Namen */ }
|
||
}
|
||
box.innerHTML = '';
|
||
if (!mics.length) { box.innerHTML = '<span class="hint">Keine Mikrofone gefunden.</span>'; return; }
|
||
mics.forEach((m, i) => {
|
||
// Windows hängt an manche Mikrofon-Bezeichnungen die USB-Hersteller/
|
||
// Produkt-ID an (z. B. "(9886:0038)") - rein technisch, verwirrt hier nur
|
||
const label = (m.label || ('Mikrofon ' + (i + 1))).replace(/\s*\([0-9a-fA-F]{4}:[0-9a-fA-F]{4}\)\s*$/, '');
|
||
const row = document.createElement('div');
|
||
row.className = 'record-source-row';
|
||
const checked = mcMicSelectedIds.includes(m.deviceId) ? 'checked' : '';
|
||
row.innerHTML = `<label class="checkbox-field">
|
||
<input type="checkbox" class="record-mic-checkbox" value="${escapeHtml(m.deviceId)}" ${checked} /> <span>${escapeHtml(label)}</span>
|
||
</label>
|
||
<input type="range" class="record-vol record-mic-vol" min="0" max="100" value="${Math.round((mcVolMics.get(m.deviceId) ?? 1) * 100)}" data-device="${escapeHtml(m.deviceId)}" title="Lautstärke" />`;
|
||
box.appendChild(row);
|
||
});
|
||
} catch {
|
||
box.innerHTML = '<span class="hint">Mikrofone nicht verfügbar.</span>';
|
||
}
|
||
}
|
||
|
||
// Prüft, ob für die aktuelle Instanz eine PID bekannt ist (Minecraft läuft) -
|
||
// nur dann ist "Nur Minecraft (isoliert)" anwählbar
|
||
async function checkGameAudioAvailability() {
|
||
const pid = await window.api.recordingGetPid(currentDetailId);
|
||
const cb = el('record-src-game');
|
||
cb.disabled = !pid;
|
||
if (!pid) cb.checked = false;
|
||
el('record-src-game-hint').classList.toggle('hidden', !!pid);
|
||
}
|
||
|
||
// kurze Zusammenfassung der gewählten Audioquellen für die Leiste im
|
||
// Hauptbereich (die eigentliche Auswahl passiert im separaten Dialog)
|
||
function updateAudioSummary() {
|
||
const teile = [];
|
||
if (el('record-src-system').checked) teile.push('Systemton');
|
||
if (el('record-src-game').checked) teile.push('Nur Minecraft');
|
||
const micCount = document.querySelectorAll('.record-mic-checkbox:checked').length;
|
||
if (micCount) teile.push(micCount === 1 ? '1 Mikrofon' : micCount + ' Mikrofone');
|
||
el('record-audio-summary').textContent = teile.length ? teile.join(' + ') : 'keine Auswahl';
|
||
}
|
||
|
||
// Rohes PCM (16-Bit interleaved, Stereo) von loopback-capture in getrennte
|
||
// Float32-Kanäle für den AudioWorklet umrechnen
|
||
function pcm16ToFloat32Stereo(bytes) {
|
||
const int16 = new Int16Array(bytes.buffer, bytes.byteOffset, bytes.byteLength / 2);
|
||
const frames = int16.length / 2;
|
||
const left = new Float32Array(frames);
|
||
const right = new Float32Array(frames);
|
||
for (let i = 0; i < frames; i++) {
|
||
left[i] = int16[i * 2] / 32768;
|
||
right[i] = int16[i * 2 + 1] / 32768;
|
||
}
|
||
return [left, right];
|
||
}
|
||
|
||
let mcRecorder = null;
|
||
let mcChunks = [];
|
||
let mcAudioTrack = null;
|
||
let mcAudioContext = null;
|
||
let mcAudioSourceStreams = [];
|
||
let mcGameAudioNode = null;
|
||
let mcGameAudioUnsub = null;
|
||
let mcTimerInterval = null;
|
||
let mcStartedAt = 0;
|
||
let mcLastSavedInstanceName = null;
|
||
|
||
// Lautstärke je Quelle (0-1), bleibt auch über Neuaufbau des Mischers hinweg
|
||
// erhalten (z. B. wenn vor der Aufnahme schon am Regler gezogen wurde)
|
||
let mcVolSystem = 1;
|
||
let mcVolGame = 1;
|
||
const mcVolMics = new Map(); // deviceId -> 0-1
|
||
|
||
// GainNode je aktiver Quelle, nur während des Mischers gesetzt - erlaubt
|
||
// live Lautstärkeänderung auch WÄHREND einer laufenden Aufnahme
|
||
let mcGainSystem = null;
|
||
let mcGainGame = null;
|
||
const mcGainMics = new Map(); // deviceId -> GainNode
|
||
|
||
function recordingActive() {
|
||
return !!(mcRecorder && mcRecorder.state === 'recording');
|
||
}
|
||
|
||
// Mischt alle angehakten Audioquellen (Systemton + gewählte Mikrofone) zu
|
||
// EINER Tonspur zusammen (MediaRecorder würde sonst nur die erste Rohspur
|
||
// verwenden statt mehrere zu mischen).
|
||
async function buildCombinedAudioTrack() {
|
||
const wantSystem = el('record-src-system').checked;
|
||
const wantGame = el('record-src-game').checked;
|
||
const micDeviceIds = [...document.querySelectorAll('.record-mic-checkbox:checked')].map((c) => c.value);
|
||
if (!wantSystem && !wantGame && !micDeviceIds.length) return null;
|
||
|
||
// fest auf 48kHz, weil loopback-capture (für "Nur Minecraft") genau das
|
||
// liefert -- Mikrofon/Systemton werden vom Browser automatisch dorthin umgerechnet
|
||
mcAudioContext = new AudioContext({ sampleRate: 48000 });
|
||
const destination = mcAudioContext.createMediaStreamDestination();
|
||
mcAudioSourceStreams = [];
|
||
|
||
if (wantGame) {
|
||
try {
|
||
const pid = await window.api.recordingGetPid(currentDetailId);
|
||
if (!pid) throw new Error('kein Minecraft-Prozess bekannt');
|
||
await mcAudioContext.audioWorklet.addModule('pcm-worklet.js');
|
||
const node = new AudioWorkletNode(mcAudioContext, 'pcm-player', {
|
||
numberOfInputs: 0,
|
||
numberOfOutputs: 1,
|
||
outputChannelCount: [2],
|
||
});
|
||
const startRes = await window.api.recordingStartGameAudio(pid);
|
||
if (!startRes.ok) throw new Error(startRes.message);
|
||
const gain = mcAudioContext.createGain();
|
||
gain.gain.value = mcVolGame;
|
||
node.connect(gain).connect(destination);
|
||
mcGainGame = gain;
|
||
mcGameAudioUnsub = window.api.onGameAudioChunk((chunk) => {
|
||
const [l, r] = pcm16ToFloat32Stereo(new Uint8Array(chunk));
|
||
node.port.postMessage([l, r], [l.buffer, r.buffer]);
|
||
});
|
||
mcGameAudioNode = node;
|
||
} catch (err) {
|
||
toast('Spielton (isoliert) nicht verfügbar: ' + err.message, true);
|
||
}
|
||
}
|
||
|
||
if (wantSystem) {
|
||
try {
|
||
const audioSourceId = await window.api.recordingGetAudioSourceId();
|
||
if (audioSourceId) {
|
||
const sysStream = await navigator.mediaDevices.getUserMedia({
|
||
video: { mandatory: { chromeMediaSource: 'desktop', chromeMediaSourceId: audioSourceId } }, // Pflichtfeld, wird verworfen
|
||
audio: { mandatory: { chromeMediaSource: 'desktop' } },
|
||
});
|
||
sysStream.getVideoTracks().forEach((t) => t.stop());
|
||
mcAudioSourceStreams.push(sysStream);
|
||
const gain = mcAudioContext.createGain();
|
||
gain.gain.value = mcVolSystem;
|
||
mcAudioContext.createMediaStreamSource(sysStream).connect(gain).connect(destination);
|
||
mcGainSystem = gain;
|
||
}
|
||
} catch {
|
||
toast('Systemton nicht verfügbar.', true);
|
||
}
|
||
}
|
||
|
||
for (const deviceId of micDeviceIds) {
|
||
try {
|
||
const micStream = await navigator.mediaDevices.getUserMedia({ audio: { deviceId: { exact: deviceId } } });
|
||
mcAudioSourceStreams.push(micStream);
|
||
const gain = mcAudioContext.createGain();
|
||
gain.gain.value = mcVolMics.get(deviceId) ?? 1;
|
||
mcAudioContext.createMediaStreamSource(micStream).connect(gain).connect(destination);
|
||
mcGainMics.set(deviceId, gain);
|
||
} catch {
|
||
toast('Ein Mikrofon war nicht verfügbar.', true);
|
||
}
|
||
}
|
||
|
||
return destination.stream.getAudioTracks()[0] || null;
|
||
}
|
||
|
||
function cleanupAudioMix() {
|
||
mcAudioSourceStreams.forEach((s) => s.getTracks().forEach((t) => t.stop()));
|
||
mcAudioSourceStreams = [];
|
||
if (mcGameAudioUnsub) { mcGameAudioUnsub(); mcGameAudioUnsub = null; }
|
||
if (mcGameAudioNode) { window.api.recordingStopGameAudio(); mcGameAudioNode.disconnect(); mcGameAudioNode = null; }
|
||
if (mcAudioContext) { mcAudioContext.close(); mcAudioContext = null; }
|
||
mcGainSystem = null;
|
||
mcGainGame = null;
|
||
mcGainMics.clear();
|
||
}
|
||
|
||
async function startMcRecording() {
|
||
if (!mcVideoStream) return;
|
||
mcAudioTrack = await buildCombinedAudioTrack();
|
||
|
||
const combined = new MediaStream([
|
||
...mcVideoStream.getVideoTracks(),
|
||
...(mcAudioTrack ? [mcAudioTrack] : []),
|
||
]);
|
||
|
||
const mime = MediaRecorder.isTypeSupported('video/webm;codecs=vp9,opus')
|
||
? 'video/webm;codecs=vp9,opus' : 'video/webm;codecs=vp8,opus';
|
||
mcChunks = [];
|
||
mcRecorder = new MediaRecorder(combined, { mimeType: mime, videoBitsPerSecond: QUALITAETEN[mcQuality] });
|
||
mcRecorder.ondataavailable = (e) => { if (e.data && e.data.size) mcChunks.push(e.data); };
|
||
mcRecorder.onstop = onMcRecordingStopped;
|
||
mcRecorder.start(1000);
|
||
|
||
window.api.recordingSetGuard(true);
|
||
mcStartedAt = Date.now();
|
||
el('record-live-badge').classList.remove('hidden');
|
||
el('record-toggle').textContent = 'Aufnahme stoppen';
|
||
updateVideoSummary();
|
||
clearInterval(mcTimerInterval);
|
||
mcTimerInterval = setInterval(updateMcTimer, 500);
|
||
}
|
||
|
||
async function stopMcRecording() {
|
||
if (!mcRecorder || mcRecorder.state !== 'recording') return;
|
||
await new Promise((resolve) => { mcRecorder.addEventListener('stop', resolve, { once: true }); mcRecorder.stop(); });
|
||
}
|
||
|
||
async function onMcRecordingStopped() {
|
||
clearInterval(mcTimerInterval);
|
||
window.api.recordingSetGuard(false);
|
||
el('record-live-badge').classList.add('hidden');
|
||
el('record-timer').textContent = '00:00';
|
||
updateVideoSummary();
|
||
cleanupAudioMix();
|
||
mcAudioTrack = null;
|
||
|
||
const blob = new Blob(mcChunks, { type: 'video/webm' });
|
||
mcChunks = [];
|
||
const buffer = await blob.arrayBuffer();
|
||
const inst = await window.api.getInstance(currentDetailId);
|
||
mcLastSavedInstanceName = inst ? inst.name : 'Instanz';
|
||
|
||
// Die Umwandlung nach MP4 (für Abspielbarkeit in jedem Player) läuft nicht
|
||
// sofort durch - kurz sichtbar machen, damit der Knopf währenddessen nicht
|
||
// wie eingefroren wirkt
|
||
const knopf = el('record-toggle');
|
||
knopf.disabled = true;
|
||
knopf.textContent = 'Wird verarbeitet…';
|
||
const res = await window.api.recordingSave(mcLastSavedInstanceName, buffer, await aktuellerAufnahmeServer(), mcFramerate);
|
||
knopf.disabled = false;
|
||
knopf.textContent = 'Aufnahme starten';
|
||
if (res.ok) {
|
||
toast('Aufzeichnung gespeichert.');
|
||
} else {
|
||
toast(res.message || 'Speichern fehlgeschlagen.', true);
|
||
}
|
||
mcRecorder = null;
|
||
}
|
||
|
||
function updateMcTimer() {
|
||
const s = Math.floor((Date.now() - mcStartedAt) / 1000);
|
||
el('record-timer').textContent = String(Math.floor(s / 60)).padStart(2, '0') + ':' + String(s % 60).padStart(2, '0');
|
||
}
|
||
|
||
// Schutz vor versehentlichem Schließen bei laufender Aufnahme (✕, Klick auf
|
||
// Hintergrund, Escape UND natives Schließen wie Alt+F4 laufen alle hierüber)
|
||
function confirmStopAndCloseRecording() {
|
||
askConfirm(
|
||
'Aufzeichnung läuft',
|
||
'Die Aufzeichnung läuft noch. Jetzt stoppen, speichern und das Fenster schließen?',
|
||
'Stoppen & Speichern',
|
||
async () => {
|
||
await stopMcRecording();
|
||
el('modal-record').classList.add('hidden'); // MutationObserver ruft danach popoutHide() auf
|
||
},
|
||
);
|
||
// Abbrechen/✕ am Bestätigungsdialog selbst lässt Fenster + Aufnahme unangetastet
|
||
}
|
||
|
||
async function openScreens() {
|
||
if (POPOUT !== 'screens') { window.api.popoutOpen('screens', { instanceId: currentDetailId }); return; }
|
||
const inst = await window.api.getInstance(currentDetailId);
|
||
el('screens-title').textContent = 'Screenshots – ' + inst.name;
|
||
const grid = el('screens-grid');
|
||
grid.innerHTML = '<div class="screens-empty">Lade …</div>';
|
||
screenSelected = new Set();
|
||
updateScreensSelectionUi();
|
||
recordsGeladen = false;
|
||
switchScreensTab('photos');
|
||
show('modal-screens');
|
||
|
||
const shots = await window.api.screenshots(currentDetailId);
|
||
grid.innerHTML = '';
|
||
if (!shots.length) {
|
||
grid.innerHTML = '<div class="screens-empty">Keine Screenshots vorhanden.</div>';
|
||
updateScreensSelectionUi();
|
||
return;
|
||
}
|
||
for (const s of shots) {
|
||
const thumb = document.createElement('div');
|
||
thumb.className = 'screen-thumb';
|
||
thumb.dataset.name = s.name;
|
||
thumb.innerHTML =
|
||
`<input type="checkbox" class="screen-check" title="Auswählen" />
|
||
<img alt="" /><div class="screen-name">${escapeHtml(s.name)}</div>
|
||
<button class="screen-del" title="Löschen">🗑</button>`;
|
||
const img = thumb.querySelector('img');
|
||
window.api.screenshotData(currentDetailId, s.name).then((d) => { if (d) img.src = d; });
|
||
|
||
const toggle = () => {
|
||
if (screenSelected.has(s.name)) screenSelected.delete(s.name);
|
||
else screenSelected.add(s.name);
|
||
updateScreensSelectionUi();
|
||
};
|
||
thumb.querySelector('.screen-check').addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
toggle();
|
||
});
|
||
thumb.addEventListener('click', (e) => {
|
||
if (e.target.closest('.screen-del') || e.target.closest('.screen-check')) return;
|
||
toggle();
|
||
});
|
||
img.addEventListener('dblclick', (e) => {
|
||
e.stopPropagation();
|
||
window.api.openScreenshot(currentDetailId, s.name);
|
||
});
|
||
thumb.querySelector('.screen-del').addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
askConfirm('Screenshot löschen', `„${s.name}" löschen?`, '🗑 Löschen', async () => {
|
||
await window.api.deleteContent(currentDetailId, 'screenshots', s.name);
|
||
openScreens();
|
||
});
|
||
});
|
||
grid.appendChild(thumb);
|
||
}
|
||
updateScreensSelectionUi();
|
||
}
|
||
|
||
async function deleteSelectedScreenshots() {
|
||
const names = [...screenSelected];
|
||
if (!names.length || !currentDetailId) return;
|
||
const label = names.length === 1
|
||
? `„${names[0]}" löschen?`
|
||
: `${names.length} Screenshots unwiderruflich löschen?`;
|
||
askConfirm('Screenshots löschen', label, '🗑 Löschen', async () => {
|
||
for (const name of names) {
|
||
await window.api.deleteContent(currentDetailId, 'screenshots', name);
|
||
}
|
||
toast(names.length === 1 ? 'Screenshot gelöscht.' : names.length + ' Screenshots gelöscht.');
|
||
openScreens();
|
||
});
|
||
}
|
||
|
||
// ---------- Aufzeichnungen-Galerie (im selben Dialog, zweiter Tab) ----------
|
||
let screensAktiverTab = 'photos';
|
||
let recordSelected = new Set();
|
||
let recordsGeladen = false; // nur beim ersten Wechsel auf den Tab laden, nicht bei jedem Öffnen
|
||
|
||
function switchScreensTab(which) {
|
||
screensAktiverTab = which;
|
||
const photos = which === 'photos';
|
||
el('screens-tab-photos').classList.toggle('on', photos);
|
||
el('screens-tab-records').classList.toggle('on', !photos);
|
||
el('screens-pane-photos').classList.toggle('hidden', !photos);
|
||
el('screens-pane-records').classList.toggle('hidden', photos);
|
||
if (!photos && !recordsGeladen) { recordsGeladen = true; openRecordsList(); }
|
||
}
|
||
|
||
function updateRecordsSelectionUi() {
|
||
const n = recordSelected.size;
|
||
const btn = el('records-delete-sel');
|
||
if (btn) {
|
||
btn.disabled = n === 0;
|
||
btn.textContent = n ? (n + ' löschen') : 'Auswahl löschen';
|
||
}
|
||
const hint = el('records-sel-hint');
|
||
if (hint) hint.textContent = n ? (n + ' ausgewählt – Doppelklick spielt ab') : 'Klick auf Vorschaubild zum Auswählen, Doppelklick zum Abspielen';
|
||
document.querySelectorAll('#records-grid .screen-thumb').forEach((t) => {
|
||
const name = t.dataset.name;
|
||
t.classList.toggle('selected', !!(name && recordSelected.has(name)));
|
||
const cb = t.querySelector('.screen-check');
|
||
if (cb) cb.checked = !!(name && recordSelected.has(name));
|
||
});
|
||
}
|
||
|
||
function formatDateigroesse(bytes) {
|
||
if (bytes >= 1024 * 1024 * 1024) return (bytes / (1024 * 1024 * 1024)).toFixed(1) + ' GB';
|
||
if (bytes >= 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||
return Math.round(bytes / 1024) + ' KB';
|
||
}
|
||
|
||
async function openRecordsList() {
|
||
const grid = el('records-grid');
|
||
grid.innerHTML = '<div class="screens-empty">Lade …</div>';
|
||
recordSelected = new Set();
|
||
updateRecordsSelectionUi();
|
||
|
||
const inst = await window.api.getInstance(currentDetailId);
|
||
const instanceName = inst ? inst.name : null;
|
||
if (!instanceName) { grid.innerHTML = '<div class="screens-empty">Keine Instanz ausgewählt.</div>'; return; }
|
||
|
||
const aufnahmen = await window.api.recordingList(instanceName);
|
||
grid.innerHTML = '';
|
||
if (!aufnahmen.length) {
|
||
grid.innerHTML = '<div class="screens-empty">Keine Aufzeichnungen vorhanden.</div>';
|
||
updateRecordsSelectionUi();
|
||
return;
|
||
}
|
||
for (const a of aufnahmen) {
|
||
const thumb = document.createElement('div');
|
||
thumb.className = 'screen-thumb record-thumb';
|
||
thumb.dataset.name = a.name;
|
||
thumb.innerHTML =
|
||
`<input type="checkbox" class="screen-check" title="Auswählen" />
|
||
<div class="record-thumb-img-wrap"><img alt="" /><span class="record-thumb-play">▶</span></div>
|
||
<div class="screen-name" title="${escapeHtml(a.name)}">${escapeHtml(a.name)}</div>
|
||
<div class="record-thumb-size">${formatDateigroesse(a.size)}</div>
|
||
<button class="screen-del" title="Löschen">🗑</button>`;
|
||
const img = thumb.querySelector('img');
|
||
window.api.recordingThumbnail(instanceName, a.name).then((d) => { if (d) img.src = d; });
|
||
|
||
const toggle = () => {
|
||
if (recordSelected.has(a.name)) recordSelected.delete(a.name);
|
||
else recordSelected.add(a.name);
|
||
updateRecordsSelectionUi();
|
||
};
|
||
thumb.querySelector('.screen-check').addEventListener('click', (e) => { e.stopPropagation(); toggle(); });
|
||
thumb.addEventListener('click', (e) => {
|
||
if (e.target.closest('.screen-del') || e.target.closest('.screen-check')) return;
|
||
toggle();
|
||
});
|
||
thumb.addEventListener('dblclick', (e) => {
|
||
if (e.target.closest('.screen-del') || e.target.closest('.screen-check')) return;
|
||
window.api.recordingOpen(instanceName, a.name);
|
||
});
|
||
thumb.querySelector('.screen-del').addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
askConfirm('Aufzeichnung löschen', `„${a.name}" löschen?`, '🗑 Löschen', async () => {
|
||
await window.api.recordingDelete(instanceName, a.name);
|
||
openRecordsList();
|
||
});
|
||
});
|
||
grid.appendChild(thumb);
|
||
}
|
||
updateRecordsSelectionUi();
|
||
}
|
||
|
||
async function deleteSelectedRecords() {
|
||
const names = [...recordSelected];
|
||
if (!names.length || !currentDetailId) return;
|
||
const inst = await window.api.getInstance(currentDetailId);
|
||
const instanceName = inst ? inst.name : null;
|
||
if (!instanceName) return;
|
||
const label = names.length === 1
|
||
? `„${names[0]}" löschen?`
|
||
: `${names.length} Aufzeichnungen unwiderruflich löschen?`;
|
||
askConfirm('Aufzeichnungen löschen', label, '🗑 Löschen', async () => {
|
||
for (const name of names) {
|
||
await window.api.recordingDelete(instanceName, name);
|
||
}
|
||
toast(names.length === 1 ? 'Aufzeichnung gelöscht.' : names.length + ' Aufzeichnungen gelöscht.');
|
||
openRecordsList();
|
||
});
|
||
}
|
||
|
||
// ---------- Freunde ----------
|
||
let friendsAutoRefresh = null;
|
||
|
||
async function openFriends() {
|
||
if (POPOUT !== 'friends') { window.api.popoutOpen('friends'); return; }
|
||
show('modal-friends');
|
||
await ladeFreunde();
|
||
clearInterval(friendsAutoRefresh);
|
||
friendsAutoRefresh = setInterval(ladeFreunde, 30000);
|
||
}
|
||
|
||
async function ladeFreunde() {
|
||
const aktiv = await window.api.friendsEnabled();
|
||
el('friends-disabled-hint').classList.toggle('hidden', aktiv);
|
||
el('friends-content').classList.toggle('hidden', !aktiv);
|
||
if (!aktiv) return;
|
||
|
||
const [anfragenRes, gesendetRes, freundeRes, einstellungen] = await Promise.all([
|
||
window.api.friendsRequests(),
|
||
window.api.friendsSentRequests(),
|
||
window.api.friendsList(),
|
||
window.api.getSettings(),
|
||
]);
|
||
const spitznamen = einstellungen.friendNicknames || {};
|
||
|
||
const reqBox = el('friends-requests-box');
|
||
reqBox.innerHTML = '';
|
||
if (anfragenRes.ok && anfragenRes.list.length) {
|
||
const titel = document.createElement('div');
|
||
titel.className = 'friends-section-title';
|
||
titel.textContent = 'Anfragen';
|
||
reqBox.appendChild(titel);
|
||
for (const r of anfragenRes.list) {
|
||
const row = document.createElement('div');
|
||
row.className = 'friend-row';
|
||
row.innerHTML = `<img class="friend-avatar" src="https://crafthead.net/cube/${escapeHtml(r.uuid)}/64" alt="" />
|
||
<div class="friend-info"><div class="friend-name">${escapeHtml(r.username || r.uuid)}</div></div>
|
||
<button class="btn btn-mini" data-act="accept">Annehmen</button>
|
||
<button class="btn btn-mini btn-danger" data-act="decline">Ablehnen</button>`;
|
||
row.querySelector('.friend-avatar').addEventListener('error', function () { this.remove(); });
|
||
row.querySelector('[data-act="accept"]').addEventListener('click', async () => {
|
||
await window.api.friendsRespond(r.uuid, true);
|
||
ladeFreunde();
|
||
});
|
||
row.querySelector('[data-act="decline"]').addEventListener('click', async () => {
|
||
await window.api.friendsRespond(r.uuid, false);
|
||
ladeFreunde();
|
||
});
|
||
reqBox.appendChild(row);
|
||
}
|
||
}
|
||
if (gesendetRes.ok && gesendetRes.list.length) {
|
||
const titel = document.createElement('div');
|
||
titel.className = 'friends-section-title';
|
||
titel.textContent = 'Gesendet';
|
||
reqBox.appendChild(titel);
|
||
for (const r of gesendetRes.list) {
|
||
const row = document.createElement('div');
|
||
row.className = 'friend-row';
|
||
row.innerHTML = `<img class="friend-avatar" src="https://crafthead.net/cube/${escapeHtml(r.uuid)}/64" alt="" />
|
||
<div class="friend-info"><div class="friend-name">${escapeHtml(r.username || r.uuid)}</div><div class="friend-status">wartet auf Bestätigung</div></div>
|
||
<button class="btn btn-mini btn-danger" data-act="cancel">Zurückziehen</button>`;
|
||
row.querySelector('.friend-avatar').addEventListener('error', function () { this.remove(); });
|
||
row.querySelector('[data-act="cancel"]').addEventListener('click', async () => {
|
||
await window.api.friendsCancel(r.uuid);
|
||
ladeFreunde();
|
||
});
|
||
reqBox.appendChild(row);
|
||
}
|
||
}
|
||
|
||
const listBox = el('friends-list-box');
|
||
listBox.innerHTML = '<div class="friends-section-title">Freunde</div>';
|
||
if (!freundeRes.ok) {
|
||
listBox.innerHTML += `<div class="friends-empty">${escapeHtml(freundeRes.message || 'Nicht erreichbar.')}</div>`;
|
||
return;
|
||
}
|
||
if (!freundeRes.list.length) {
|
||
listBox.innerHTML += '<div class="friends-empty">Noch keine Freunde. Namen oben eingeben, um eine Anfrage zu senden.</div>';
|
||
return;
|
||
}
|
||
const sortiert = [...freundeRes.list].sort((a, b) => {
|
||
if (a.online !== b.online) return a.online ? -1 : 1;
|
||
return (a.username || '').localeCompare(b.username || '');
|
||
});
|
||
for (const f of sortiert) {
|
||
const row = document.createElement('div');
|
||
row.className = 'friend-row';
|
||
const anzeigeName = spitznamen[f.uuid] || f.username || f.uuid;
|
||
const status = f.online
|
||
? (f.instance ? `spielt ${f.instance}${f.server ? ' · ' + f.server : ''}` : 'online')
|
||
: 'offline';
|
||
row.innerHTML = `<span class="friend-dot${f.online ? ' online' : ''}"></span>
|
||
<img class="friend-avatar" src="https://crafthead.net/cube/${escapeHtml(f.uuid)}/64" alt="" />
|
||
<div class="friend-info"><div class="friend-name">${escapeHtml(anzeigeName)}</div><div class="friend-status">${escapeHtml(status)}</div></div>
|
||
<button class="btn btn-mini" data-act="nick" title="Spitzname">✎</button>
|
||
<button class="btn btn-mini btn-danger" data-act="remove" title="Entfernen">🗑</button>`;
|
||
row.querySelector('.friend-avatar').addEventListener('error', function () { this.remove(); });
|
||
row.querySelector('[data-act="nick"]').addEventListener('click', async () => {
|
||
const wert = await askPrompt({
|
||
title: 'Spitzname',
|
||
label: `Eigener Spitzname für ${f.username || f.uuid} (leer lassen zum Entfernen)`,
|
||
value: spitznamen[f.uuid] || '',
|
||
okLabel: 'Speichern',
|
||
});
|
||
if (wert === null) return; // Abgebrochen
|
||
await window.api.friendsSetNickname(f.uuid, wert.trim());
|
||
ladeFreunde();
|
||
});
|
||
row.querySelector('[data-act="remove"]').addEventListener('click', () => {
|
||
askConfirm('Freund entfernen', `„${anzeigeName}" aus der Freundesliste entfernen?`, '🗑 Entfernen', async () => {
|
||
await window.api.friendsRemove(f.uuid);
|
||
ladeFreunde();
|
||
});
|
||
});
|
||
listBox.appendChild(row);
|
||
}
|
||
}
|
||
|
||
async function sendeFreundschaftsanfrage() {
|
||
const input = el('friends-add-input');
|
||
const name = input.value.trim();
|
||
if (!name) return;
|
||
const res = await window.api.friendsAdd(name);
|
||
if (res.ok) {
|
||
toast('Anfrage gesendet.');
|
||
input.value = '';
|
||
ladeFreunde();
|
||
} else {
|
||
toast(res.message || 'Anfrage fehlgeschlagen.', true);
|
||
}
|
||
}
|
||
|
||
function renderContentList(boxId, items, sub, emoji) {
|
||
const box = el(boxId);
|
||
box.innerHTML = '';
|
||
if (!items.length) { box.innerHTML = '<div class="mods-empty">Nichts vorhanden.</div>'; return; }
|
||
for (const it of items) {
|
||
const item = document.createElement('div');
|
||
item.className = 'mod-item';
|
||
item.innerHTML =
|
||
`<div class="mod-icon">${emoji}</div>
|
||
<div class="mod-info"><div class="mod-name">${escapeHtml(it.name)}</div></div>
|
||
<div class="mod-action"></div>`;
|
||
const aktionen = item.querySelector('.mod-action');
|
||
if (sub === 'saves') {
|
||
const sichern = document.createElement('button');
|
||
sichern.className = 'btn btn-mini';
|
||
sichern.textContent = 'Sichern';
|
||
sichern.title = 'Nur diese Welt jetzt als ZIP sichern (Dokumente\\AeroMC Launcher\\Welten-Sicherungen)';
|
||
sichern.addEventListener('click', async () => {
|
||
sichern.disabled = true;
|
||
const orig = sichern.textContent;
|
||
sichern.textContent = 'Sichere …';
|
||
const res = await window.api.backupWorld(currentDetailId, it.name);
|
||
sichern.disabled = false;
|
||
sichern.textContent = orig;
|
||
if (res.ok) toast(`„${it.name}" gesichert.`);
|
||
else toast(res.message || 'Sicherung fehlgeschlagen.', true);
|
||
});
|
||
aktionen.appendChild(sichern);
|
||
}
|
||
const rm = document.createElement('button');
|
||
rm.className = 'btn btn-danger btn-mini';
|
||
rm.textContent = 'Löschen';
|
||
rm.addEventListener('click', () => askConfirm(
|
||
'Löschen', `„${it.name}" wird unwiderruflich gelöscht. Fortfahren?`, '🗑 Löschen',
|
||
async () => { await window.api.deleteContent(currentDetailId, sub, it.name); await renderContentLists(); toast('Gelöscht: ' + it.name); },
|
||
));
|
||
aktionen.appendChild(rm);
|
||
box.appendChild(item);
|
||
}
|
||
}
|
||
|
||
// ---------- Mod-Browser ----------
|
||
|
||
// Welche Inhaltsart wird gerade gezeigt: mod | resourcepack | shader
|
||
let modArt = 'mod';
|
||
const ART_LABEL = {
|
||
mod: 'Mods', resourcepack: 'Ressourcenpakete', shader: 'Shader', datapack: 'Datenpakete',
|
||
};
|
||
const ART_SUCHE = {
|
||
mod: 'Mods durchsuchen … (z. B. Sodium, JEI, Create)',
|
||
resourcepack: 'Ressourcenpakete durchsuchen … (z. B. Faithful, Bare Bones)',
|
||
shader: 'Shader durchsuchen … (z. B. Complementary, BSL)',
|
||
datapack: 'Datenpakete durchsuchen … (z. B. Terralith, Vanilla Tweaks)',
|
||
};
|
||
// Nur Mods brauchen einen Loader – Ressourcenpakete, Shader und Datenpakete laufen auch in Vanilla
|
||
const ART_BRAUCHT_LOADER = {
|
||
mod: true, resourcepack: false, shader: false, datapack: false,
|
||
};
|
||
// CurseForge hat für Datenpakete keinen verlässlich zugeordneten API-Klassencode in
|
||
// diesem Launcher – dort deshalb nur Modrinth als Quelle anbieten, statt falsche
|
||
// Treffer (z. B. Mods) unter "Datenpakete" anzuzeigen.
|
||
const ART_NUR_MODRINTH = { datapack: true };
|
||
|
||
async function openMods() {
|
||
if (POPOUT !== 'mods') { window.api.popoutOpen('mods', { instanceId: currentDetailId }); return; }
|
||
const inst = await window.api.getInstance(currentDetailId);
|
||
if (!inst) return;
|
||
modArt = 'mod';
|
||
el('m-art').value = 'mod';
|
||
await zeigeModAnsicht(inst);
|
||
show('modal-mods');
|
||
}
|
||
|
||
// Wechsel der Inhaltsart: alles neu aufbauen
|
||
async function modArtGewechselt() {
|
||
modArt = el('m-art').value || 'mod';
|
||
const inst = await window.api.getInstance(currentDetailId);
|
||
if (inst) await zeigeModAnsicht(inst);
|
||
}
|
||
|
||
async function zeigeModAnsicht(inst) {
|
||
el('m-title').textContent = ART_LABEL[modArt] + ' – ' + inst.name;
|
||
const loaderTxt = LOADER_LABEL[inst.minecraft.loader] || inst.minecraft.loader;
|
||
el('m-sub').textContent = (inst.minecraft.version || 'keine Version') + ' · ' + loaderTxt;
|
||
el('m-query').placeholder = ART_SUCHE[modArt];
|
||
|
||
const warn = el('m-warn');
|
||
const brauchtLoader = ART_BRAUCHT_LOADER[modArt];
|
||
const blocked = !inst.minecraft.version || (brauchtLoader && inst.minecraft.loader === 'vanilla');
|
||
if (blocked) {
|
||
warn.classList.remove('hidden');
|
||
warn.textContent = !inst.minecraft.version
|
||
? '⚠ Diese Instanz hat keine Minecraft-Version. Bitte zuerst im Bearbeiten-Dialog eine Version wählen.'
|
||
: '⚠ Vanilla-Instanzen unterstützen keine Mods. Wähle beim Bearbeiten einen Mod-Loader (Fabric/Forge/NeoForge/Quilt). Ressourcenpakete und Shader gehen auch ohne.';
|
||
} else {
|
||
warn.classList.add('hidden');
|
||
}
|
||
['m-query', 'm-search-btn', 'm-sort', 'm-category'].forEach((id) => { el(id).disabled = blocked; });
|
||
// Kategorien gelten nur für Mods
|
||
el('m-category').disabled = blocked || modArt !== 'mod';
|
||
// Manche Quellen decken nicht jede Inhaltsart ab (z. B. CurseForge <-> Datenpakete)
|
||
const cfOption = el('m-source').querySelector('option[value="curseforge"]');
|
||
if (cfOption) cfOption.disabled = !!ART_NUR_MODRINTH[modArt];
|
||
if (ART_NUR_MODRINTH[modArt] && el('m-source').value === 'curseforge') el('m-source').value = 'modrinth';
|
||
el('m-query').value = '';
|
||
el('m-more').classList.add('hidden');
|
||
// Ergebnisse der vorigen Ansicht nicht mitschleppen
|
||
modUpdates = new Map();
|
||
setUpdateStatus('');
|
||
el('m-apply-updates').classList.add('hidden');
|
||
el('m-check-updates').disabled = blocked;
|
||
switchModTab('catalog');
|
||
|
||
await refreshInstalled();
|
||
if (blocked) {
|
||
const box = el('m-results');
|
||
box.innerHTML = `<div class="mods-empty">${!inst.minecraft.version
|
||
? 'Ohne Minecraft-Version geht hier nichts.'
|
||
: 'Für Mods braucht diese Instanz einen Mod-Loader.'}</div>`;
|
||
const b = document.createElement('button');
|
||
b.className = 'btn btn-primary';
|
||
b.style.margin = '0 auto';
|
||
b.style.display = 'block';
|
||
b.textContent = inst.minecraft.version ? 'Mod-Loader hinzufügen' : 'Version wählen';
|
||
b.addEventListener('click', async () => {
|
||
hide('modal-mods');
|
||
const i = await window.api.getInstance(currentDetailId);
|
||
if (i) openEdit(i);
|
||
});
|
||
box.appendChild(b);
|
||
} else {
|
||
await loadCatalog(false); // beliebte Einträge sofort anzeigen
|
||
}
|
||
}
|
||
|
||
function switchModTab(which) {
|
||
const cat = which === 'catalog';
|
||
el('m-tab-catalog').classList.toggle('on', cat);
|
||
el('m-tab-installed').classList.toggle('on', !cat);
|
||
el('m-pane-catalog').classList.toggle('hidden', !cat);
|
||
el('m-pane-installed').classList.toggle('hidden', cat);
|
||
}
|
||
|
||
// gefundene Aktualisierungen: projectId -> { title, alt, neu }
|
||
let modUpdates = new Map();
|
||
let installedSort = 'name-asc';
|
||
|
||
function sortInstalled(list) {
|
||
const sorted = [...list];
|
||
if (installedSort === 'name-asc') {
|
||
sorted.sort((a, b) => a.title.localeCompare(b.title, 'de'));
|
||
} else if (installedSort === 'name-desc') {
|
||
sorted.sort((a, b) => b.title.localeCompare(a.title, 'de'));
|
||
} else if (installedSort === 'status') {
|
||
sorted.sort((a, b) => (a.enabled === false ? 1 : 0) - (b.enabled === false ? 1 : 0)
|
||
|| a.title.localeCompare(b.title, 'de'));
|
||
}
|
||
return sorted;
|
||
}
|
||
|
||
async function refreshInstalled() {
|
||
const list = sortInstalled(await window.api.modList(currentDetailId, modArt));
|
||
currentModsInstalled = new Set(list.map((m) => m.projectId));
|
||
el('m-installed-count').textContent = String(list.length);
|
||
const box = el('m-installed');
|
||
box.innerHTML = '';
|
||
if (!list.length) { box.innerHTML = '<div class="mods-empty">Noch keine Mods.</div>'; return; }
|
||
for (const m of list) {
|
||
const up = m.projectId ? modUpdates.get(m.projectId) : null;
|
||
const aus = m.enabled === false;
|
||
const item = document.createElement('div');
|
||
item.className = 'mod-item' + (aus ? ' mod-aus' : '');
|
||
|
||
const kennzeichen = [];
|
||
if (up) kennzeichen.push('<span class="mod-new">Update</span>');
|
||
if (aus) kennzeichen.push('<span class="mod-off">aus</span>');
|
||
if (m.fehlt) kennzeichen.push('<span class="mod-off">Datei fehlt</span>');
|
||
// OptiFine lässt sich nicht über Modrinth/CurseForge installieren (kein API-Zugriff auf
|
||
// deren Downloads) – von Hand hinzugefügt taucht es sonst wie ein beliebiger Mod auf.
|
||
const istOptiFine = modArt === 'mod' && /^optifine[_-]/i.test(m.filename || '');
|
||
if (istOptiFine) kennzeichen.push('<span class="mod-off" style="opacity:.8">OptiFine</span>');
|
||
|
||
item.innerHTML =
|
||
`<div class="mod-icon">${aus ? '🚫' : istOptiFine ? '⚡' : '🧩'}</div>
|
||
<div class="mod-info">
|
||
<div class="mod-name">${escapeHtml(m.title)}${kennzeichen.length ? ' ' + kennzeichen.join(' ') : ''}</div>
|
||
<div class="mod-meta">${up
|
||
? escapeHtml(up.alt.versionNumber) + ' → ' + escapeHtml(up.neu.versionNumber)
|
||
: escapeHtml(m.versionNumber || '') + ' · ' + escapeHtml(m.filename)}</div>
|
||
</div>
|
||
<div class="mod-action"></div>`;
|
||
|
||
const aktionen = item.querySelector('.mod-action');
|
||
|
||
// Ab-/Anschalten: die Datei bleibt liegen, Minecraft übergeht sie nur
|
||
if (!m.fehlt) {
|
||
const schalter = document.createElement('button');
|
||
schalter.className = 'btn btn-mini';
|
||
schalter.textContent = aus ? 'Einschalten' : 'Ausschalten';
|
||
schalter.title = aus
|
||
? 'Datei wieder auf .jar umbenennen'
|
||
: 'Datei auf .jar.disabled umbenennen – Minecraft lädt sie dann nicht';
|
||
schalter.addEventListener('click', async () => {
|
||
schalter.disabled = true;
|
||
const res = await window.api.modSetEnabled(currentDetailId, m.filename, aus, modArt);
|
||
if (res.ok) toast((aus ? 'Eingeschaltet: ' : 'Ausgeschaltet: ') + m.title);
|
||
else toast(res.message || 'Umschalten fehlgeschlagen.', true);
|
||
await refreshInstalled();
|
||
});
|
||
aktionen.appendChild(schalter);
|
||
}
|
||
|
||
const btn = document.createElement('button');
|
||
btn.className = 'btn btn-danger btn-mini';
|
||
btn.textContent = 'Entfernen';
|
||
btn.addEventListener('click', async () => {
|
||
const tatsaechlichEntfernen = async () => {
|
||
await window.api.modRemove(currentDetailId, m.filename, modArt);
|
||
toast('Entfernt: ' + m.title);
|
||
await refreshInstalled();
|
||
};
|
||
const abhaenger = await window.api.modDependents(currentDetailId, m.filename, modArt);
|
||
if (abhaenger.length) {
|
||
const namen = abhaenger.map((n) => `„${n}"`).join(', ');
|
||
askConfirm(
|
||
'Wird noch gebraucht',
|
||
`${namen} ${abhaenger.length === 1 ? 'benötigt' : 'benötigen'} „${m.title}" als Pflicht-Abhängigkeit. `
|
||
+ 'Wird sie trotzdem entfernt, funktionieren diese Mods vermutlich nicht mehr richtig. Trotzdem entfernen?',
|
||
'Trotzdem entfernen',
|
||
tatsaechlichEntfernen,
|
||
);
|
||
} else {
|
||
await tatsaechlichEntfernen();
|
||
}
|
||
});
|
||
aktionen.appendChild(btn);
|
||
box.appendChild(item);
|
||
}
|
||
}
|
||
|
||
// ---------- Mod-Aktualisierung ----------
|
||
|
||
function setUpdateStatus(text) { el('m-update-status').textContent = text || ''; }
|
||
|
||
async function checkModUpdates() {
|
||
const btn = el('m-check-updates');
|
||
btn.disabled = true;
|
||
el('m-apply-updates').classList.add('hidden');
|
||
setUpdateStatus('Suche …');
|
||
try {
|
||
const res = await window.api.modCheckUpdates(currentDetailId, modArt);
|
||
if (!res.ok) { setUpdateStatus('✖ ' + (res.message || 'Suche fehlgeschlagen.')); return; }
|
||
|
||
modUpdates = new Map((res.treffer || []).map((t) => [t.projectId, t]));
|
||
await refreshInstalled();
|
||
|
||
const teile = [];
|
||
if (!res.treffer.length) teile.push('Alles aktuell');
|
||
else teile.push(res.treffer.length + (res.treffer.length === 1 ? ' Update verfügbar' : ' Updates verfügbar'));
|
||
teile.push(res.geprueft + ' geprüft');
|
||
// von Hand abgelegte Dateien lassen sich nicht zuordnen
|
||
if (res.uebersprungen) teile.push(res.uebersprungen + ' ohne Quelle übersprungen');
|
||
setUpdateStatus(teile.join(' · '));
|
||
el('m-apply-updates').classList.toggle('hidden', !res.treffer.length);
|
||
} finally {
|
||
btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function applyModUpdates() {
|
||
if (!modUpdates.size) return;
|
||
const btn = el('m-apply-updates');
|
||
btn.disabled = true;
|
||
setUpdateStatus('Wird aktualisiert …');
|
||
try {
|
||
const res = await window.api.modApplyUpdates(currentDetailId, [...modUpdates.keys()], modArt);
|
||
if (!res.ok) { setUpdateStatus('✖ ' + (res.message || 'Aktualisierung fehlgeschlagen.')); return; }
|
||
modUpdates = new Map();
|
||
await refreshInstalled();
|
||
const n = res.erneuert.length;
|
||
const wort = n === 1 ? 'Eintrag' : 'Einträge';
|
||
setUpdateStatus(`${n} ${wort} aktualisiert`
|
||
+ (res.fehler.length ? ' · ' + res.fehler.length + ' fehlgeschlagen' : ''));
|
||
if (n) toast(`${n} ${wort} aktualisiert.`);
|
||
for (const f of res.fehler) toast(f.title + ': ' + f.message, true);
|
||
btn.classList.add('hidden');
|
||
} finally {
|
||
btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
let modOffset = 0;
|
||
async function loadCatalog(append) {
|
||
const inst = await window.api.getInstance(currentDetailId);
|
||
if (!inst || !inst.minecraft.version) return;
|
||
// Loader nur für Mods verlangen
|
||
if (ART_BRAUCHT_LOADER[modArt] && inst.minecraft.loader === 'vanilla') return;
|
||
const box = el('m-results');
|
||
if (!append) { modOffset = 0; box.innerHTML = '<div class="mods-empty">Lade …</div>'; el('m-more').classList.add('hidden'); }
|
||
const source = (el('m-source') && el('m-source').value) || 'modrinth';
|
||
if (el('m-category')) el('m-category').disabled = source === 'curseforge' || modArt !== 'mod';
|
||
try {
|
||
const res = await window.api.modSearch({
|
||
query: el('m-query').value.trim(),
|
||
gameVersion: inst.minecraft.version,
|
||
loader: inst.minecraft.loader,
|
||
category: (source === 'modrinth' && modArt === 'mod') ? el('m-category').value : '',
|
||
sort: el('m-sort').value,
|
||
limit: 30,
|
||
offset: modOffset,
|
||
art: modArt,
|
||
source,
|
||
});
|
||
if (!append) box.innerHTML = '';
|
||
if (res && (res.error || res.needKey)) {
|
||
if (!append) {
|
||
box.innerHTML = `<div class="mods-empty">${escapeHtml(res.error || 'Suche nicht möglich.')}</div>`;
|
||
if (res.needKey || /API-Key|Weiterleitung reicht/i.test(res.error || '')) {
|
||
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-mods'); openSettings('pane-network'); });
|
||
wrap.appendChild(btn);
|
||
box.appendChild(wrap);
|
||
}
|
||
}
|
||
el('m-more').classList.add('hidden');
|
||
return;
|
||
}
|
||
const hits = (res && res.hits) || [];
|
||
if (!hits.length && !append) {
|
||
box.innerHTML = '<div class="mods-empty">Nichts gefunden.</div>';
|
||
el('m-more').classList.add('hidden');
|
||
return;
|
||
}
|
||
for (const mod of hits) box.appendChild(buildResultItem(mod));
|
||
modOffset += hits.length;
|
||
el('m-more').classList.toggle('hidden', hits.length < 30 || modOffset >= (res.total || 0));
|
||
} catch (e) {
|
||
if (!append) {
|
||
const msg = (e && e.message) ? e.message : String(e);
|
||
box.innerHTML = `<div class="mods-empty">Fehler: ${escapeHtml(msg)}</div>`;
|
||
}
|
||
}
|
||
}
|
||
|
||
function buildResultItem(mod) {
|
||
const item = document.createElement('div');
|
||
item.className = 'mod-item';
|
||
|
||
const icon = document.createElement(mod.iconUrl ? 'img' : 'div');
|
||
icon.className = 'mod-icon';
|
||
if (mod.iconUrl) {
|
||
icon.src = mod.iconUrl;
|
||
icon.addEventListener('error', () => {
|
||
const d = document.createElement('div');
|
||
d.className = 'mod-icon'; d.textContent = '🧩';
|
||
icon.replaceWith(d);
|
||
});
|
||
} else { icon.textContent = '🧩'; }
|
||
|
||
const info = document.createElement('div');
|
||
info.className = 'mod-info';
|
||
info.innerHTML =
|
||
`<div class="mod-name">${escapeHtml(mod.title)}</div>
|
||
<div class="mod-desc">${escapeHtml(mod.description || '')}</div>
|
||
<div class="mod-meta">von ${escapeHtml(mod.author || 'unbekannt')} · ${Number(mod.downloads).toLocaleString('de-DE')} Downloads${mod.source === 'curseforge' ? ' · CurseForge' : ''}</div>`;
|
||
|
||
const action = document.createElement('div');
|
||
action.className = 'mod-action';
|
||
const btn = document.createElement('button');
|
||
btn.className = 'btn btn-primary btn-mini';
|
||
const already = currentModsInstalled.has(mod.projectId);
|
||
if (already) {
|
||
btn.textContent = '✓ Installiert'; btn.disabled = true;
|
||
} else {
|
||
btn.textContent = 'Installieren';
|
||
btn.addEventListener('click', (e) => { e.stopPropagation(); installMod(mod, btn, item); });
|
||
// ganze Zeile klickbar: ein Klick installiert Mod + alle Abhängigkeiten
|
||
item.classList.add('clickable');
|
||
item.title = 'Klicken zum Installieren (inkl. Abhängigkeiten)';
|
||
item.addEventListener('click', () => { if (!btn.disabled) installMod(mod, btn, item); });
|
||
}
|
||
action.appendChild(btn);
|
||
|
||
item.appendChild(icon); item.appendChild(info); item.appendChild(action);
|
||
return item;
|
||
}
|
||
|
||
async function installMod(mod, btn, item) {
|
||
btn.disabled = true; btn.textContent = 'Lädt …';
|
||
if (item) { item.classList.add('installing'); item.classList.remove('clickable'); }
|
||
const res = await window.api.modInstall(currentDetailId, { projectId: mod.projectId, title: mod.title }, modArt);
|
||
if (item) item.classList.remove('installing');
|
||
if (res.ok) {
|
||
const dep = res.depCount ? ` (+${res.depCount} Abhängigkeit${res.depCount > 1 ? 'en' : ''})` : '';
|
||
toast('Installiert: ' + mod.title + dep);
|
||
btn.textContent = '✓ Installiert';
|
||
currentModsInstalled.add(mod.projectId);
|
||
await refreshInstalled();
|
||
} else {
|
||
toast(res.message || 'Installation fehlgeschlagen.', true);
|
||
btn.disabled = false; btn.textContent = 'Installieren';
|
||
if (item) item.classList.add('clickable');
|
||
}
|
||
}
|
||
|
||
// ---------- Einstellungen ----------
|
||
function setSettingsPane(paneId) {
|
||
document.querySelectorAll('.snav').forEach((button) => button.classList.toggle('on', button.dataset.pane === paneId));
|
||
document.querySelectorAll('.spane').forEach((pane) => pane.classList.toggle('hidden', pane.id !== paneId));
|
||
}
|
||
|
||
// pane: optionaler Bereich, der direkt geöffnet werden soll (z. B. 'pane-network')
|
||
async function openSettings(pane) {
|
||
if (POPOUT !== 'settings') { window.api.popoutOpen('settings', { pane }); return; }
|
||
setSettingsPane(pane || new URLSearchParams(location.search).get('pane') || 'pane-general');
|
||
|
||
const s = await window.api.getSettings();
|
||
el('s-theme').value = s.theme || 'dark';
|
||
el('s-global-pre').value = s.globalPreLaunch || '';
|
||
el('s-global-post').value = s.globalPostExit || '';
|
||
el('s-wrapper').value = s.wrapperCommand || '';
|
||
|
||
// Skin-Hintergrund: Auswahl aus allen angemeldeten Konten
|
||
const accs = await window.api.authAccounts();
|
||
const sel = el('s-bgskin');
|
||
sel.innerHTML = '<option value="">Aus</option><option value="active">Aktives Konto</option>';
|
||
for (const a of accs) {
|
||
const o = document.createElement('option');
|
||
o.value = a.id; o.textContent = a.name;
|
||
sel.appendChild(o);
|
||
}
|
||
sel.value = s.bgSkin || '';
|
||
el('s-bgskin-pose').value = s.bgSkinPose || 'walking';
|
||
el('s-bgskin-cycle').checked = !!s.bgSkinCycle;
|
||
el('s-bgskin-cycle-seconds').value = s.bgSkinCycleSeconds || 30;
|
||
sel.disabled = !!s.bgSkinCycle;
|
||
el('s-sortby').value = s.sortBy || 'lastPlayed';
|
||
el('s-iso3d').checked = s.iso3dIcons !== false;
|
||
el('s-bedrock').checked = s.bedrockEnabled !== false;
|
||
el('s-notify').checked = s.desktopNotifications !== false;
|
||
el('s-friends').checked = s.friendsEnabled !== false;
|
||
el('s-autostart').checked = !!s.autostart;
|
||
el('s-auto-java').checked = s.autoJava !== false;
|
||
el('s-autobackup').checked = !!s.autoBackupEnabled;
|
||
el('s-autobackup-every').value = s.autoBackupEvery || 'daily';
|
||
el('s-autobackup-keep').value = s.autoBackupKeep || 5;
|
||
el('s-autobackup-launch').checked = !!s.autoBackupBeforeLaunch;
|
||
el('s-autobackup-last').textContent = s.autoBackupLast
|
||
? 'Zuletzt automatisch gesichert: ' + new Date(s.autoBackupLast).toLocaleString('de-DE')
|
||
: 'Noch keine automatische Sicherung.';
|
||
el('s-console-launch').checked = !!s.showConsoleOnLaunch;
|
||
el('s-console-autoclose').checked = !!s.autoCloseConsole;
|
||
el('s-console-crash').checked = s.showConsoleOnCrash !== false;
|
||
el('s-console-lines').value = s.consoleMaxLines || 100000;
|
||
el('s-mc-max').checked = !!s.mcMaximized;
|
||
el('s-mc-width').value = s.mcWidth || 854;
|
||
el('s-mc-height').value = s.mcHeight || 480;
|
||
el('s-bgskin-opacity').value = s.bgSkinOpacity ?? 55;
|
||
el('s-bgskin-val').textContent = String(s.bgSkinOpacity ?? 55);
|
||
el('s-proxy-enabled').checked = !!s.proxyEnabled;
|
||
el('s-proxy-host').value = s.proxyHost || '';
|
||
el('s-proxy-port').value = s.proxyPort || '';
|
||
el('s-proxy-user').value = s.proxyUser || '';
|
||
el('s-proxy-pass').value = s.proxyPass || '';
|
||
el('s-cf-key').value = s.curseforgeApiKey || '';
|
||
el('s-cf-proxy').value = s.curseforgeProxyUrl || '';
|
||
el('s-cf-proxy-token').value = s.curseforgeProxyToken || '';
|
||
el('s-backup-status').className = 'hint';
|
||
el('s-backup-status').textContent = 'Noch kein Backup erstellt.';
|
||
el('s-backup-global').disabled = false;
|
||
el('s-restore-status').className = 'hint';
|
||
el('s-restore-status').textContent = 'Noch kein Backup wiederhergestellt.';
|
||
await refreshGlobalBackupOptions();
|
||
resetBackupProgressUi();
|
||
setBackupButtonsBusy(false);
|
||
el('s-instances-dir').value = await window.api.getInstancesDir();
|
||
el('s-java').value = s.javaPath || '';
|
||
el('s-min-mem').value = s.defaultMinMemMb;
|
||
el('s-max-mem').value = s.defaultMaxMemMb;
|
||
el('s-java-detected').innerHTML = '<option value="">– gefundene Java-Installationen –</option>';
|
||
await refreshJavaStatus(s.javaPath);
|
||
|
||
const info = await window.api.appInfo();
|
||
el('s-appinfo').innerHTML = [
|
||
['Name', info.name || 'AeroMC'],
|
||
['Version', formatVersionLabel(info.version)],
|
||
['Autor', info.author || 'Nicht hinterlegt'],
|
||
['Webseite', info.homepage || 'Nicht hinterlegt', info.homepage || ''],
|
||
['Launcher-Webseite', info.launcherWebsite || 'Nicht hinterlegt', info.launcherWebsite || ''],
|
||
['Lizenz', 'Nur privater, nicht-kommerzieller Gebrauch – Details anzeigen', null, 'license'],
|
||
].map(([label, value, url, action]) => {
|
||
const renderedValue = url
|
||
? `<a href="#" class="info-meta-link" data-url="${escapeHtml(url)}">${escapeHtml(value)}</a>`
|
||
: action
|
||
? `<a href="#" class="info-meta-link" data-action="${escapeHtml(action)}">${escapeHtml(value)}</a>`
|
||
: escapeHtml(value || '–');
|
||
return `<div class="settings-kv-row"><span class="settings-kv-label">${escapeHtml(label)}</span><span class="settings-kv-value">${renderedValue}</span></div>`;
|
||
}).join('');
|
||
for (const link of el('s-appinfo').querySelectorAll('.info-meta-link')) {
|
||
link.addEventListener('click', async (evt) => {
|
||
evt.preventDefault();
|
||
const url = link.dataset.url;
|
||
if (url) { window.api.openExternal(url); return; }
|
||
if (link.dataset.action === 'license') {
|
||
show('modal-license');
|
||
el('license-text').textContent = await window.api.appLicense();
|
||
}
|
||
});
|
||
}
|
||
show('modal-settings');
|
||
await renderAccounts('s-accounts'); // Konten direkt in den Einstellungen zeigen
|
||
applyBedrockVisibility(); // aktualisiert auch den Hinweistext
|
||
}
|
||
|
||
async function refreshJavaStatus(javaPath) {
|
||
const box = el('s-java-status');
|
||
box.className = 'hint';
|
||
box.textContent = 'Prüfe Java …';
|
||
const r = await window.api.detectJava(javaPath);
|
||
if (r.ok) {
|
||
box.classList.add('status-ok'); // grün, wenn Java passt
|
||
box.textContent = '✔ ' + r.version;
|
||
} else {
|
||
box.classList.add('status-bad');
|
||
box.textContent = '✖ Java nicht gefunden (' + (r.error || 'unbekannt') + ')';
|
||
}
|
||
}
|
||
|
||
async function scanJava() {
|
||
const btn = el('s-scan-java');
|
||
btn.disabled = true; btn.textContent = 'Suche …';
|
||
try {
|
||
const jvms = await window.api.scanJava();
|
||
const sel = el('s-java-detected');
|
||
sel.innerHTML = '<option value="">– gefundene Java-Installationen –</option>';
|
||
for (const j of jvms) {
|
||
const o = document.createElement('option');
|
||
o.value = j.path;
|
||
o.textContent = `Java ${j.major} (${j.arch}) – ${j.path}`;
|
||
sel.appendChild(o);
|
||
}
|
||
toast(jvms.length ? `${jvms.length} Java-Installation(en) gefunden.` : 'Keine Java-Installation gefunden.', !jvms.length);
|
||
} catch (e) {
|
||
toast('Java-Suche fehlgeschlagen: ' + e.message, true);
|
||
} finally {
|
||
btn.disabled = false; btn.textContent = '🔍 Erkennen';
|
||
}
|
||
}
|
||
|
||
async function saveSettings() {
|
||
// globale Speicher-Vorgaben genauso prüfen wie die je Instanz
|
||
const gMin = numOrNull(el('s-min-mem').value) || 2048;
|
||
const gMax = numOrNull(el('s-max-mem').value) || 4096;
|
||
const pruefung = await window.api.checkMemory(gMin, gMax);
|
||
if (!pruefung.ok) { toast(pruefung.message, true); setSettingsPane('pane-minecraft'); return; }
|
||
if (pruefung.warnung) toast(pruefung.warnung, false);
|
||
|
||
const patch = {
|
||
theme: el('s-theme').value,
|
||
globalPreLaunch: el('s-global-pre').value.trim(),
|
||
globalPostExit: el('s-global-post').value.trim(),
|
||
bgSkin: el('s-bgskin').value,
|
||
bgSkinPose: el('s-bgskin-pose').value,
|
||
bgSkinCycle: el('s-bgskin-cycle').checked,
|
||
bgSkinCycleSeconds: Math.max(5, numOrNull(el('s-bgskin-cycle-seconds').value) || 30),
|
||
sortBy: el('s-sortby').value,
|
||
iso3dIcons: el('s-iso3d').checked,
|
||
bedrockEnabled: el('s-bedrock').checked,
|
||
desktopNotifications: el('s-notify').checked,
|
||
friendsEnabled: el('s-friends').checked,
|
||
autostart: el('s-autostart').checked,
|
||
autoJava: el('s-auto-java').checked,
|
||
autoBackupEnabled: el('s-autobackup').checked,
|
||
autoBackupEvery: el('s-autobackup-every').value,
|
||
autoBackupKeep: Math.min(50, Math.max(1, numOrNull(el('s-autobackup-keep').value) || 5)),
|
||
autoBackupBeforeLaunch: el('s-autobackup-launch').checked,
|
||
showConsoleOnLaunch: el('s-console-launch').checked,
|
||
autoCloseConsole: el('s-console-autoclose').checked,
|
||
showConsoleOnCrash: el('s-console-crash').checked,
|
||
consoleMaxLines: numOrNull(el('s-console-lines').value) || 100000,
|
||
mcMaximized: el('s-mc-max').checked,
|
||
mcWidth: numOrNull(el('s-mc-width').value) || 854,
|
||
mcHeight: numOrNull(el('s-mc-height').value) || 480,
|
||
bgSkinOpacity: numOrNull(el('s-bgskin-opacity').value) || 55,
|
||
wrapperCommand: el('s-wrapper').value.trim(),
|
||
proxyEnabled: el('s-proxy-enabled').checked,
|
||
proxyHost: el('s-proxy-host').value.trim(),
|
||
proxyPort: el('s-proxy-port').value.trim(),
|
||
proxyUser: el('s-proxy-user').value.trim(),
|
||
proxyPass: el('s-proxy-pass').value,
|
||
curseforgeApiKey: el('s-cf-key').value.trim(),
|
||
curseforgeProxyUrl: el('s-cf-proxy').value.trim(),
|
||
curseforgeProxyToken: el('s-cf-proxy-token').value.trim(),
|
||
javaPath: el('s-java').value.trim(),
|
||
defaultMinMemMb: numOrNull(el('s-min-mem').value) || 2048,
|
||
defaultMaxMemMb: numOrNull(el('s-max-mem').value) || 4096,
|
||
instancesDir: el('s-instances-dir').value.trim() || null,
|
||
};
|
||
await window.api.saveSettings(patch);
|
||
// Theme hier direkt anwenden (für dieses Fenster selbst); alles, was das
|
||
// Hauptfenster betrifft (Bedrock-Sichtbarkeit, Skin-Hintergrund,
|
||
// Instanzliste), zieht das Hauptfenster über "settings:changed" selbst nach
|
||
applyTheme(patch.theme);
|
||
iso3dEnabled = patch.iso3dIcons;
|
||
hide('modal-settings');
|
||
toast('Einstellungen gespeichert.');
|
||
}
|
||
|
||
function resetBackupProgressUi() {
|
||
['backup', 'restore'].forEach((kind) => {
|
||
clearBackupProgressTimer(kind);
|
||
backupProgressUiState[kind].shownPercent = 0;
|
||
backupProgressUiState[kind].targetPercent = 0;
|
||
backupProgressUiState[kind].detail = '';
|
||
backupProgressUiState[kind].packing = false;
|
||
el(`s-${kind}-progress`).dataset.percent = '0';
|
||
el(`s-${kind}-progress`).dataset.complete = '0';
|
||
el(`s-${kind}-progress`).classList.add('hidden');
|
||
el(`s-${kind}-progress-fill`).style.width = '0%';
|
||
el(`s-${kind}-progress-fill`).classList.remove('backup-progress-fill-busy');
|
||
el(`s-${kind}-progress-text`).textContent = '0 %';
|
||
});
|
||
}
|
||
|
||
const backupProgressActive = { backup: false, restore: false };
|
||
const backupProgressUiState = {
|
||
backup: { timer: null, shownPercent: 0, targetPercent: 0, detail: '', packing: false },
|
||
restore: { timer: null, shownPercent: 0, targetPercent: 0, detail: '', packing: false },
|
||
};
|
||
|
||
function clearBackupProgressTimer(prefix) {
|
||
const state = backupProgressUiState[prefix];
|
||
if (!state.timer) return;
|
||
clearInterval(state.timer);
|
||
state.timer = null;
|
||
}
|
||
|
||
function renderBackupProgress(prefix) {
|
||
const state = backupProgressUiState[prefix];
|
||
const box = el(`s-${prefix}-progress`);
|
||
const fill = el(`s-${prefix}-progress-fill`);
|
||
const text = el(`s-${prefix}-progress-text`);
|
||
const displayPercent = state.targetPercent >= 100 ? 100 : Math.floor(state.shownPercent);
|
||
|
||
box.dataset.percent = String(displayPercent);
|
||
box.dataset.complete = state.targetPercent >= 100 && state.shownPercent >= 100 ? '1' : '0';
|
||
box.classList.remove('hidden');
|
||
fill.classList.toggle('backup-progress-fill-busy', state.packing && state.targetPercent < 99);
|
||
fill.style.width = `${Math.max(0, Math.min(100, state.shownPercent))}%`;
|
||
text.textContent = `${displayPercent} %${state.detail ? ' · ' + state.detail : ''}`;
|
||
}
|
||
|
||
function ensureBackupProgressTimer(prefix) {
|
||
const state = backupProgressUiState[prefix];
|
||
if (state.timer) return;
|
||
|
||
state.timer = setInterval(() => {
|
||
const gap = state.targetPercent - state.shownPercent;
|
||
|
||
if (gap > 0.05) {
|
||
const step = state.packing ? Math.max(0.18, gap * 0.18) : Math.max(0.8, gap * 0.4);
|
||
state.shownPercent = Math.min(state.targetPercent, state.shownPercent + step);
|
||
} else {
|
||
state.shownPercent = state.targetPercent;
|
||
}
|
||
|
||
renderBackupProgress(prefix);
|
||
|
||
if (state.targetPercent >= 100 && state.shownPercent >= 100) clearBackupProgressTimer(prefix);
|
||
}, 120);
|
||
}
|
||
|
||
function setBackupProgressActive(mode, active) {
|
||
const prefix = mode === 'restore' ? 'restore' : 'backup';
|
||
backupProgressActive[prefix] = !!active;
|
||
}
|
||
|
||
function applyBackupProgressEvent(mode, percent, detail) {
|
||
const prefix = mode === 'restore' ? 'restore' : 'backup';
|
||
if (!backupProgressActive[prefix]) return;
|
||
updateBackupProgressUi(mode, percent, detail);
|
||
}
|
||
|
||
function setBackupButtonsBusy(busy) {
|
||
const restoreSelect = el('s-restore-backup');
|
||
const hasPlaceholderOnly = restoreSelect.options.length === 1 && !restoreSelect.options[0].value;
|
||
const canRestore = restoreSelect.options.length > 0 && !hasPlaceholderOnly && !!restoreSelect.value;
|
||
|
||
el('s-backup-global').disabled = busy;
|
||
restoreSelect.disabled = busy || !canRestore;
|
||
el('s-restore-global').disabled = busy || !canRestore;
|
||
}
|
||
|
||
function formatBackupSize(bytes) {
|
||
const value = Number(bytes) || 0;
|
||
if (value < 1024 * 1024) return `${Math.max(1, Math.round(value / 1024))} KB`;
|
||
return `${(value / (1024 * 1024)).toFixed(1)} MB`;
|
||
}
|
||
|
||
function formatBackupModified(isoString) {
|
||
try {
|
||
return new Date(isoString).toLocaleString('de-DE');
|
||
} catch {
|
||
return isoString || '';
|
||
}
|
||
}
|
||
|
||
async function refreshGlobalBackupOptions(selectedPath) {
|
||
const select = el('s-restore-backup');
|
||
const backups = await window.api.listGlobalBackups();
|
||
|
||
select.innerHTML = '';
|
||
if (!backups.length) {
|
||
const option = document.createElement('option');
|
||
option.value = '';
|
||
option.textContent = 'Keine Backups gefunden';
|
||
select.appendChild(option);
|
||
select.disabled = true;
|
||
el('s-restore-global').disabled = true;
|
||
return [];
|
||
}
|
||
|
||
backups.forEach((backup) => {
|
||
const option = document.createElement('option');
|
||
option.value = backup.path;
|
||
option.textContent = `${backup.name} · ${formatBackupModified(backup.modifiedAt)} · ${formatBackupSize(backup.size)}`;
|
||
select.appendChild(option);
|
||
});
|
||
|
||
select.value = backups.some((backup) => backup.path === selectedPath) ? selectedPath : backups[0].path;
|
||
select.disabled = false;
|
||
el('s-restore-global').disabled = !select.value;
|
||
return backups;
|
||
}
|
||
|
||
function updateBackupProgressUi(mode, percent, detail) {
|
||
const prefix = mode === 'restore' ? 'restore' : 'backup';
|
||
const state = backupProgressUiState[prefix];
|
||
const nextPercent = Math.max(0, Math.min(100, Number(percent) || 0));
|
||
const completed = state.targetPercent >= 100;
|
||
|
||
if (nextPercent === 0) {
|
||
state.shownPercent = 0;
|
||
state.targetPercent = 0;
|
||
state.detail = detail || '';
|
||
state.packing = false;
|
||
renderBackupProgress(prefix);
|
||
return;
|
||
}
|
||
|
||
if ((completed && nextPercent < 100) || nextPercent < state.targetPercent) {
|
||
return;
|
||
}
|
||
|
||
state.targetPercent = nextPercent;
|
||
state.detail = detail || '';
|
||
state.packing = /^Packe ZIP-Archiv/.test(state.detail) && nextPercent < 99;
|
||
|
||
if (nextPercent >= 99 || (nextPercent - state.shownPercent) >= 20 || state.shownPercent === 0) {
|
||
state.shownPercent = nextPercent;
|
||
}
|
||
|
||
renderBackupProgress(prefix);
|
||
if (state.shownPercent < state.targetPercent) ensureBackupProgressTimer(prefix);
|
||
else if (state.targetPercent >= 100) clearBackupProgressTimer(prefix);
|
||
}
|
||
|
||
async function runGlobalBackup() {
|
||
const statusLine = el('s-backup-status');
|
||
setBackupButtonsBusy(true);
|
||
setBackupProgressActive('create', true);
|
||
statusLine.className = 'hint';
|
||
updateBackupProgressUi('create', 0, 'Bereite Zielordner vor');
|
||
statusLine.textContent = 'Erstelle Backup in Dokumente/AeroMC Launcher/Backups ...';
|
||
try {
|
||
const res = await window.api.createGlobalBackup();
|
||
if (res.canceled) {
|
||
statusLine.className = 'hint';
|
||
statusLine.textContent = 'Backup abgebrochen.';
|
||
resetBackupProgressUi();
|
||
setBackupProgressActive('create', false);
|
||
return;
|
||
}
|
||
if (res.ok) {
|
||
updateBackupProgressUi('create', 100, 'Backup abgeschlossen');
|
||
setBackupProgressActive('create', false);
|
||
statusLine.className = 'hint status-ok';
|
||
statusLine.textContent = 'Backup erfolgreich erstellt';
|
||
await refreshGlobalBackupOptions(res.path);
|
||
toast('Globales Backup erstellt: ' + res.path);
|
||
return;
|
||
}
|
||
setBackupProgressActive('create', false);
|
||
statusLine.className = 'hint status-bad';
|
||
statusLine.textContent = '✖ ' + (res.message || 'Backup fehlgeschlagen.');
|
||
toast(res.message || 'Backup fehlgeschlagen.', true);
|
||
} finally {
|
||
setBackupProgressActive('create', false);
|
||
setBackupButtonsBusy(false);
|
||
}
|
||
}
|
||
|
||
async function runGlobalRestore() {
|
||
const statusLine = el('s-restore-status');
|
||
const selectedBackup = el('s-restore-backup').value;
|
||
if (!selectedBackup) {
|
||
statusLine.className = 'hint status-bad';
|
||
statusLine.textContent = 'Bitte ein Backup auswählen.';
|
||
return;
|
||
}
|
||
setBackupButtonsBusy(true);
|
||
setBackupProgressActive('restore', true);
|
||
updateBackupProgressUi('restore', 0, 'Warte auf Backup-Datei');
|
||
statusLine.className = 'hint';
|
||
statusLine.textContent = 'Stelle Backup wieder her ...';
|
||
try {
|
||
const res = await window.api.restoreGlobalBackup(selectedBackup);
|
||
if (res.canceled) {
|
||
statusLine.className = 'hint';
|
||
statusLine.textContent = 'Wiederherstellen abgebrochen.';
|
||
resetBackupProgressUi();
|
||
setBackupProgressActive('restore', false);
|
||
return;
|
||
}
|
||
if (!res.ok) {
|
||
setBackupProgressActive('restore', false);
|
||
statusLine.className = 'hint status-bad';
|
||
statusLine.textContent = '✖ ' + (res.message || 'Wiederherstellen fehlgeschlagen.');
|
||
toast(res.message || 'Wiederherstellen fehlgeschlagen.', true);
|
||
return;
|
||
}
|
||
|
||
updateBackupProgressUi('restore', 100, 'Wiederherstellen abgeschlossen');
|
||
setBackupProgressActive('restore', false);
|
||
statusLine.className = 'hint status-ok';
|
||
statusLine.textContent = 'Backup erfolgreich wiederhergestellt';
|
||
const s = await window.api.getSettings();
|
||
applyTheme(s.theme || 'dark');
|
||
iso3dEnabled = s.iso3dIcons !== false;
|
||
await applyBedrockVisibility();
|
||
await refreshSkinBackground();
|
||
hide('modal-settings');
|
||
await reload();
|
||
toast('Backup wiederhergestellt: ' + res.path);
|
||
} finally {
|
||
setBackupProgressActive('restore', false);
|
||
setBackupButtonsBusy(false);
|
||
}
|
||
}
|
||
|
||
function promptGlobalRestore() {
|
||
askConfirm(
|
||
'Backup wiederherstellen',
|
||
'Das überschreibt alle aktuellen Instanzen mit dem Inhalt des Backups. Fortfahren?',
|
||
'Wiederherstellen',
|
||
runGlobalRestore,
|
||
);
|
||
}
|
||
|
||
// ---------- Microsoft-Login / Kontoverwaltung ----------
|
||
async function refreshAccountButton() {
|
||
const acc = await window.api.authStatus();
|
||
const av = el('account-av');
|
||
if (acc && acc.profile) {
|
||
av.innerHTML = '';
|
||
const img = document.createElement('img');
|
||
img.className = 'acct-head';
|
||
img.src = `https://crafthead.net/bust/${acc.profile.id}?size=64`;
|
||
img.addEventListener('error', () => { av.innerHTML = svgIcon('user'); });
|
||
av.appendChild(img);
|
||
el('account-name').textContent = acc.profile.name;
|
||
} else {
|
||
av.innerHTML = svgIcon('user');
|
||
el('account-name').textContent = 'Konten';
|
||
}
|
||
}
|
||
|
||
// aktualisiert Kontoliste im Login-Dialog und in den Einstellungen
|
||
async function refreshAllAccountLists() {
|
||
await renderAccounts('login-accounts');
|
||
if (!el('modal-settings').classList.contains('hidden')) await renderAccounts('s-accounts');
|
||
}
|
||
|
||
async function renderAccounts(boxId = 'login-accounts') {
|
||
const accounts = await window.api.authAccounts();
|
||
const box = el(boxId);
|
||
if (!box) return;
|
||
box.innerHTML = '';
|
||
if (!accounts.length) {
|
||
box.innerHTML = '<div class="account-empty">Noch kein Konto angemeldet.</div>';
|
||
return;
|
||
}
|
||
for (const a of accounts) {
|
||
const item = document.createElement('div');
|
||
item.className = 'account-item' + (a.active ? ' active' : '');
|
||
|
||
const av = document.createElement('img');
|
||
av.className = 'account-av';
|
||
av.src = `https://crafthead.net/avatar/${a.id}?size=64`;
|
||
av.addEventListener('error', () => {
|
||
const d = document.createElement('div');
|
||
d.className = 'account-av'; d.textContent = '🎮';
|
||
av.replaceWith(d);
|
||
});
|
||
|
||
// "spielt" nur, wenn wirklich ein Spiel dieses Kontos läuft; "aktiv" heißt
|
||
// lediglich: dieses Konto wird für den nächsten Start verwendet
|
||
const merkmale = [];
|
||
if (a.active) merkmale.push('aktiv');
|
||
if (a.playing) merkmale.push('spielt gerade');
|
||
const main = document.createElement('div');
|
||
main.className = 'account-main';
|
||
main.innerHTML =
|
||
`<div class="account-name">${escapeHtml(a.name)}</div>
|
||
<div class="account-tag">${merkmale.length ? '● ' + merkmale.join(' · ') : ''}</div>`;
|
||
|
||
const actions = document.createElement('div');
|
||
actions.className = 'account-actions';
|
||
item.append(av, main, actions);
|
||
if (!a.active) {
|
||
const use = document.createElement('button');
|
||
use.className = 'btn btn-mini';
|
||
use.textContent = 'Aktivieren';
|
||
use.addEventListener('click', async () => {
|
||
await window.api.authSetActive(a.id);
|
||
await refreshAllAccountLists();
|
||
await refreshAccountButton();
|
||
await refreshSkinBackground();
|
||
toast('Aktiv: ' + a.name);
|
||
});
|
||
actions.appendChild(use);
|
||
}
|
||
const rm = document.createElement('button');
|
||
rm.className = 'btn btn-danger btn-mini';
|
||
rm.textContent = 'Entfernen';
|
||
rm.addEventListener('click', async () => {
|
||
await window.api.authRemove(a.id);
|
||
await refreshAllAccountLists();
|
||
await refreshAccountButton();
|
||
toast('Entfernt: ' + a.name);
|
||
});
|
||
actions.appendChild(rm);
|
||
box.appendChild(item);
|
||
}
|
||
}
|
||
|
||
// steht wortgleich in index.html – von dort wird er beim Öffnen zurückgesetzt
|
||
const LOGIN_HINWEIS = 'Melde dich mit deinem Microsoft-Konto an (Minecraft Java Edition nötig). '
|
||
+ 'Das aktive Konto wird beim nächsten Start verwendet. Für zwei Konten gleichzeitig: '
|
||
+ 'eine Instanz starten, hier das zweite Konto aktivieren und eine weitere Instanz starten – '
|
||
+ 'laufende Spiele behalten ihr Konto.';
|
||
|
||
function resetLoginHint() {
|
||
const h = el('login-hint');
|
||
h.classList.remove('login-error');
|
||
h.textContent = LOGIN_HINWEIS;
|
||
}
|
||
|
||
async function openLogin(opts) {
|
||
// "Konto hinzufügen" direkt aus den Einstellungen: opts.addAccount sorgt
|
||
// dafür, dass addAccount() im richtigen (Login-)Fenster läuft, statt in
|
||
// dem Fenster, das openLogin() aufgerufen hat
|
||
if (POPOUT !== 'login') { window.api.popoutOpen('login', opts); return; }
|
||
el('login-code').classList.add('hidden');
|
||
resetLoginHint();
|
||
await refreshAllAccountLists();
|
||
show('modal-login');
|
||
if (opts && opts.addAccount) await addAccount();
|
||
}
|
||
|
||
async function addAccount() {
|
||
resetLoginHint();
|
||
el('login-code').classList.remove('hidden');
|
||
el('login-usercode').textContent = '– – – –';
|
||
el('login-url').textContent = 'Lade …';
|
||
el('login-status').textContent = 'Fordere Code an …';
|
||
el('login-add').disabled = true;
|
||
try {
|
||
const res = await window.api.authLogin(); // läuft bis zur Bestätigung
|
||
el('login-code').classList.add('hidden');
|
||
if (res.ok) {
|
||
await refreshAllAccountLists();
|
||
await refreshAccountButton();
|
||
toast('Angemeldet als ' + res.account.profile.name);
|
||
} else {
|
||
const msg = res.message || 'Login fehlgeschlagen.';
|
||
el('login-hint').textContent = '✖ ' + msg;
|
||
el('login-hint').classList.add('login-error');
|
||
toast(msg, true);
|
||
}
|
||
} finally {
|
||
el('login-add').disabled = false;
|
||
}
|
||
}
|
||
|
||
// Device-Code-Ereignis aus dem Main-Prozess
|
||
function onAuthCode(code) {
|
||
loginVerificationUri = code.verificationUri;
|
||
el('login-code').classList.remove('hidden');
|
||
el('login-url').textContent = code.verificationUri;
|
||
el('login-usercode').textContent = code.userCode;
|
||
el('login-status').textContent = 'Warte auf Bestätigung im Browser …';
|
||
}
|
||
|
||
// ---------- Bestätigung ----------
|
||
|
||
let promptResolver = null;
|
||
function askPrompt({ title, text, label, value, okLabel } = {}) {
|
||
return new Promise((resolve) => {
|
||
// falls schon ein Prompt offen ist
|
||
if (promptResolver) { promptResolver(null); promptResolver = null; }
|
||
promptResolver = resolve;
|
||
el('prompt-title').textContent = title || 'Eingabe';
|
||
el('prompt-text').textContent = text || '';
|
||
el('prompt-text').classList.toggle('hidden', !text);
|
||
el('prompt-label').textContent = label || 'Wert';
|
||
el('prompt-ok').textContent = okLabel || 'OK';
|
||
const input = el('prompt-input');
|
||
input.value = value != null ? String(value) : '';
|
||
show('modal-prompt');
|
||
setTimeout(() => { input.focus(); input.select(); }, 30);
|
||
});
|
||
}
|
||
function closePrompt(wert) {
|
||
hide('modal-prompt');
|
||
const r = promptResolver;
|
||
promptResolver = null;
|
||
if (r) r(wert);
|
||
}
|
||
|
||
function askConfirm(title, text, okLabel, action) {
|
||
el('confirm-title').textContent = title;
|
||
el('confirm-text').textContent = text;
|
||
el('confirm-ok').textContent = okLabel;
|
||
confirmAction = action;
|
||
show('modal-confirm');
|
||
}
|
||
|
||
// ---------- Events ----------
|
||
function wire() {
|
||
el('btn-new').addEventListener('click', openCreate);
|
||
el('btn-new-2').addEventListener('click', openCreate);
|
||
// ohne Wrapper käme das Klick-Ereignis als Bereichsname an
|
||
el('btn-settings').addEventListener('click', () => openSettings());
|
||
el('search').addEventListener('input', render);
|
||
|
||
el('btn-open-dir').addEventListener('click', () => {
|
||
if (currentDetailId) window.api.openInstanceFolder(currentDetailId);
|
||
else if (instances[0]) window.api.openInstanceFolder(instances[0].id);
|
||
else toast('Noch keine Instanz vorhanden.', false);
|
||
});
|
||
|
||
el('btn-import').addEventListener('click', () => { openImportDialog('zip'); });
|
||
|
||
// Skin-Hintergrund an/aus (wie MultiMCs Katzen-Knopf)
|
||
el('btn-skin').addEventListener('click', async () => {
|
||
const s = await window.api.getSettings();
|
||
if (s.bgSkin || s.bgSkinCycle) {
|
||
// auch den Kontenwechsel mit ausschalten, sonst würde er sofort wieder anspringen
|
||
await window.api.saveSettings({ bgSkin: '', bgSkinCycle: false });
|
||
toast('Skin-Hintergrund aus.');
|
||
} else {
|
||
const acc = await window.api.authStatus();
|
||
if (!acc || !acc.profile) { toast('Erst ein Microsoft-Konto anmelden.', true); return; }
|
||
await window.api.saveSettings({ bgSkin: 'active' });
|
||
toast('Skin-Hintergrund an: ' + acc.profile.name);
|
||
}
|
||
await refreshSkinBackground();
|
||
});
|
||
|
||
// Kontextmenü: Einträge lösen die Aktionen der rechten Leiste aus
|
||
document.querySelectorAll('#ctx-menu .ctx-item').forEach((item) => {
|
||
item.addEventListener('click', () => {
|
||
const target = el(item.dataset.act);
|
||
closeContextMenu();
|
||
if (target) target.click();
|
||
});
|
||
});
|
||
// schließen bei Klick daneben, Rechtsklick ins Leere, Scrollen oder ESC
|
||
document.addEventListener('click', (e) => {
|
||
if (!e.target.closest('#ctx-menu')) closeContextMenu();
|
||
});
|
||
document.addEventListener('contextmenu', (e) => {
|
||
if (!e.target.closest('.card') && !e.target.closest('#ctx-menu')) closeContextMenu();
|
||
});
|
||
document.addEventListener('scroll', closeContextMenu, true);
|
||
window.addEventListener('blur', closeContextMenu);
|
||
|
||
// Warnung anklicken -> sofort erneut prüfen
|
||
el('status-auth').addEventListener('click', async () => {
|
||
el('status-auth').textContent = 'Prüfe Anmelde-Dienste …';
|
||
const h = await window.api.authRecheck();
|
||
showAuthHealth(h);
|
||
if (h.ok) toast('Anmelde-Dienste wieder erreichbar.');
|
||
});
|
||
|
||
// Import
|
||
document.querySelectorAll('.import-tabs .mtab').forEach((tab) => {
|
||
tab.addEventListener('click', async () => {
|
||
setImportKind(tab.dataset.kind);
|
||
if (tab.dataset.kind === 'mc') await ensureDefaultMcScan();
|
||
if (tab.dataset.kind === 'modpack') {
|
||
const box = el('mp-results');
|
||
if (box && !box.dataset.loaded) {
|
||
box.dataset.loaded = '1';
|
||
searchModpacks();
|
||
}
|
||
}
|
||
if (tab.dataset.kind === 'technic') {
|
||
const box = el('tp-results');
|
||
if (box && !box.dataset.loaded) {
|
||
box.dataset.loaded = '1';
|
||
searchTechnicPlatform();
|
||
}
|
||
}
|
||
});
|
||
});
|
||
el('import-zip-do').addEventListener('click', runZipImport);
|
||
el('import-modpack-do').addEventListener('click', runModpackImport);
|
||
el('mp-search-btn').addEventListener('click', searchModpacks);
|
||
el('mp-query').addEventListener('keydown', (e) => { if (e.key === 'Enter') searchModpacks(); });
|
||
el('mp-mc').addEventListener('keydown', (e) => { if (e.key === 'Enter') searchModpacks(); });
|
||
el('mc-pick').addEventListener('click', async () => {
|
||
const p = await window.api.mcPickFolder();
|
||
if (p) { el('mc-path').value = p; await scanMc(); }
|
||
});
|
||
el('mc-scan').addEventListener('click', scanMc);
|
||
el('mc-path').addEventListener('keydown', (e) => { if (e.key === 'Enter') scanMc(); });
|
||
el('mc-all').addEventListener('change', (e) => {
|
||
document.querySelectorAll('#mc-list input[type=checkbox]').forEach((c) => { c.checked = e.target.checked; });
|
||
updateMcButton();
|
||
});
|
||
el('mc-list').addEventListener('change', updateMcButton);
|
||
el('mc-do').addEventListener('click', runMcImport);
|
||
el('cf-pick').addEventListener('click', async () => {
|
||
const p = await window.api.cfPickFolder();
|
||
if (p) { el('cf-path').value = p; await scanCf(); }
|
||
});
|
||
el('cf-scan').addEventListener('click', scanCf);
|
||
el('cf-path').addEventListener('keydown', (e) => { if (e.key === 'Enter') scanCf(); });
|
||
el('cf-all').addEventListener('change', (e) => {
|
||
document.querySelectorAll('#cf-list input[type=checkbox]').forEach((c) => { c.checked = e.target.checked; });
|
||
updateCfButton();
|
||
});
|
||
el('cf-list').addEventListener('change', updateCfButton);
|
||
el('cf-do').addEventListener('click', runCfImport);
|
||
el('mmc-pick').addEventListener('click', async () => {
|
||
const p = await window.api.mmcPickFolder();
|
||
if (p) { el('mmc-path').value = p; await scanMmc(); }
|
||
});
|
||
el('mmc-scan').addEventListener('click', scanMmc);
|
||
el('mmc-path').addEventListener('keydown', (e) => { if (e.key === 'Enter') scanMmc(); });
|
||
el('mmc-all').addEventListener('change', (e) => {
|
||
document.querySelectorAll('#mmc-list input[type=checkbox]').forEach((c) => { c.checked = e.target.checked; });
|
||
updateMmcButton();
|
||
});
|
||
el('mmc-list').addEventListener('change', updateMmcButton);
|
||
el('mmc-do').addEventListener('click', runMmcImport);
|
||
el('tp-search-btn').addEventListener('click', searchTechnicPlatform);
|
||
el('tp-query').addEventListener('keydown', (e) => { if (e.key === 'Enter') searchTechnicPlatform(); });
|
||
window.api.onTechnicPlatformProgress((p) => {
|
||
el('tp-status').textContent = `Lade Mods … ${p.current}/${p.total}${p.name ? ' (' + p.name + ')' : ''}`;
|
||
});
|
||
el('technic-pick').addEventListener('click', async () => {
|
||
const p = await window.api.technicPickFolder();
|
||
if (p) { el('technic-path').value = p; await scanTechnic(); }
|
||
});
|
||
el('technic-scan').addEventListener('click', scanTechnic);
|
||
el('technic-path').addEventListener('keydown', (e) => { if (e.key === 'Enter') scanTechnic(); });
|
||
el('technic-all').addEventListener('change', (e) => {
|
||
document.querySelectorAll('#technic-list input[type=checkbox]').forEach((c) => { c.checked = e.target.checked; });
|
||
updateTechnicButton();
|
||
});
|
||
el('technic-list').addEventListener('change', updateTechnicButton);
|
||
el('technic-do').addEventListener('click', runTechnicImport);
|
||
window.api.onMcProgress((p) => {
|
||
el('mc-progress').textContent = `Übernehme … ${p.current}/${p.total}`;
|
||
el('mc-progress').classList.remove('hidden');
|
||
});
|
||
window.api.onCfProgress((p) => {
|
||
el('cf-progress').textContent = `Übernehme … ${p.current}/${p.total}`;
|
||
el('cf-progress').classList.remove('hidden');
|
||
});
|
||
window.api.onMmcProgress((p) => {
|
||
el('mmc-progress').textContent = `Übernehme … ${p.current}/${p.total}`;
|
||
el('mmc-progress').classList.remove('hidden');
|
||
});
|
||
window.api.onTechnicProgress((p) => {
|
||
el('technic-progress').textContent = `Übernehme … ${p.current}/${p.total}`;
|
||
el('technic-progress').classList.remove('hidden');
|
||
});
|
||
|
||
// Bedrock
|
||
el('btn-bedrock').addEventListener('click', openBedrock);
|
||
el('bd-play').addEventListener('click', async () => {
|
||
const btn = el('bd-play');
|
||
// Ein Klick löst eine nicht abbrechbare App-Aktivierung aus, die etwas
|
||
// dauern kann – Button sperren, damit ein ungeduldiger zweiter Klick
|
||
// nicht zu einem doppelt geöffneten Spiel führt.
|
||
if (btn.disabled) return;
|
||
btn.disabled = true;
|
||
toast('Bedrock wird gestartet …');
|
||
try {
|
||
const res = await window.api.bedrockLaunch();
|
||
if (res && res.ok) {
|
||
if (!res.confirmed) {
|
||
toast('Start ausgelöst, konnte aber nicht bestätigt werden. Falls sich nichts öffnet, bitte erneut versuchen.');
|
||
}
|
||
} else {
|
||
toast('Start fehlgeschlagen: ' + ((res && res.message) || 'unbekannter Fehler'), true);
|
||
}
|
||
} finally {
|
||
btn.disabled = false;
|
||
}
|
||
});
|
||
el('bd-import').addEventListener('click', async () => {
|
||
const res = await window.api.bedrockInstallAddon();
|
||
if (res.canceled) return;
|
||
if (res.ok) toast('Import gestartet – Minecraft übernimmt die Installation.');
|
||
else toast(res.message || 'Import fehlgeschlagen.', true);
|
||
});
|
||
el('bd-folder').addEventListener('click', () => window.api.bedrockOpenFolder(bedrockKind));
|
||
document.querySelectorAll('.btab').forEach((t) =>
|
||
t.addEventListener('click', () => switchBedrockTab(t.dataset.kind)));
|
||
|
||
// Freunde
|
||
el('btn-friends').addEventListener('click', openFriends);
|
||
el('friends-add-btn').addEventListener('click', sendeFreundschaftsanfrage);
|
||
el('friends-add-input').addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter') sendeFreundschaftsanfrage();
|
||
});
|
||
new MutationObserver(() => {
|
||
if (el('modal-friends').classList.contains('hidden')) { clearInterval(friendsAutoRefresh); friendsAutoRefresh = null; }
|
||
}).observe(el('modal-friends'), { attributes: true, attributeFilter: ['class'] });
|
||
|
||
// Konsole
|
||
el('btn-console').addEventListener('click', openConsole);
|
||
el('console-clear').addEventListener('click', () => { consoleBuffer = ''; el('console-out').textContent = ''; });
|
||
el('console-copy').addEventListener('click', async () => {
|
||
try { await navigator.clipboard.writeText(consoleBuffer); toast('Log kopiert.'); }
|
||
catch { toast('Kopieren nicht möglich.', true); }
|
||
});
|
||
|
||
// Instanz-Dialog
|
||
el('btn-save-instance').addEventListener('click', saveInstance);
|
||
el('f-version-type').addEventListener('change', () => fillVersionSelect(el('f-version').value));
|
||
el('f-loader').addEventListener('change', () => updateLoaderVersions());
|
||
el('f-version').addEventListener('change', () => updateLoaderVersions());
|
||
|
||
el('f-optifine-install').addEventListener('click', async () => {
|
||
if (!editingId) return;
|
||
const btn = el('f-optifine-install');
|
||
const status = el('f-optifine-status');
|
||
btn.disabled = true;
|
||
status.textContent = 'Minecraft wird vorbereitet …';
|
||
try {
|
||
const res = await window.api.optifineSetup(editingId);
|
||
if (res.canceled) { status.textContent = ''; return; }
|
||
if (!res.ok) { status.textContent = '✖ ' + (res.message || 'Fehlgeschlagen.'); toast(res.message || 'OptiFine-Installer konnte nicht gestartet werden.', true); return; }
|
||
status.textContent = `Installer gestartet – Zielordner wurde in die Zwischenablage kopiert: ${res.sharedDir} – `
|
||
+ 'im OptiFine-Fenster als Minecraft-Ordner einfügen, „Install" klicken, danach hier bestätigen.';
|
||
el('f-optifine-confirm').classList.remove('hidden');
|
||
} finally {
|
||
btn.disabled = false;
|
||
}
|
||
});
|
||
|
||
el('f-optifine-confirm').addEventListener('click', async () => {
|
||
if (!editingId) return;
|
||
const btn = el('f-optifine-confirm');
|
||
const status = el('f-optifine-status');
|
||
btn.disabled = true;
|
||
try {
|
||
const res = await window.api.optifineConfirm(editingId);
|
||
if (!res.ok) { status.textContent = '✖ ' + (res.message || 'Nicht gefunden.'); toast(res.message || 'OptiFine-Version wurde nicht gefunden.', true); return; }
|
||
toast('OptiFine ' + res.versionId + ' übernommen.');
|
||
status.textContent = '✔ Übernommen: ' + res.versionId;
|
||
const sel = el('f-loader-version');
|
||
if (![...sel.options].some((o) => o.value === res.versionId)) {
|
||
const o = document.createElement('option');
|
||
o.value = res.versionId; o.textContent = res.versionId;
|
||
sel.appendChild(o);
|
||
}
|
||
sel.value = res.versionId;
|
||
btn.classList.add('hidden');
|
||
} finally {
|
||
btn.disabled = false;
|
||
}
|
||
});
|
||
el('f-pick-image').addEventListener('click', async () => {
|
||
const p = await window.api.pickImage();
|
||
if (!p) return;
|
||
pendingImagePath = p;
|
||
pendingClearImage = false;
|
||
const data = await window.api.fileDataUri(p);
|
||
setIconPreview(data);
|
||
});
|
||
el('f-clear-image').addEventListener('click', () => {
|
||
pendingImagePath = null;
|
||
pendingClearImage = true;
|
||
setIconPreview(null);
|
||
});
|
||
|
||
// Rechte Aktionsleiste (Instanz-Aktionen)
|
||
el('p-play').addEventListener('click', () => currentDetailId && launch(currentDetailId));
|
||
el('p-offline').addEventListener('click', () => currentDetailId && launch(currentDetailId));
|
||
el('p-shortcut').addEventListener('click', async () => {
|
||
if (!currentDetailId) return;
|
||
const inst = await window.api.getInstance(currentDetailId);
|
||
const eingabe = await askPrompt({
|
||
title: 'Verknüpfung erstellen',
|
||
text: 'Name der Desktop-Verknüpfung (ohne .lnk):',
|
||
label: 'Name',
|
||
value: inst ? inst.name : '',
|
||
okLabel: 'Erstellen',
|
||
});
|
||
if (eingabe == null) return; // abgebrochen
|
||
const res = await window.api.createShortcut(currentDetailId, eingabe.trim());
|
||
if (res.ok) toast('Verknüpfung auf dem Desktop erstellt.');
|
||
else toast(res.message || 'Verknüpfung fehlgeschlagen.', true);
|
||
});
|
||
el('p-edit').addEventListener('click', async () => { if (!currentDetailId) return; const inst = await window.api.getInstance(currentDetailId); if (inst) openEdit(inst); });
|
||
el('p-notes').addEventListener('click', async () => { if (!currentDetailId) return; const inst = await window.api.getInstance(currentDetailId); if (inst) openEdit(inst); });
|
||
el('p-groupline').addEventListener('click', async () => { if (!currentDetailId) return; const inst = await window.api.getInstance(currentDetailId); if (inst) openEdit(inst); });
|
||
el('p-mods').addEventListener('click', openMods);
|
||
el('p-worlds').addEventListener('click', openContent);
|
||
el('p-screens').addEventListener('click', openScreens);
|
||
el('p-record').addEventListener('click', openRecording);
|
||
el('record-search-retry').addEventListener('click', startWindowSearch);
|
||
el('record-toggle').addEventListener('click', () => {
|
||
if (!recordingActive()) startMcRecording();
|
||
else stopMcRecording();
|
||
});
|
||
el('record-open-folder').addEventListener('click', async () => {
|
||
const inst = await window.api.getInstance(currentDetailId);
|
||
window.api.recordingOpenFolder(inst ? inst.name : mcLastSavedInstanceName);
|
||
});
|
||
el('record-video-edit').addEventListener('click', () => show('modal-record-video'));
|
||
el('record-resolution').addEventListener('change', (e) => {
|
||
mcResolution = e.target.value;
|
||
wendeVideoEinstellungenAn();
|
||
updateVideoSummary();
|
||
speichereRecordEinstellungen();
|
||
});
|
||
el('record-fps').addEventListener('change', (e) => {
|
||
mcFramerate = parseInt(e.target.value, 10);
|
||
wendeVideoEinstellungenAn();
|
||
updateVideoSummary();
|
||
speichereRecordEinstellungen();
|
||
});
|
||
el('record-quality').addEventListener('change', (e) => {
|
||
mcQuality = e.target.value;
|
||
updateVideoSummary();
|
||
speichereRecordEinstellungen();
|
||
});
|
||
el('record-audio-edit').addEventListener('click', () => show('modal-record-audio'));
|
||
el('record-src-system').addEventListener('change', () => { updateAudioSummary(); speichereRecordEinstellungen(); });
|
||
el('record-src-game').addEventListener('change', () => { updateAudioSummary(); speichereRecordEinstellungen(); });
|
||
el('record-vol-system').addEventListener('input', (e) => {
|
||
mcVolSystem = e.target.value / 100;
|
||
if (mcGainSystem) mcGainSystem.gain.value = mcVolSystem;
|
||
});
|
||
el('record-vol-system').addEventListener('change', speichereRecordEinstellungen);
|
||
el('record-vol-game').addEventListener('input', (e) => {
|
||
mcVolGame = e.target.value / 100;
|
||
if (mcGainGame) mcGainGame.gain.value = mcVolGame;
|
||
});
|
||
el('record-vol-game').addEventListener('change', speichereRecordEinstellungen);
|
||
el('record-mic-list').addEventListener('change', (e) => {
|
||
if (e.target.classList.contains('record-mic-checkbox')) { updateAudioSummary(); speichereRecordEinstellungen(); }
|
||
if (e.target.classList.contains('record-mic-vol')) speichereRecordEinstellungen();
|
||
});
|
||
el('record-mic-list').addEventListener('input', (e) => {
|
||
if (!e.target.classList.contains('record-mic-vol')) return;
|
||
const deviceId = e.target.dataset.device;
|
||
const vol = e.target.value / 100;
|
||
mcVolMics.set(deviceId, vol);
|
||
if (mcGainMics.has(deviceId)) mcGainMics.get(deviceId).gain.value = vol;
|
||
});
|
||
el('p-logs').addEventListener('click', openLogs);
|
||
el('p-servers').addEventListener('click', openServers);
|
||
el('p-modpack').addEventListener('click', openModpackUpdate);
|
||
el('mp-do').addEventListener('click', doModpackUpdate);
|
||
el('srv-add').addEventListener('click', addServer);
|
||
el('srv-ip').addEventListener('keydown', (e) => { if (e.key === 'Enter') addServer(); });
|
||
el('srv-copy-do').addEventListener('click', doServerCopy);
|
||
el('login-skin').addEventListener('click', openSkin);
|
||
el('skin-pick').addEventListener('click', waehleSkinDatei);
|
||
el('skin-variante').addEventListener('change', skinVarianteGewechselt);
|
||
el('skin-apply').addEventListener('click', setzeSkin);
|
||
el('skin-reset').addEventListener('click', setzeSkinZurueck);
|
||
el('logs-select').addEventListener('change', (e) => ladeLog(e.target.value));
|
||
el('logs-folder').addEventListener('click', async () => {
|
||
const ok = await window.api.logsOpenFolder(currentDetailId);
|
||
if (!ok) toast('Log-Ordner nicht gefunden.', true);
|
||
});
|
||
el('logs-copy').addEventListener('click', async () => {
|
||
if (!aktuellesLog) return;
|
||
try { await navigator.clipboard.writeText(aktuellesLog); toast('Log kopiert.'); }
|
||
catch { toast('Kopieren fehlgeschlagen.', true); }
|
||
});
|
||
el('p-mcfolder').addEventListener('click', () => currentDetailId && window.api.openGameDir(currentDetailId));
|
||
el('p-configfolder').addEventListener('click', () => currentDetailId && window.api.openSubdir(currentDetailId, 'config'));
|
||
el('p-instfolder').addEventListener('click', () => currentDetailId && window.api.openInstanceFolder(currentDetailId));
|
||
el('p-export').addEventListener('click', async () => {
|
||
if (!currentDetailId) return;
|
||
const res = await window.api.exportInstance(currentDetailId);
|
||
if (res.canceled) return;
|
||
if (res.ok) toast('Exportiert: ' + res.path); else toast(res.message || 'Export fehlgeschlagen.', true);
|
||
});
|
||
el('p-copy').addEventListener('click', async () => {
|
||
if (!currentDetailId) return;
|
||
const copy = await window.api.duplicateInstance(currentDetailId);
|
||
await reload(); await selectInstance(copy.id); toast('Kopiert: ' + copy.name);
|
||
});
|
||
el('p-delete').addEventListener('click', async () => {
|
||
const ids = orderedSelectedInstanceIds();
|
||
if (!ids.length) return;
|
||
if (ids.length === 1) {
|
||
const inst = await window.api.getInstance(ids[0]);
|
||
if (!inst) return;
|
||
askConfirm('Instanz löschen',
|
||
`„${inst.name}" wird mit allen Dateien unwiderruflich gelöscht. Fortfahren?`,
|
||
'Endgültig löschen',
|
||
async () => {
|
||
await window.api.deleteInstance(ids[0]);
|
||
selectedInstanceIds.clear();
|
||
await reload();
|
||
await selectInstance(instances.length ? instances[0].id : null);
|
||
toast('Gelöscht.');
|
||
});
|
||
return;
|
||
}
|
||
|
||
const names = instances.filter((inst) => selectedInstanceIds.has(inst.id)).map((inst) => inst.name);
|
||
const preview = names.slice(0, 3).map((name) => `„${name}"`).join(', ');
|
||
const more = names.length > 3 ? ` und ${names.length - 3} weitere` : '';
|
||
askConfirm('Instanzen löschen',
|
||
`${ids.length} Instanzen (${preview}${more}) werden mit allen Dateien unwiderruflich gelöscht. Fortfahren?`,
|
||
'Endgültig löschen',
|
||
async () => {
|
||
for (const id of ids) await window.api.deleteInstance(id);
|
||
selectedInstanceIds.clear();
|
||
await reload();
|
||
await selectInstance(instances.length ? instances[0].id : null);
|
||
toast(`${ids.length} Instanzen gelöscht.`);
|
||
});
|
||
});
|
||
|
||
// Ordner-Buttons in den Inhalte-/Screenshots-Dialogen
|
||
el('content-open-worlds').addEventListener('click', () => window.api.openSubdir(currentDetailId, 'saves'));
|
||
el('content-open-rp').addEventListener('click', () => window.api.openSubdir(currentDetailId, 'resourcepacks'));
|
||
el('screens-open').addEventListener('click', async () => {
|
||
if (screensAktiverTab === 'records') {
|
||
const inst = await window.api.getInstance(currentDetailId);
|
||
if (inst) window.api.recordingOpenFolder(inst.name);
|
||
} else {
|
||
window.api.openSubdir(currentDetailId, 'screenshots');
|
||
}
|
||
});
|
||
el('screens-select-all').addEventListener('click', () => {
|
||
document.querySelectorAll('#screens-grid .screen-thumb').forEach((t) => {
|
||
if (t.dataset.name) screenSelected.add(t.dataset.name);
|
||
});
|
||
updateScreensSelectionUi();
|
||
});
|
||
el('screens-select-none').addEventListener('click', () => {
|
||
screenSelected.clear();
|
||
updateScreensSelectionUi();
|
||
});
|
||
el('screens-delete-sel').addEventListener('click', () => deleteSelectedScreenshots());
|
||
el('screens-tab-photos').addEventListener('click', () => switchScreensTab('photos'));
|
||
el('screens-tab-records').addEventListener('click', () => switchScreensTab('records'));
|
||
el('records-select-all').addEventListener('click', () => {
|
||
document.querySelectorAll('#records-grid .screen-thumb').forEach((t) => {
|
||
if (t.dataset.name) recordSelected.add(t.dataset.name);
|
||
});
|
||
updateRecordsSelectionUi();
|
||
});
|
||
el('records-select-none').addEventListener('click', () => {
|
||
recordSelected.clear();
|
||
updateRecordsSelectionUi();
|
||
});
|
||
el('records-delete-sel').addEventListener('click', () => deleteSelectedRecords());
|
||
el('content-open-dp').addEventListener('click', () => window.api.openSubdir(currentDetailId, 'datapacks'));
|
||
|
||
// Mod-Dialog
|
||
el('m-search-btn').addEventListener('click', () => loadCatalog(false));
|
||
el('m-query').addEventListener('keydown', (e) => { if (e.key === 'Enter') loadCatalog(false); });
|
||
el('m-sort').addEventListener('change', () => loadCatalog(false));
|
||
el('m-category').addEventListener('change', () => loadCatalog(false));
|
||
if (el('m-source')) el('m-source').addEventListener('change', () => loadCatalog(false));
|
||
el('m-more').addEventListener('click', () => loadCatalog(true));
|
||
el('m-tab-catalog').addEventListener('click', () => switchModTab('catalog'));
|
||
el('m-tab-installed').addEventListener('click', () => switchModTab('installed'));
|
||
el('m-installed-sort').addEventListener('change', (e) => { installedSort = e.target.value; refreshInstalled(); });
|
||
el('m-art').addEventListener('change', modArtGewechselt);
|
||
el('m-open-folder').addEventListener('click', () => window.api.openGameDir(currentDetailId));
|
||
// Automatische Sicherung: unaufdringlich melden, Fehler deutlich
|
||
window.api.onAutoBackup((d) => {
|
||
if (d.ok) toast(d.anlass === 'vor-dem-start' ? 'Sicherung vor dem Start erstellt.' : 'Automatische Sicherung erstellt.');
|
||
else toast('Automatische Sicherung fehlgeschlagen: ' + (d.message || 'unbekannter Grund'), true);
|
||
});
|
||
|
||
// Absturzbericht
|
||
el('crash-copy').addEventListener('click', async () => {
|
||
if (!aktuellerCrash) return;
|
||
try { await navigator.clipboard.writeText(aktuellerCrash.text || ''); toast('Bericht kopiert.'); }
|
||
catch { toast('Kopieren fehlgeschlagen.', true); }
|
||
});
|
||
el('crash-folder').addEventListener('click', async () => {
|
||
const ok = await window.api.crashOpenFolder(currentDetailId);
|
||
if (!ok) toast('Ordner mit den Berichten nicht gefunden.', true);
|
||
});
|
||
el('crash-console').addEventListener('click', () => { hide('modal-crash'); openConsole(); });
|
||
|
||
el('m-add-file').addEventListener('click', async () => {
|
||
const res = await window.api.modAddFile(currentDetailId, modArt);
|
||
if (res.canceled) return;
|
||
if (!res.ok) { toast(res.message || 'Datei konnte nicht hinzugefügt werden.', true); return; }
|
||
const n = res.added.length;
|
||
const f = res.failed.length;
|
||
if (n) toast(`${n} Datei${n === 1 ? '' : 'en'} hinzugefügt${f ? ` · ${f} fehlgeschlagen` : ''}.`, f > 0 && !n);
|
||
else if (f) toast(res.failed[0].message || 'Hinzufügen fehlgeschlagen.', true);
|
||
await refreshInstalled();
|
||
});
|
||
el('m-check-updates').addEventListener('click', checkModUpdates);
|
||
el('m-apply-updates').addEventListener('click', applyModUpdates);
|
||
window.api.onModUpdateProgress((p) => setUpdateStatus(`Suche … ${p.geprueft}/${p.gesamt} (${p.titel})`));
|
||
|
||
// Einstellungen
|
||
el('s-pick-dir').addEventListener('click', async () => {
|
||
const p = await window.api.pickFolder('Instanz-Verzeichnis wählen');
|
||
if (p) el('s-instances-dir').value = p;
|
||
});
|
||
el('s-pick-java').addEventListener('click', async () => {
|
||
const p = await window.api.pickJava();
|
||
if (p) { el('s-java').value = p; await refreshJavaStatus(p); }
|
||
});
|
||
el('s-theme').addEventListener('change', (e) => applyTheme(e.target.value));
|
||
el('s-bgskin-opacity').addEventListener('input', (e) => {
|
||
el('s-bgskin-val').textContent = e.target.value;
|
||
document.documentElement.style.setProperty('--bg-skin-opacity', String(e.target.value / 100));
|
||
});
|
||
// Konto-/Pose-Wechsel sofort anwenden (ohne erst zu speichern)
|
||
const previewSkin = async () => {
|
||
await window.api.saveSettings({
|
||
bgSkin: el('s-bgskin').value,
|
||
bgSkinPose: el('s-bgskin-pose').value,
|
||
});
|
||
await refreshSkinBackground();
|
||
};
|
||
el('s-bgskin').addEventListener('change', previewSkin);
|
||
el('s-bgskin-pose').addEventListener('change', previewSkin);
|
||
const previewSkinCycle = async () => {
|
||
el('s-bgskin').disabled = el('s-bgskin-cycle').checked;
|
||
await window.api.saveSettings({
|
||
bgSkinCycle: el('s-bgskin-cycle').checked,
|
||
bgSkinCycleSeconds: Math.max(5, numOrNull(el('s-bgskin-cycle-seconds').value) || 30),
|
||
});
|
||
await refreshSkinBackground();
|
||
};
|
||
el('s-bgskin-cycle').addEventListener('change', previewSkinCycle);
|
||
el('s-bgskin-cycle-seconds').addEventListener('change', previewSkinCycle);
|
||
el('s-scan-java').addEventListener('click', scanJava);
|
||
el('f-java-detected').addEventListener('change', (e) => {
|
||
if (e.target.value) el('f-java').value = e.target.value;
|
||
});
|
||
el('f-java-pick').addEventListener('click', async () => {
|
||
const p = await window.api.pickJava();
|
||
if (p) { el('f-java').value = p; el('f-java-detected').value = ''; }
|
||
});
|
||
el('f-extra-args-aikar').addEventListener('click', () => {
|
||
const feld = el('f-extra-args');
|
||
const vorhanden = feld.value.trim();
|
||
if (vorhanden.includes('G1NewSizePercent')) { toast('Die GC-Flags stehen schon drin.'); return; }
|
||
feld.value = vorhanden ? vorhanden + ' ' + AIKAR_FLAGS : AIKAR_FLAGS;
|
||
toast('GC-Flags eingefügt.');
|
||
});
|
||
el('s-java-detected').addEventListener('change', async (e) => {
|
||
if (e.target.value) { el('s-java').value = e.target.value; await refreshJavaStatus(e.target.value); }
|
||
});
|
||
el('s-java').addEventListener('change', (e) => refreshJavaStatus(e.target.value.trim()));
|
||
el('s-backup-global').addEventListener('click', runGlobalBackup);
|
||
el('s-restore-global').addEventListener('click', promptGlobalRestore);
|
||
el('btn-save-settings').addEventListener('click', saveSettings);
|
||
if (el('s-cf-test')) {
|
||
el('s-cf-test').addEventListener('click', async () => {
|
||
const st = el('s-cf-test-status');
|
||
const key = el('s-cf-key').value.trim();
|
||
st.textContent = 'Prüfe bei CurseForge …';
|
||
try {
|
||
const res = await window.api.curseforgeTestKey(key);
|
||
st.textContent = (res.ok ? '✓ ' : '✖ ') + (res.message || '');
|
||
if (res.proben) {
|
||
st.textContent += ' [' + res.proben.map((p) => p.name + ':' + p.status).join(', ') + ']';
|
||
}
|
||
if (res.siehtAusWieCurseForge === false && key) {
|
||
st.textContent += ' Hinweis: Erwartet wird ein Studios-Key (cfc_pat_…) oder ein klassischer Key ($2a$10$…).';
|
||
}
|
||
} catch (err) {
|
||
st.textContent = '✖ ' + (err.message || err);
|
||
}
|
||
});
|
||
}
|
||
document.querySelectorAll('.snav').forEach((b) => b.addEventListener('click', () => setSettingsPane(b.dataset.pane)));
|
||
el('s-open-accounts').addEventListener('click', () => { hide('modal-settings'); openLogin(); });
|
||
el('s-add-account').addEventListener('click', () => { hide('modal-settings'); openLogin({ addAccount: true }); });
|
||
|
||
// Updates
|
||
el('upd-install').addEventListener('click', startUpdate);
|
||
el('upd-open').addEventListener('click', openReleasePage);
|
||
if (el('s-check-update')) {
|
||
el('s-check-update').addEventListener('click', async () => {
|
||
renderUpdateStatus({ checking: true });
|
||
const info = await window.api.updateCheck();
|
||
renderUpdateStatus(info);
|
||
if (info.available) { hide('modal-settings'); showUpdateDialog(info); }
|
||
});
|
||
}
|
||
|
||
// Microsoft-Login / Kontoverwaltung
|
||
el('btn-account').addEventListener('click', openLogin);
|
||
el('login-add').addEventListener('click', addAccount);
|
||
el('login-url').addEventListener('click', (e) => {
|
||
e.preventDefault();
|
||
if (loginVerificationUri) window.api.openExternal(loginVerificationUri);
|
||
});
|
||
el('login-copy').addEventListener('click', async () => {
|
||
try { await navigator.clipboard.writeText(el('login-usercode').textContent.trim()); toast('Code kopiert.'); }
|
||
catch { toast('Kopieren nicht möglich.', true); }
|
||
});
|
||
|
||
// Bestätigung
|
||
el('prompt-ok').addEventListener('click', () => {
|
||
const v = el('prompt-input').value;
|
||
closePrompt(v);
|
||
});
|
||
el('prompt-input').addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
closePrompt(el('prompt-input').value);
|
||
}
|
||
});
|
||
// Abbrechen / Schließen des Prompt-Dialogs liefert null
|
||
el('modal-prompt').querySelectorAll('[data-close]').forEach((b) => {
|
||
b.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
closePrompt(null);
|
||
});
|
||
});
|
||
el('modal-prompt').addEventListener('mousedown', (e) => {
|
||
if (e.target === el('modal-prompt')) closePrompt(null);
|
||
});
|
||
|
||
el('confirm-ok').addEventListener('click', async () => {
|
||
hide('modal-confirm');
|
||
if (confirmAction) { const a = confirmAction; confirmAction = null; await a(); }
|
||
});
|
||
|
||
// generisches Schließen
|
||
document.querySelectorAll('[data-close]').forEach((b) =>
|
||
b.addEventListener('click', () => {
|
||
const m = b.closest('.modal');
|
||
if (!m) return;
|
||
if (m.id === 'modal-prompt') { closePrompt(null); return; }
|
||
if (m.id === 'modal-record' && recordingActive()) { confirmStopAndCloseRecording(); return; }
|
||
m.classList.add('hidden');
|
||
}));
|
||
document.querySelectorAll('.modal').forEach((m) =>
|
||
m.addEventListener('mousedown', (e) => {
|
||
if (e.target !== m) return;
|
||
if (m.id === 'modal-prompt') { closePrompt(null); return; }
|
||
if (m.id === 'modal-record' && recordingActive()) { confirmStopAndCloseRecording(); return; }
|
||
m.classList.add('hidden');
|
||
}));
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape') {
|
||
if (!el('ctx-menu').classList.contains('hidden')) { closeContextMenu(); return; }
|
||
if (!el('modal-prompt').classList.contains('hidden')) { closePrompt(null); return; }
|
||
const open = [...document.querySelectorAll('.modal:not(.hidden)')].pop();
|
||
if (open && open.id === 'modal-record' && recordingActive()) { confirmStopAndCloseRecording(); return; }
|
||
if (open) open.classList.add('hidden');
|
||
return;
|
||
}
|
||
|
||
const strg = e.ctrlKey || e.metaKey;
|
||
const modalOffen = document.querySelector('.modal:not(.hidden)');
|
||
const inEingabe = ['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement.tagName)
|
||
|| document.activeElement.isContentEditable;
|
||
|
||
// Strg+F: Suche fokussieren – nicht wenn gerade ein Dialog offen ist,
|
||
// der eine eigene Suche hat (Mods, Modpack-Katalog, Import …)
|
||
if (strg && e.key.toLowerCase() === 'f' && !modalOffen) {
|
||
e.preventDefault();
|
||
el('search').focus();
|
||
el('search').select();
|
||
return;
|
||
}
|
||
// Strg+N: neue Instanz anlegen
|
||
if (strg && e.key.toLowerCase() === 'n' && !modalOffen) {
|
||
e.preventDefault();
|
||
el('btn-new').click();
|
||
return;
|
||
}
|
||
// Entf: markierte Instanz(en) löschen – nicht während man irgendwo tippt
|
||
if (e.key === 'Delete' && !modalOffen && !inEingabe) {
|
||
e.preventDefault();
|
||
el('p-delete').click();
|
||
}
|
||
});
|
||
wireFileDrop();
|
||
}
|
||
|
||
// ---------- Datei-Drag&Drop aufs Fenster (.jar/.mrpack/.zip) ----------
|
||
function wireFileDrop() {
|
||
let dragDepth = 0; // dragenter/-leave feuern auch für Kindelemente
|
||
const istDatei = (e) => e.dataTransfer && [...e.dataTransfer.types].includes('Files');
|
||
|
||
window.addEventListener('dragenter', (e) => {
|
||
if (!istDatei(e)) return;
|
||
e.preventDefault();
|
||
dragDepth++;
|
||
document.body.classList.add('file-drop-active');
|
||
});
|
||
window.addEventListener('dragover', (e) => {
|
||
if (!istDatei(e)) return;
|
||
e.preventDefault();
|
||
});
|
||
window.addEventListener('dragleave', (e) => {
|
||
if (!istDatei(e)) return;
|
||
dragDepth = Math.max(0, dragDepth - 1);
|
||
if (dragDepth === 0) document.body.classList.remove('file-drop-active');
|
||
});
|
||
window.addEventListener('drop', async (e) => {
|
||
if (!istDatei(e)) return;
|
||
e.preventDefault();
|
||
dragDepth = 0;
|
||
document.body.classList.remove('file-drop-active');
|
||
const files = [...(e.dataTransfer.files || [])].filter((f) => f.path);
|
||
for (const f of files) await handleDroppedFile(f.path);
|
||
});
|
||
}
|
||
|
||
async function handleDroppedFile(filePath) {
|
||
const ext = ('.' + filePath.split('.').pop()).toLowerCase();
|
||
if (!['.jar', '.mrpack', '.zip'].includes(ext)) {
|
||
toast('Nicht unterstützter Dateityp: ' + filePath.split(/[\\/]/).pop(), true);
|
||
return;
|
||
}
|
||
// Für .jar: läuft der Mods-Dialog gerade, zählt die dort gewählte
|
||
// Inhaltsart (Mod/Ressourcenpaket/Shader) – sonst landet es als Mod
|
||
// direkt in der aktuell ausgewählten Instanz.
|
||
const modsOpen = !el('modal-mods').classList.contains('hidden');
|
||
const targetArt = modsOpen ? modArt : 'mod';
|
||
const res = await window.api.filesDrop(filePath, currentDetailId, targetArt);
|
||
if (!res.ok) { toast(res.message || 'Import fehlgeschlagen.', true); return; }
|
||
|
||
if (res.kind === 'mod') {
|
||
toast(`Datei hinzugefügt: ${res.added[0]}`);
|
||
if (modsOpen) await refreshInstalled();
|
||
return;
|
||
}
|
||
await reload();
|
||
if (res.instance) {
|
||
toast(`Instanz übernommen: ${res.instance.name}`);
|
||
await selectInstance(res.instance.id);
|
||
} else {
|
||
toast('Import abgeschlossen.');
|
||
}
|
||
}
|
||
|
||
// Welche Öffnen-Funktion (unten im Code definiert) zu welcher
|
||
// Popup-Fenster-Kennung gehört – für den Erstaufruf beim Boot UND für ein
|
||
// erneutes Öffnen desselben, schon existierenden Fensters (popout:reopen).
|
||
const POPOUT_OPENERS = {
|
||
settings: (p) => openSettings(p.pane || new URLSearchParams(location.search).get('pane') || undefined),
|
||
console: () => openConsole(),
|
||
mods: () => openMods(),
|
||
crash: () => zeigeAbsturzbericht(currentDetailId),
|
||
logs: () => openLogs(),
|
||
content: () => openContent(),
|
||
screens: () => openScreens(),
|
||
servers: () => openServers(),
|
||
skin: () => openSkin(),
|
||
bedrock: () => openBedrock(),
|
||
login: (p) => openLogin({ addAccount: p.addAccount === true || p.addAccount === 'true' }),
|
||
import: (p) => openImportDialog(p.kind || new URLSearchParams(location.search).get('kind') || 'zip'),
|
||
update: async () => { try { showUpdateDialog(await window.api.updateGetPending()); } catch { /* egal */ } },
|
||
instance: async (p) => {
|
||
if (p.instanceId) {
|
||
const inst = await window.api.getInstance(p.instanceId);
|
||
if (inst) await openEdit(inst);
|
||
} else {
|
||
await openCreate();
|
||
}
|
||
},
|
||
record: () => openRecording(),
|
||
friends: () => openFriends(),
|
||
};
|
||
|
||
function popoutBoot(params) {
|
||
const p = params || {};
|
||
if (p.instanceId) currentDetailId = p.instanceId;
|
||
const opener = POPOUT_OPENERS[POPOUT];
|
||
if (opener) opener(p);
|
||
}
|
||
|
||
window.addEventListener('DOMContentLoaded', async () => {
|
||
if (POPOUT) {
|
||
document.body.classList.add('is-popout');
|
||
const hostModal = el('modal-' + POPOUT);
|
||
if (hostModal) hostModal.classList.add('popout-host');
|
||
// Innerhalb dieses Fensters kann man von den Einstellungen aus auch zur
|
||
// Lizenz, zum Update-Dialog oder zur Konten-Verwaltung springen (alles
|
||
// normale, weiterhin im selben Fenster gezeigte Modals) – deshalb erst
|
||
// dann das ganze Fenster verstecken, wenn wirklich KEIN Modal mehr offen
|
||
// ist, nicht schon wenn nur das ursprüngliche Popup-Modal geschlossen wird
|
||
new MutationObserver((mutations) => {
|
||
if (!mutations.some((m) => m.target.classList && m.target.classList.contains('modal'))) return;
|
||
if (!document.querySelector('.modal:not(.hidden)')) window.api.popoutHide();
|
||
}).observe(document.body, { attributes: true, attributeFilter: ['class'], subtree: true });
|
||
// Fenster war schon offen (nur versteckt) und wird erneut angefordert –
|
||
// Inhalt neu aufbauen, statt die Seite neu zu laden (sonst wäre z. B. der
|
||
// bisher gesammelte Konsolentext weg)
|
||
window.api.onPopoutReopen((params) => popoutBoot(params));
|
||
if (POPOUT === 'record') window.api.onRecordCloseAttempt(() => confirmStopAndCloseRecording());
|
||
}
|
||
|
||
wire();
|
||
applyIcons();
|
||
try {
|
||
const s = await window.api.getSettings();
|
||
applyTheme(s.theme);
|
||
iso3dEnabled = s.iso3dIcons !== false;
|
||
consoleMaxChars = Math.max(50000, (s.consoleMaxLines || 100000) * 3);
|
||
el('btn-friends').classList.toggle('hidden', s.friendsEnabled === false);
|
||
} catch { applyTheme('dark'); }
|
||
// Zahl offener Freundschaftsanfragen am "Freunde"-Knopf (nur Hauptfenster
|
||
// zeigt die Werkzeugleiste)
|
||
if (!POPOUT) {
|
||
const zeigeAnfragenZahl = (zahl) => {
|
||
const badge = el('friends-badge');
|
||
badge.textContent = zahl > 99 ? '99+' : String(zahl);
|
||
badge.classList.toggle('hidden', !zahl);
|
||
el('btn-friends').title = zahl
|
||
? `Freunde – ${zahl} offene Anfrage${zahl === 1 ? '' : 'n'} (ingame: Strg+Alt+F zeigt, wer online ist)`
|
||
: 'Freunde (ingame: Strg+Alt+F zeigt, wer online ist)';
|
||
};
|
||
window.api.onFriendsRequestCount(zeigeAnfragenZahl);
|
||
window.api.friendsRequestCount().then(zeigeAnfragenZahl).catch(() => {});
|
||
}
|
||
await refreshSkinBackground();
|
||
window.api.onLaunchProgress(onLaunchEvt);
|
||
// Servername live in den Titel nachtragen, falls das Aufzeichnungs-Fenster
|
||
// schon offen war, BEVOR die Verbindung zum Server erkannt wurde
|
||
window.api.onRecordingServerDetected(({ id }) => {
|
||
if (POPOUT === 'record' && id === currentDetailId) aktualisiereRecordTitel();
|
||
});
|
||
window.api.onBackupProgress((p) => applyBackupProgressEvent(p.mode, p.percent || 0, p.detail || ''));
|
||
window.api.onAuthCode(onAuthCode);
|
||
window.api.onAuthRefreshed(async () => { await refreshAccountButton(); await refreshSkinBackground(); });
|
||
window.api.onAuthHealth(showAuthHealth);
|
||
// Spiel gestartet/beendet ODER das aktive Konto in einem ANDEREN Fenster
|
||
// gewechselt (separater Renderer-Prozess, siehe cross-window state) ->
|
||
// Kontenlisten, Kopfzeile und Skin-Hintergrund hier ebenfalls nachziehen
|
||
window.api.onAccountsChanged(async () => {
|
||
await refreshAllAccountLists();
|
||
await refreshAccountButton();
|
||
await refreshSkinBackground();
|
||
});
|
||
// über eine Desktop-Verknüpfung gestartet -> Instanz auswählen und anwerfen
|
||
// (bei einer Server-Verknüpfung zusätzlich direkt mit dem Server verbinden)
|
||
window.api.onLaunchRequest(async (payload) => {
|
||
const id = payload && typeof payload === 'object' ? payload.id : payload;
|
||
const serverIp = payload && typeof payload === 'object' ? payload.serverIp : null;
|
||
await selectInstance(id);
|
||
launch(id, null, serverIp ? { quickPlayServer: serverIp } : null);
|
||
});
|
||
// Empfänger steht jetzt – dem Hauptprozess Bescheid geben, damit ein über
|
||
// eine Verknüpfung mitgegebener Startwunsch sicher zugestellt werden kann.
|
||
// Nur das Hauptfenster meldet sich hier – sonst könnte das Popup-Fenster
|
||
// (falls es zufällig zuerst fertig ist) den Startwunsch abgreifen, bevor
|
||
// das Hauptfenster überhaupt zuhört.
|
||
if (!POPOUT) window.api.notifyReady();
|
||
window.api.onUpdateAvailable(showUpdateDialog);
|
||
window.api.onUpdateProgress(onUpdateProgress);
|
||
window.api.onUpdateChecked(renderUpdateStatus);
|
||
// Aus dem Einstellungen-Fenster gespeicherte Einstellungen betreffen auch
|
||
// das Hauptfenster – Theme überall, der Rest ist im Popup-Fenster selbst
|
||
// unsichtbar (Sidebar/Symbolleiste sind dort ausgeblendet) und daher nur
|
||
// im Hauptfenster sinnvoll
|
||
window.api.onSettingsChanged((s) => {
|
||
applyTheme(s.theme);
|
||
iso3dEnabled = s.iso3dIcons !== false;
|
||
if (!POPOUT) {
|
||
applyBedrockVisibility();
|
||
refreshSkinBackground();
|
||
reload();
|
||
el('btn-friends').classList.toggle('hidden', s.friendsEnabled === false);
|
||
}
|
||
});
|
||
// Ein Skin-Wechsel oder eine neue/aktualisierte Instanz aus einem anderen
|
||
// Fenster (Skin-/Import-Popup) betrifft ebenfalls nur die Anzeige im
|
||
// Hauptfenster (Skin-Hintergrund, Seitenleiste) – Popups zeigen beides nicht an
|
||
window.api.onSkinChanged(() => { if (!POPOUT) refreshSkinBackground(); });
|
||
window.api.onInstancesChanged(() => { if (!POPOUT) reload(); });
|
||
try { showAuthHealth(await window.api.authHealth()); } catch { /* noch keine Prüfung */ }
|
||
await loadMcIcons(); // echte Minecraft-Icons bereitstellen
|
||
await applyBedrockVisibility();
|
||
await refreshAccountButton();
|
||
|
||
if (!POPOUT) {
|
||
await reload();
|
||
// zuletzt gewählte Instanz wieder markieren (sonst die zuletzt gespielte)
|
||
if (instances.length) {
|
||
let start = null;
|
||
try {
|
||
const s = await window.api.getSettings();
|
||
if (s.lastSelectedId && instances.some((i) => i.id === s.lastSelectedId)) start = s.lastSelectedId;
|
||
} catch { /* Einstellungen nicht lesbar */ }
|
||
await selectInstance(start || instances[0].id); // instances ist nach lastPlayed sortiert
|
||
}
|
||
} else {
|
||
popoutBoot(Object.fromEntries(new URLSearchParams(location.search)));
|
||
}
|
||
});
|