Upload via GUI (143 Dateien)
This commit is contained in:
+436
-66
@@ -541,6 +541,31 @@ function numOrNull(v) { const n = parseInt(v, 10); return Number.isFinite(n) ? n
|
||||
// 'lila' = Aero in dunklem Lila, 'minecraft' = Block-Optik. Alle außer Klassisch
|
||||
// bauen auf den dunklen Regeln auf (data-theme="dark") und überschreiben sie über data-design.
|
||||
const DESIGNS = ['dark', 'light', 'aero', 'lila', 'minecraft'];
|
||||
// ---------- Bewegte Oberfläche (Optionen → Darstellung → Animationen) ----------
|
||||
let uiAnimationen = true;
|
||||
const MIT_STARTANIMATION = new URLSearchParams(location.search).get('startanimation') === '1';
|
||||
function bewegungErlaubt() {
|
||||
return uiAnimationen && !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
}
|
||||
function setzeUiAnimationen(an) {
|
||||
uiAnimationen = an !== false;
|
||||
document.documentElement.classList.toggle('ui-animiert', bewegungErlaubt());
|
||||
if (!bewegungErlaubt()) beendeSkinLive();
|
||||
}
|
||||
|
||||
// Design weich überblenden statt hart umzuschalten (View Transitions von Chromium)
|
||||
function designUeberblenden(theme) {
|
||||
const root = document.documentElement;
|
||||
const vorher = root.getAttribute('data-design') + '/' + root.getAttribute('data-theme');
|
||||
const t = DESIGNS.includes(theme) ? theme : 'dark';
|
||||
const nachher = (['aero', 'lila', 'minecraft'].includes(t) ? t : 'klassisch') + '/' + (t === 'light' ? 'light' : 'dark');
|
||||
// versteckte Fenster (z. B. Hauptfenster im Infobereich) zeichnen nicht –
|
||||
// dort liefe der Übergang nie an, also gleich umschalten
|
||||
if (vorher === nachher || !bewegungErlaubt() || !document.startViewTransition || document.hidden) { applyTheme(theme); return; }
|
||||
document.startViewTransition(() => applyTheme(theme));
|
||||
setTimeout(() => applyTheme(theme), 600); // Sicherheitsnetz, falls der Übergang hängt
|
||||
}
|
||||
|
||||
function applyTheme(theme) {
|
||||
const t = DESIGNS.includes(theme) ? theme : 'dark';
|
||||
const root = document.documentElement;
|
||||
@@ -561,6 +586,73 @@ let importKind = 'zip';
|
||||
let importWarteschlange = [];
|
||||
|
||||
// Nach einem Import nicht gleich schließen, solange weitere vorbereitete Launcher warten
|
||||
// ---------- Fortschritt großer Importe ----------
|
||||
function zuGroesse(b) {
|
||||
const gb = b / 1073741824;
|
||||
if (gb >= 1) return gb.toLocaleString('de-DE', { maximumFractionDigits: 1, minimumFractionDigits: 1 }) + ' GB';
|
||||
return Math.max(0, Math.round(b / 1048576)).toLocaleString('de-DE') + ' MB';
|
||||
}
|
||||
function zuDauer(s) {
|
||||
if (s == null || !isFinite(s)) return '';
|
||||
if (s < 60) return 'noch wenige Sekunden';
|
||||
const m = Math.round(s / 60);
|
||||
if (m < 60) return `noch ca. ${m} Min.`;
|
||||
return `noch ca. ${Math.floor(m / 60)} Std. ${m % 60} Min.`;
|
||||
}
|
||||
function zeigeImportFortschritt(p) {
|
||||
const balken = el('import-lauf-balken');
|
||||
const leiste = balken.parentElement;
|
||||
el('import-lauf').classList.remove('hidden');
|
||||
if (p.phase === 'zaehlen') {
|
||||
el('import-lauf-titel').textContent = 'Größe wird ermittelt …';
|
||||
leiste.classList.add('unbestimmt');
|
||||
el('import-lauf-zeile').textContent = `${p.dateien.toLocaleString('de-DE')} ${p.dateien === 1 ? 'Datei' : 'Dateien'} · ${zuGroesse(p.bytes)} gefunden`;
|
||||
el('import-lauf-datei').textContent = '';
|
||||
return;
|
||||
}
|
||||
leiste.classList.remove('unbestimmt');
|
||||
const titel = p.von > 1 ? `Instanz ${p.nr} von ${p.von}: ${p.instanz}` : `Übernehme ${p.instanz}`;
|
||||
if (p.phase === 'modpack') {
|
||||
el('import-lauf-titel').textContent = `${titel} – Modpack-Verknüpfung …`;
|
||||
return;
|
||||
}
|
||||
el('import-lauf-titel').textContent = titel;
|
||||
const anteil = p.gesamtBytes > 0 ? Math.min(1, p.bytes / p.gesamtBytes) : (p.gesamtDateien ? p.dateien / p.gesamtDateien : 0);
|
||||
balken.style.width = (anteil * 100).toFixed(1) + '%';
|
||||
const teile = [`${zuGroesse(p.bytes)} von ${zuGroesse(p.gesamtBytes)} (${Math.round(anteil * 100)} %)`];
|
||||
if (p.tempo > 0) teile.push(zuGroesse(p.tempo) + '/s');
|
||||
const rest = zuDauer(p.restSekunden);
|
||||
if (rest) teile.push(rest);
|
||||
el('import-lauf-zeile').textContent = teile.join(' · ');
|
||||
el('import-lauf-datei').textContent = p.datei ? `${p.dateien.toLocaleString('de-DE')} / ${p.gesamtDateien.toLocaleString('de-DE')} Dateien · ${p.datei}` : '';
|
||||
}
|
||||
|
||||
/*
|
||||
* Führt einen Launcher-Import mit Fortschrittsanzeige aus. Gibt das Ergebnis
|
||||
* zurück – oder null, wenn er gar nicht erst lief (z. B. zu wenig Platz).
|
||||
*/
|
||||
async function importMitFortschritt(aufruf) {
|
||||
el('import-lauf-titel').textContent = 'Wird vorbereitet …';
|
||||
el('import-lauf-zeile').textContent = '';
|
||||
el('import-lauf-datei').textContent = '';
|
||||
el('import-lauf-balken').style.width = '0';
|
||||
el('import-lauf-abbrechen').disabled = false;
|
||||
el('import-lauf').classList.remove('hidden');
|
||||
let res;
|
||||
try { res = await aufruf(); }
|
||||
catch (err) { res = { ok: false, message: err.message }; }
|
||||
el('import-lauf').classList.add('hidden');
|
||||
if (!res || !res.ok) {
|
||||
toast(res && res.message ? res.message : 'Import fehlgeschlagen.', true);
|
||||
return null;
|
||||
}
|
||||
if (res.uebersprungen && res.uebersprungen.length) {
|
||||
// nach der Zusammenfassung zeigen, sonst überschreibt diese den Hinweis
|
||||
setTimeout(() => toast(`${res.uebersprungen.length} Datei${res.uebersprungen.length === 1 ? '' : 'en'} konnte${res.uebersprungen.length === 1 ? '' : 'n'} nicht kopiert werden (z. B. gerade geöffnet) und wurde${res.uebersprungen.length === 1 ? '' : 'n'} übersprungen.`, true), 3000);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
function importAbgeschlossen(kind) {
|
||||
importWarteschlange = importWarteschlange.filter((k) => k !== kind);
|
||||
if (importWarteschlange.length) {
|
||||
@@ -864,12 +956,13 @@ async function runMcImport() {
|
||||
el('mc-progress').textContent = 'Übernehme …';
|
||||
el('mc-progress').classList.remove('hidden');
|
||||
|
||||
const res = await window.api.mcImport(entries, copy);
|
||||
const res = await importMitFortschritt(() => window.api.mcImport(entries, copy));
|
||||
if (!res) { el('mc-do').disabled = false; el('mc-progress').classList.add('hidden'); return; }
|
||||
await reload();
|
||||
importAbgeschlossen('mc');
|
||||
const n = res.imported.length;
|
||||
const f = res.failed.length;
|
||||
toast(`${n} Profil${n === 1 ? '' : 'e'} aus dem Minecraft Launcher übernommen${f ? ` · ${f} fehlgeschlagen` : ''}.`, f > 0);
|
||||
toast(`${n} Profil${n === 1 ? '' : 'e'} aus dem Minecraft Launcher übernommen${f ? ` · ${f} fehlgeschlagen` : ''}${res.abgebrochen ? ' – Rest abgebrochen' : ''}.`, f > 0);
|
||||
if (instances.length) await selectInstance(instances[0].id);
|
||||
}
|
||||
|
||||
@@ -934,12 +1027,13 @@ async function runCfImport() {
|
||||
el('cf-progress').textContent = 'Übernehme …';
|
||||
el('cf-progress').classList.remove('hidden');
|
||||
|
||||
const res = await window.api.cfImport(entries, copy);
|
||||
const res = await importMitFortschritt(() => window.api.cfImport(entries, copy));
|
||||
if (!res) { el('cf-do').disabled = false; el('cf-progress').classList.add('hidden'); return; }
|
||||
await reload();
|
||||
importAbgeschlossen('cf');
|
||||
const n = res.imported.length;
|
||||
const f = res.failed.length;
|
||||
toast(`${n} CurseForge-Instanz${n === 1 ? '' : 'en'} übernommen${f ? ` · ${f} fehlgeschlagen` : ''}.`, f > 0);
|
||||
toast(`${n} CurseForge-Instanz${n === 1 ? '' : 'en'} übernommen${f ? ` · ${f} fehlgeschlagen` : ''}${res.abgebrochen ? ' – Rest abgebrochen' : ''}.`, f > 0);
|
||||
if (instances.length) await selectInstance(instances[0].id);
|
||||
}
|
||||
|
||||
@@ -1008,13 +1102,14 @@ async function runMrImport() {
|
||||
el('mr-progress').textContent = 'Übernehme …';
|
||||
el('mr-progress').classList.remove('hidden');
|
||||
|
||||
const res = await window.api.mrImport(entries, copy);
|
||||
const res = await importMitFortschritt(() => window.api.mrImport(entries, copy));
|
||||
if (!res) { el('mr-do').disabled = false; el('mr-progress').classList.add('hidden'); return; }
|
||||
await reload();
|
||||
importAbgeschlossen('mr');
|
||||
const n = res.imported.length;
|
||||
const f = res.failed.length;
|
||||
const ohneLink = res.imported.filter((i) => i.modpack === false).length;
|
||||
toast(`${n} Instanz${n === 1 ? '' : 'en'} aus der Modrinth App übernommen${f ? ` · ${f} fehlgeschlagen` : ''}`
|
||||
toast(`${n} Instanz${n === 1 ? '' : 'en'} aus der Modrinth App übernommen${f ? ` · ${f} fehlgeschlagen` : ''}${res.abgebrochen ? ' – Rest abgebrochen' : ''}`
|
||||
+ (ohneLink ? ` · ${ohneLink}× Modpack-Verknüpfung nicht möglich (offline?)` : '') + '.', f > 0);
|
||||
if (instances.length) await selectInstance(instances[0].id);
|
||||
}
|
||||
@@ -1080,12 +1175,13 @@ async function runMmcImport() {
|
||||
el('mmc-progress').textContent = 'Übernehme …';
|
||||
el('mmc-progress').classList.remove('hidden');
|
||||
|
||||
const res = await window.api.mmcImport(entries, copy);
|
||||
const res = await importMitFortschritt(() => window.api.mmcImport(entries, copy));
|
||||
if (!res) { el('mmc-do').disabled = false; el('mmc-progress').classList.add('hidden'); return; }
|
||||
await reload();
|
||||
importAbgeschlossen('mmc');
|
||||
const n = res.imported.length;
|
||||
const f = res.failed.length;
|
||||
toast(`${n} Instanz${n === 1 ? '' : 'en'} übernommen${f ? ` · ${f} fehlgeschlagen` : ''}.`, f > 0);
|
||||
toast(`${n} Instanz${n === 1 ? '' : 'en'} übernommen${f ? ` · ${f} fehlgeschlagen` : ''}${res.abgebrochen ? ' – Rest abgebrochen' : ''}.`, f > 0);
|
||||
if (instances.length) await selectInstance(instances[0].id);
|
||||
}
|
||||
|
||||
@@ -1206,12 +1302,13 @@ async function runTechnicImport() {
|
||||
el('technic-progress').textContent = 'Übernehme …';
|
||||
el('technic-progress').classList.remove('hidden');
|
||||
|
||||
const res = await window.api.technicImport(entries, copy);
|
||||
const res = await importMitFortschritt(() => window.api.technicImport(entries, copy));
|
||||
if (!res) { el('technic-do').disabled = false; el('technic-progress').classList.add('hidden'); return; }
|
||||
await reload();
|
||||
importAbgeschlossen('technic');
|
||||
const n = res.imported.length;
|
||||
const f = res.failed.length;
|
||||
toast(`${n} Technic-Modpack${n === 1 ? '' : 's'} übernommen${f ? ` · ${f} fehlgeschlagen` : ''}.`, f > 0);
|
||||
toast(`${n} Technic-Modpack${n === 1 ? '' : 's'} übernommen${f ? ` · ${f} fehlgeschlagen` : ''}${res.abgebrochen ? ' – Rest abgebrochen' : ''}.`, f > 0);
|
||||
if (instances.length) await selectInstance(instances[0].id);
|
||||
}
|
||||
|
||||
@@ -1427,6 +1524,55 @@ function closeContextMenu() {
|
||||
el('ctx-menu').classList.add('hidden');
|
||||
}
|
||||
|
||||
// ---------- Konto-Menü (Klick auf den Kontoknopf) ----------
|
||||
function schliesseKontoMenue() {
|
||||
el('konto-menu').classList.add('hidden');
|
||||
}
|
||||
|
||||
async function oeffneKontoMenue() {
|
||||
const menu = el('konto-menu');
|
||||
if (!menu.classList.contains('hidden')) { schliesseKontoMenue(); return; }
|
||||
closeContextMenu();
|
||||
// feste, alphabetische Reihenfolge – sonst springt das aktive Konto nach oben
|
||||
const konten = (await window.api.authAccounts()).slice().sort((a, b) => a.name.localeCompare(b.name, 'de', { sensitivity: 'base' }));
|
||||
menu.innerHTML = '';
|
||||
const eintrag = (inhalt, aktion, klasse = '') => {
|
||||
const b = document.createElement('button');
|
||||
b.className = 'ctx-item konto-eintrag ' + klasse;
|
||||
b.innerHTML = inhalt;
|
||||
b.addEventListener('click', async () => { schliesseKontoMenue(); await aktion(); });
|
||||
menu.appendChild(b);
|
||||
return b;
|
||||
};
|
||||
for (const k of konten) {
|
||||
const b = eintrag(
|
||||
`<span class="konto-haken">${k.active ? '✓' : ''}</span><img alt="" /><span class="konto-name">${escapeHtml(k.name)}</span>${k.playing ? '<span class="konto-spielt">spielt</span>' : ''}`,
|
||||
async () => {
|
||||
if (k.active) return;
|
||||
await window.api.authSetActive(k.id);
|
||||
await refreshAccountButton();
|
||||
await refreshAllAccountLists();
|
||||
await refreshSkinBackground();
|
||||
toast('Aktives Konto: ' + k.name);
|
||||
},
|
||||
k.active ? 'aktiv' : '');
|
||||
b.title = k.active ? 'Wird beim nächsten Start verwendet' : 'Für den nächsten Start verwenden';
|
||||
const img = b.querySelector('img');
|
||||
kontoKopf(k.id).then((src) => { img.src = src; });
|
||||
}
|
||||
if (konten.length) menu.appendChild(Object.assign(document.createElement('div'), { className: 'ctx-sep' }));
|
||||
eintrag(`${svgIcon('plus')}<span class="konto-name">Konto hinzufügen…</span>`, () => openLogin({ addAccount: true }));
|
||||
if (konten.some((k) => k.active)) eintrag(`${svgIcon('user')}<span class="konto-name">Skin ändern…</span>`, () => openSkin());
|
||||
eintrag(`${svgIcon('users')}<span class="konto-name">Konten verwalten…</span>`, () => openLogin());
|
||||
|
||||
// unter dem Knopf, rechtsbündig – aber im Fenster bleiben
|
||||
const r = el('btn-account').getBoundingClientRect();
|
||||
menu.classList.remove('hidden');
|
||||
const breite = menu.offsetWidth;
|
||||
menu.style.left = Math.max(6, Math.min(r.right - breite, innerWidth - breite - 6)) + 'px';
|
||||
menu.style.top = (r.bottom + 4) + 'px';
|
||||
}
|
||||
|
||||
function formatVersionLabel(version) {
|
||||
return String(version || '')
|
||||
.replace(/^v/i, '')
|
||||
@@ -1553,7 +1699,7 @@ async function applySkinBackground(uuidOverride) {
|
||||
} else if (s.bgSkin) {
|
||||
uuid = s.bgSkin;
|
||||
}
|
||||
if (!uuid) { root.style.setProperty('--bg-skin', 'none'); return; }
|
||||
if (!uuid) { beendeSkinLive(); root.style.setProperty('--bg-skin', 'none'); return; }
|
||||
|
||||
root.style.setProperty('--bg-skin-opacity', String((s.bgSkinOpacity ?? 55) / 100));
|
||||
|
||||
@@ -1566,6 +1712,11 @@ async function applySkinBackground(uuidOverride) {
|
||||
if (!modell) { try { modell = await window.api.skinModelFor(uuid); } catch { modell = 'classic'; } }
|
||||
let umhang = null;
|
||||
try { const r = await window.api.capeForUuid(uuid); umhang = r ? r.dataUri : null; } catch { /* ohne Umhang */ }
|
||||
// 1a) bewegt (Atmen, Kopf folgt der Maus) – sonst wie bisher als Standbild
|
||||
if (await zeigeLebendigeFigur(`https://crafthead.net/skin/${uuid}`, s.bgSkinPose || 'walking', { modell, umhang })) {
|
||||
root.style.setProperty('--bg-skin', 'none');
|
||||
return;
|
||||
}
|
||||
const local = await renderSkin3D(`https://crafthead.net/skin/${uuid}`, s.bgSkinPose || 'walking', { modell, umhang });
|
||||
if (local) { root.style.setProperty('--bg-skin', `url("${local}")`); return; }
|
||||
|
||||
@@ -1586,6 +1737,119 @@ async function applySkinBackground(uuidOverride) {
|
||||
root.style.setProperty('--bg-skin', 'none');
|
||||
}
|
||||
|
||||
// ---------- Lebendige Skin-Figur im Hintergrund ----------
|
||||
// Statt eines starren Bildes läuft eine echte 3D-Figur in der gewählten Pose:
|
||||
// sie atmet, wiegt sich leicht, der Umhang schwingt, und der Kopf folgt dem
|
||||
// Mauszeiger. Pausiert, solange das Fenster minimiert/im Hintergrund ist.
|
||||
let skinLive = null;
|
||||
|
||||
function skinLiveGroesse() {
|
||||
const r = document.querySelector('.list-area').getBoundingClientRect();
|
||||
// wie der frühere Hintergrund: 52 % Breite × 88 % Höhe, Seitenverhältnis 420:700
|
||||
const w = Math.max(60, Math.min(r.width * 0.52, r.height * 0.88 * 0.6));
|
||||
return { w: Math.round(w), h: Math.round(w / 0.6) };
|
||||
}
|
||||
|
||||
function beendeSkinLive() {
|
||||
if (!skinLive) return;
|
||||
const { viewer, abmelden } = skinLive;
|
||||
skinLive = null;
|
||||
abmelden();
|
||||
try { viewer.dispose(); } catch { /* egal */ }
|
||||
const c = el('skin-live');
|
||||
if (c) c.classList.add('hidden');
|
||||
const flaeche = document.querySelector('.list-area');
|
||||
if (flaeche) flaeche.classList.remove('skin-lebt');
|
||||
}
|
||||
|
||||
async function zeigeLebendigeFigur(quelle, poseKey, opts = {}) {
|
||||
beendeSkinLive();
|
||||
const canvas = el('skin-live');
|
||||
if (POPOUT || !canvas || typeof skinview3d === 'undefined' || !bewegungErlaubt()) return false;
|
||||
const { w, h } = skinLiveGroesse();
|
||||
let viewer;
|
||||
try {
|
||||
viewer = new skinview3d.SkinViewer({ canvas, width: w, height: h, skin: quelle, model: opts.modell === 'slim' ? 'slim' : 'default' });
|
||||
await new Promise((resolve, reject) => {
|
||||
const t = setTimeout(reject, 8000);
|
||||
const check = () => {
|
||||
if (viewer.playerObject && viewer.playerObject.skin && viewer.playerObject.skin.map) { clearTimeout(t); resolve(); }
|
||||
else setTimeout(check, 60);
|
||||
};
|
||||
check();
|
||||
});
|
||||
} catch {
|
||||
try { if (viewer) viewer.dispose(); } catch { /* egal */ }
|
||||
return false;
|
||||
}
|
||||
const pose = SKIN_POSES[poseKey] || SKIN_POSES.walking;
|
||||
viewer.fov = pose.fov;
|
||||
viewer.zoom = pose.zoom;
|
||||
viewer.autoRotate = false;
|
||||
viewer.controls.enabled = false;
|
||||
if (opts.umhang) { try { await viewer.loadCape(opts.umhang); } catch { /* ohne Umhang */ } }
|
||||
|
||||
// Grundhaltung merken: Pose einmal anwenden und Kopf/Umhang/Höhe festhalten
|
||||
const p = viewer.playerObject;
|
||||
pose.apply(p);
|
||||
const kopfBasis = p.skin.head.rotation.clone();
|
||||
const umhangBasis = p.cape ? p.cape.rotation.x : 0;
|
||||
const hoeheBasis = p.position.y;
|
||||
const koerperBasis = p.rotation.y;
|
||||
|
||||
const ziel = { yaw: 0, pitch: 0 };
|
||||
const kopf = { yaw: 0, pitch: 0 };
|
||||
const maus = (e) => {
|
||||
const r = canvas.getBoundingClientRect();
|
||||
const kx = r.left + r.width * 0.5;
|
||||
const ky = r.top + r.height * 0.22; // Kopf sitzt im oberen Fünftel der Figur
|
||||
ziel.yaw = Math.max(-0.75, Math.min(0.75, (e.clientX - kx) / 520));
|
||||
ziel.pitch = Math.max(-0.4, Math.min(0.45, (e.clientY - ky) / 650));
|
||||
};
|
||||
const weg = () => { ziel.yaw = 0; ziel.pitch = 0; };
|
||||
viewer.animation = new skinview3d.FunctionAnimation((pl, t) => {
|
||||
pose.apply(pl); // Arme/Beine jedes Mal frisch aus der Pose
|
||||
const atem = Math.sin(t * 1.8);
|
||||
pl.position.y = hoeheBasis + atem * 0.18; // Brustkorb hebt und senkt sich
|
||||
pl.rotation.y = koerperBasis + Math.sin(t * 0.55) * 0.05;
|
||||
pl.skin.leftArm.rotation.z += atem * 0.03;
|
||||
pl.skin.rightArm.rotation.z -= atem * 0.03;
|
||||
if (pl.cape) pl.cape.rotation.x = umhangBasis + 0.06 + Math.sin(t * 1.3) * 0.05;
|
||||
kopf.yaw += (ziel.yaw - kopf.yaw) * 0.07; // weich nachführen
|
||||
kopf.pitch += (ziel.pitch - kopf.pitch) * 0.07;
|
||||
// ohne Maus schaut die Figur halb zum Betrachter, sonst dem Zeiger hinterher
|
||||
pl.skin.head.rotation.set(kopfBasis.x + kopf.pitch, kopfBasis.y - koerperBasis * 0.45 + kopf.yaw, kopfBasis.z);
|
||||
});
|
||||
|
||||
const sicht = () => { viewer.renderPaused = document.hidden || !document.hasFocus(); };
|
||||
const ro = new ResizeObserver(() => {
|
||||
const g = skinLiveGroesse();
|
||||
viewer.setSize(g.w, g.h);
|
||||
});
|
||||
ro.observe(document.querySelector('.list-area'));
|
||||
window.addEventListener('mousemove', maus);
|
||||
document.documentElement.addEventListener('mouseleave', weg);
|
||||
document.addEventListener('visibilitychange', sicht);
|
||||
window.addEventListener('blur', sicht);
|
||||
window.addEventListener('focus', sicht);
|
||||
sicht();
|
||||
|
||||
skinLive = {
|
||||
viewer,
|
||||
abmelden: () => {
|
||||
ro.disconnect();
|
||||
window.removeEventListener('mousemove', maus);
|
||||
document.documentElement.removeEventListener('mouseleave', weg);
|
||||
document.removeEventListener('visibilitychange', sicht);
|
||||
window.removeEventListener('blur', sicht);
|
||||
window.removeEventListener('focus', sicht);
|
||||
},
|
||||
};
|
||||
canvas.classList.remove('hidden');
|
||||
document.querySelector('.list-area').classList.add('skin-lebt');
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------- Skin-Hintergrund: automatischer Kontenwechsel ----------
|
||||
let skinCycleTimer = null;
|
||||
let skinCycleIndex = 0;
|
||||
@@ -1668,6 +1932,15 @@ const SKIN_POSES = {
|
||||
p.skin.leftLeg.rotation.set(-1.5, 0, 0.12); // Beine angewinkelt
|
||||
p.skin.rightLeg.rotation.set(-1.5, 0, -0.12);
|
||||
} },
|
||||
kopf: { label: 'Büste', fov: 28, zoom: 2.15, apply: (p) => {
|
||||
p.rotation.set(0.08, -0.5, 0);
|
||||
p.position.y = -9.5; // Kopf in die Bildmitte
|
||||
p.skin.head.rotation.set(0.05, 0.22, 0); // Blick leicht zum Betrachter
|
||||
p.skin.leftArm.rotation.set(0, 0, 0.06);
|
||||
p.skin.rightArm.rotation.set(0, 0, -0.06);
|
||||
p.skin.leftLeg.rotation.set(0, 0, 0);
|
||||
p.skin.rightLeg.rotation.set(0, 0, 0);
|
||||
} },
|
||||
spooky: { label: 'Unheimlich', fov: 32, zoom: 0.6, apply: (p) => {
|
||||
p.rotation.set(-0.06, -0.45, 0); // gedreht + leichte Untersicht
|
||||
p.skin.head.rotation.set(0.22, -0.2, 0.28); // Kopf schief gelegt
|
||||
@@ -1685,7 +1958,15 @@ const SKIN_POSES = {
|
||||
* die noch gar nicht hochgeladen ist.
|
||||
* modell: 'default' (breite Arme) oder 'slim' (Alex)
|
||||
*/
|
||||
async function renderSkin3D(quelle, poseKey, opts = {}) {
|
||||
let renderWarteschlange = Promise.resolve();
|
||||
function renderSkin3D(quelle, poseKey, opts = {}) {
|
||||
// nacheinander: alle Aufrufe zeichnen auf dieselbe Leinwand (#skin-canvas)
|
||||
const auftrag = renderWarteschlange.then(() => renderSkin3DJetzt(quelle, poseKey, opts));
|
||||
renderWarteschlange = auftrag.catch(() => null);
|
||||
return auftrag;
|
||||
}
|
||||
|
||||
async function renderSkin3DJetzt(quelle, poseKey, opts = {}) {
|
||||
if (typeof skinview3d === 'undefined') return null;
|
||||
const canvas = el('skin-canvas');
|
||||
const breite = opts.breite || 420;
|
||||
@@ -1848,7 +2129,7 @@ async function refreshSelectionPanel() {
|
||||
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';
|
||||
el('p-groupline').textContent = inst.group ? `Gruppe: ${gruppenAnzeige(inst.group)}` : 'Ohne Gruppe';
|
||||
|
||||
const loaderTxt = inst.minecraft.loader && inst.minecraft.loader !== 'vanilla'
|
||||
? ' · ' + (LOADER_LABEL[inst.minecraft.loader] || inst.minecraft.loader) : '';
|
||||
@@ -1924,6 +2205,8 @@ function sucheSchliessen() {
|
||||
if (document.activeElement === el('search')) el('search').blur();
|
||||
}
|
||||
|
||||
let instanzenEingeblendet = false;
|
||||
|
||||
function render() {
|
||||
const q = el('search').value.trim().toLowerCase();
|
||||
const grid = el('grid');
|
||||
@@ -1935,59 +2218,113 @@ function render() {
|
||||
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);
|
||||
// nach Gruppen (und Untergruppen, "Modpacks/Survival") bündeln
|
||||
const baum = baueGruppenbaum(filtered);
|
||||
const showHeaders = baum.kinder.size > 0;
|
||||
for (const kind of sortierteKinder(baum)) grid.appendChild(baueGruppenSektion(kind, 0, showHeaders));
|
||||
// Instanzen ohne Gruppe zuletzt
|
||||
if (baum.instanzen.length) {
|
||||
grid.appendChild(baueGruppenSektion({ name: 'Ohne Gruppe', pfad: '', kinder: new Map(), instanzen: baum.instanzen }, 0, showHeaders));
|
||||
}
|
||||
// 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);
|
||||
// Beim ersten Anzeigen kurz nacheinander einblenden – nicht nach der
|
||||
// Startanimation (da ist der Fensteraufbau schon der Auftritt)
|
||||
if (!instanzenEingeblendet && instances.length) {
|
||||
instanzenEingeblendet = true;
|
||||
if (bewegungErlaubt() && !MIT_STARTANIMATION && !POPOUT) {
|
||||
grid.querySelectorAll('.group-header, .card').forEach((k, i) => k.style.setProperty('--i', String(Math.min(i, 24))));
|
||||
grid.classList.add('einblenden');
|
||||
setTimeout(() => grid.classList.remove('einblenden'), 1400);
|
||||
}
|
||||
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'}`);
|
||||
}
|
||||
|
||||
// ---------- Gruppen und Untergruppen ----------
|
||||
// Eine Untergruppe steckt im Gruppennamen, getrennt mit "/": "Modpacks/Survival".
|
||||
// Ohne "/" bleibt alles wie bisher (eine Ebene).
|
||||
function gruppenPfad(g) {
|
||||
return String(g || '').split(/[\/\\]/).map((t) => t.trim()).filter(Boolean).join('/');
|
||||
}
|
||||
function gruppenAnzeige(g) {
|
||||
return gruppenPfad(g).split('/').join(' › ');
|
||||
}
|
||||
|
||||
function baueGruppenbaum(liste) {
|
||||
const wurzel = { name: '', pfad: '', kinder: new Map(), instanzen: [] };
|
||||
for (const inst of liste) {
|
||||
let knoten = wurzel;
|
||||
const pfad = gruppenPfad(inst.group);
|
||||
if (pfad) {
|
||||
for (const teil of pfad.split('/')) {
|
||||
if (!knoten.kinder.has(teil)) {
|
||||
knoten.kinder.set(teil, { name: teil, pfad: knoten.pfad ? knoten.pfad + '/' + teil : teil, kinder: new Map(), instanzen: [] });
|
||||
}
|
||||
knoten = knoten.kinder.get(teil);
|
||||
}
|
||||
}
|
||||
knoten.instanzen.push(inst);
|
||||
}
|
||||
return wurzel;
|
||||
}
|
||||
const anzahlImZweig = (k) => k.instanzen.length + [...k.kinder.values()].reduce((s, c) => s + anzahlImZweig(c), 0);
|
||||
const sortierteKinder = (k) => [...k.kinder.values()].sort((a, b) => a.name.localeCompare(b.name, 'de', { numeric: true }));
|
||||
|
||||
// Abschnitt für eine Gruppe samt Untergruppen (rekursiv)
|
||||
function baueGruppenSektion(knoten, tiefe, showHeaders) {
|
||||
const g = knoten.pfad;
|
||||
const zu = collapsedGroups.has(g);
|
||||
const section = document.createElement('div');
|
||||
section.className = 'group-section' + (tiefe ? ' group-sub' : '') + (zu ? ' collapsed' : '');
|
||||
|
||||
if (showHeaders) {
|
||||
const header = document.createElement('div');
|
||||
header.className = 'group-header' + (zu ? ' collapsed' : '');
|
||||
header.title = g ? gruppenAnzeige(g) : 'Instanzen ohne Gruppe';
|
||||
header.innerHTML = `<span class="chev">${zu ? '▸' : '▾'}</span> ${escapeHtml(knoten.name)} <span class="group-count">(${anzahlImZweig(knoten)})</span>`;
|
||||
header.addEventListener('click', () => {
|
||||
if (collapsedGroups.has(g)) collapsedGroups.delete(g); else collapsedGroups.add(g);
|
||||
render();
|
||||
});
|
||||
section.appendChild(header);
|
||||
}
|
||||
const inhalt = document.createElement('div');
|
||||
inhalt.className = 'group-body';
|
||||
if (knoten.instanzen.length) {
|
||||
const gridEl = document.createElement('div');
|
||||
gridEl.className = 'group-grid';
|
||||
for (const inst of knoten.instanzen) gridEl.appendChild(buildCard(inst));
|
||||
inhalt.appendChild(gridEl);
|
||||
}
|
||||
for (const kind of sortierteKinder(knoten)) inhalt.appendChild(baueGruppenSektion(kind, tiefe + 1, showHeaders));
|
||||
section.appendChild(inhalt);
|
||||
|
||||
// Drop-Ziel: Instanz per Drag&Drop in diese (Unter-)Gruppe verschieben –
|
||||
// stopPropagation, damit die innerste Gruppe gewinnt, nicht die äußere
|
||||
section.addEventListener('dragover', (e) => { e.preventDefault(); e.stopPropagation(); section.classList.add('drop-hover'); });
|
||||
section.addEventListener('dragleave', (e) => { if (!section.contains(e.relatedTarget)) section.classList.remove('drop-hover'); });
|
||||
section.addEventListener('drop', async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
section.classList.remove('drop-hover');
|
||||
const dropId = e.dataTransfer.getData('text/plain');
|
||||
const dragged = instances.find((i) => i.id === dropId);
|
||||
if (dragged && gruppenPfad(dragged.group) !== g) {
|
||||
await window.api.updateInstance(dropId, { group: g });
|
||||
await reload();
|
||||
toast('Verschoben nach: ' + (g ? gruppenAnzeige(g) : 'Ohne Gruppe'));
|
||||
}
|
||||
});
|
||||
return section;
|
||||
}
|
||||
|
||||
function fillGroupDatalist() {
|
||||
const groups = [...new Set(instances.map((i) => i.group).filter(Boolean))].sort();
|
||||
// alle Gruppen samt ihrer übergeordneten Ebenen vorschlagen
|
||||
const alle = new Set();
|
||||
for (const i of instances) {
|
||||
const teile = gruppenPfad(i.group).split('/').filter(Boolean);
|
||||
for (let n = 1; n <= teile.length; n++) alle.add(teile.slice(0, n).join('/'));
|
||||
}
|
||||
const groups = [...alle].sort((a, b) => a.localeCompare(b, 'de', { numeric: true }));
|
||||
el('group-list').innerHTML = groups.map((g) => `<option value="${escapeHtml(g)}"></option>`).join('');
|
||||
}
|
||||
|
||||
@@ -2246,7 +2583,7 @@ async function saveInstance() {
|
||||
|
||||
const payload = {
|
||||
name,
|
||||
group: el('f-group').value.trim(),
|
||||
group: gruppenPfad(el('f-group').value),
|
||||
icon: el('f-icon').value,
|
||||
notes: el('f-notes').value.trim(),
|
||||
minecraft: {
|
||||
@@ -4993,6 +5330,8 @@ async function openSettings(pane) {
|
||||
sel.disabled = !!s.bgSkinCycle;
|
||||
el('s-sortby').value = s.sortBy || 'lastPlayed';
|
||||
el('s-iso3d').checked = s.iso3dIcons !== false;
|
||||
el('s-start-animation').checked = s.startAnimation !== false;
|
||||
el('s-ui-animationen').checked = s.uiAnimationen !== false;
|
||||
el('s-bedrock').checked = s.bedrockEnabled !== false;
|
||||
el('s-notify').checked = s.desktopNotifications !== false;
|
||||
el('s-friends').checked = s.friendsEnabled !== false;
|
||||
@@ -5127,6 +5466,8 @@ async function saveSettings() {
|
||||
bgSkinCycleSeconds: Math.max(5, numOrNull(el('s-bgskin-cycle-seconds').value) || 30),
|
||||
sortBy: el('s-sortby').value,
|
||||
iso3dIcons: el('s-iso3d').checked,
|
||||
startAnimation: el('s-start-animation').checked,
|
||||
uiAnimationen: el('s-ui-animationen').checked,
|
||||
bedrockEnabled: el('s-bedrock').checked,
|
||||
desktopNotifications: el('s-notify').checked,
|
||||
friendsEnabled: el('s-friends').checked,
|
||||
@@ -5166,6 +5507,7 @@ async function saveSettings() {
|
||||
// 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
|
||||
setzeUiAnimationen(patch.uiAnimationen);
|
||||
applyTheme(patch.theme);
|
||||
iso3dEnabled = patch.iso3dIcons;
|
||||
hide('modal-settings');
|
||||
@@ -5424,6 +5766,22 @@ function promptGlobalRestore() {
|
||||
}
|
||||
|
||||
// ---------- Microsoft-Login / Kontoverwaltung ----------
|
||||
// Kopf/Büste eines Kontos selbst in 3D zeichnen – so steht die 2. Ebene
|
||||
// (Mütze, Haare, Kragen, Ärmel) wie im Spiel plastisch ab. Fertige Bilder von
|
||||
// crafthead zeigen sie nur flach aufgemalt; die dienen nur noch als Rückfall.
|
||||
const kontoKoepfe = new Map(); // uuid -> data:-Bild
|
||||
async function kontoKopf(uuid) {
|
||||
if (kontoKoepfe.has(uuid)) return kontoKoepfe.get(uuid);
|
||||
const auftrag = (async () => {
|
||||
let modell = 'classic';
|
||||
try { modell = await window.api.skinModelFor(uuid); } catch { /* breite Arme */ }
|
||||
const bild = await renderSkin3D(`https://crafthead.net/skin/${uuid}`, 'kopf', { modell, breite: 128, hoehe: 128 });
|
||||
return bild || `https://crafthead.net/armor/bust/${uuid}?size=64`;
|
||||
})();
|
||||
kontoKoepfe.set(uuid, auftrag);
|
||||
return auftrag;
|
||||
}
|
||||
|
||||
async function refreshAccountButton() {
|
||||
const acc = await window.api.authStatus();
|
||||
const av = el('account-av');
|
||||
@@ -5431,7 +5789,7 @@ async function refreshAccountButton() {
|
||||
av.innerHTML = '';
|
||||
const img = document.createElement('img');
|
||||
img.className = 'acct-head';
|
||||
img.src = `https://crafthead.net/bust/${acc.profile.id}?size=64`;
|
||||
img.src = await kontoKopf(acc.profile.id);
|
||||
img.addEventListener('error', () => { av.innerHTML = svgIcon('user'); });
|
||||
av.appendChild(img);
|
||||
el('account-name').textContent = acc.profile.name;
|
||||
@@ -5462,7 +5820,7 @@ async function renderAccounts(boxId = 'login-accounts') {
|
||||
|
||||
const av = document.createElement('img');
|
||||
av.className = 'account-av';
|
||||
av.src = `https://crafthead.net/avatar/${a.id}?size=64`;
|
||||
av.src = await kontoKopf(a.id);
|
||||
av.addEventListener('error', () => {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'account-av'; d.textContent = '🎮';
|
||||
@@ -5662,7 +6020,9 @@ function wire() {
|
||||
// schließen bei Klick daneben, Rechtsklick ins Leere, Scrollen oder ESC
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('#ctx-menu')) closeContextMenu();
|
||||
if (!e.target.closest('#konto-menu') && !e.target.closest('#btn-account')) schliesseKontoMenue();
|
||||
});
|
||||
window.addEventListener('blur', schliesseKontoMenue);
|
||||
document.addEventListener('contextmenu', (e) => {
|
||||
if (!e.target.closest('.card') && !e.target.closest('#ctx-menu')) closeContextMenu();
|
||||
});
|
||||
@@ -5771,6 +6131,12 @@ function wire() {
|
||||
});
|
||||
el('technic-list').addEventListener('change', updateTechnicButton);
|
||||
el('technic-do').addEventListener('click', runTechnicImport);
|
||||
window.api.onImportFortschritt(zeigeImportFortschritt);
|
||||
el('import-lauf-abbrechen').addEventListener('click', async () => {
|
||||
el('import-lauf-abbrechen').disabled = true;
|
||||
el('import-lauf-titel').textContent = 'Wird abgebrochen …';
|
||||
await window.api.importAbbrechen();
|
||||
});
|
||||
window.api.onMcProgress((p) => {
|
||||
el('mc-progress').textContent = `Übernehme … ${p.current}/${p.total}`;
|
||||
el('mc-progress').classList.remove('hidden');
|
||||
@@ -6212,7 +6578,7 @@ function wire() {
|
||||
// Design-Kacheln setzen die (versteckte) Auswahl und zeigen es sofort an
|
||||
document.querySelectorAll('.design-wahl').forEach((b) => b.addEventListener('click', () => {
|
||||
el('s-theme').value = b.dataset.design;
|
||||
applyTheme(b.dataset.design);
|
||||
designUeberblenden(b.dataset.design);
|
||||
}));
|
||||
el('s-bgskin-opacity').addEventListener('input', (e) => {
|
||||
el('s-bgskin-val').textContent = e.target.value;
|
||||
@@ -6298,7 +6664,7 @@ function wire() {
|
||||
}
|
||||
|
||||
// Microsoft-Login / Kontoverwaltung
|
||||
el('btn-account').addEventListener('click', openLogin);
|
||||
el('btn-account').addEventListener('click', (e) => { e.stopPropagation(); oeffneKontoMenue(); });
|
||||
el('login-add').addEventListener('click', addAccount);
|
||||
el('login-url').addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
@@ -6355,6 +6721,7 @@ function wire() {
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (!el('ctx-menu').classList.contains('hidden')) { closeContextMenu(); return; }
|
||||
if (!el('konto-menu').classList.contains('hidden')) { schliesseKontoMenue(); 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; }
|
||||
@@ -6533,6 +6900,7 @@ window.addEventListener('DOMContentLoaded', async () => {
|
||||
applyIcons();
|
||||
try {
|
||||
const s = await window.api.getSettings();
|
||||
setzeUiAnimationen(s.uiAnimationen);
|
||||
applyTheme(s.theme);
|
||||
iso3dEnabled = s.iso3dIcons !== false;
|
||||
consoleMaxChars = Math.max(50000, (s.consoleMaxLines || 100000) * 3);
|
||||
@@ -6615,7 +6983,8 @@ window.addEventListener('DOMContentLoaded', async () => {
|
||||
// unsichtbar (Sidebar/Symbolleiste sind dort ausgeblendet) und daher nur
|
||||
// im Hauptfenster sinnvoll
|
||||
window.api.onSettingsChanged((s) => {
|
||||
applyTheme(s.theme);
|
||||
setzeUiAnimationen(s.uiAnimationen);
|
||||
designUeberblenden(s.theme);
|
||||
iso3dEnabled = s.iso3dIcons !== false;
|
||||
schnellstartBelegung = s.schnellstart || {};
|
||||
if (!POPOUT) {
|
||||
@@ -6629,7 +6998,8 @@ window.addEventListener('DOMContentLoaded', async () => {
|
||||
// Fenster (Skin-/Import-Popup) betrifft ebenfalls nur die Anzeige im
|
||||
// Hauptfenster (Skin-Hintergrund, Seitenleiste) – Popups zeigen beides nicht an
|
||||
window.api.onSkinChanged((info) => {
|
||||
if (!POPOUT) refreshSkinBackground();
|
||||
kontoKoepfe.clear();
|
||||
if (!POPOUT) { refreshSkinBackground(); refreshAccountButton(); }
|
||||
// im Editor gesetzt -> offenes Skin-Fenster zeigt sonst noch den alten Stand
|
||||
if (POPOUT === 'skin' && (info.quelle === 'editor' || info.quelle === 'umhang') && !el('modal-skin').classList.contains('hidden')) openSkin();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user