// =====================================================================
// Sulfur Launcher — Core Logic & Instance Engine
// =====================================================================
const STORAGE_KEY = 'sulfur:state';
const DB_NAME = 'SulfurLauncherDB';
const DB_VERSION = 1;
// --- 1. Default Instances Catalog --------------------------------------
const DEFAULT_INSTANCES = [
{
id: 'sulfur-v25',
title: 'Sulfur Client v2.5',
icon: 'assets/thumb-atm10.png',
bg: 'assets/bg-atm10.jpg',
tags: ['2.5', 'SULFUR', 'FPS-BOOST'],
playtimeSeconds: 7200,
lastPlayed: new Date(Date.now() - 3600 * 2000).toISOString(),
launchesCount: 14,
launchType: 'local',
launchFile: 'instances/sulfur.html'
},
{
id: 'resent-188',
title: 'Resent Client 1.8.8',
icon: 'assets/thumb-vanilla8.png',
bg: 'assets/bg-vanilla8.jpg',
tags: ['1.8.8', 'MODDED', 'PVP'],
playtimeSeconds: 14400,
lastPlayed: new Date(Date.now() - 3600 * 24000).toISOString(),
launchesCount: 28,
launchType: 'local',
launchFile: 'instances/resent_client.html'
},
{
id: 'vanilla-188',
title: 'Vanilla 1.8.8',
icon: 'assets/thumb-vanilla8.png',
bg: 'assets/bg-vanilla8.jpg',
tags: ['1.8.8', 'VANILLA', 'MULTIPLAYER'],
playtimeSeconds: 43200,
lastPlayed: new Date(Date.now() - 3600 * 48000).toISOString(),
launchesCount: 52,
launchType: 'local',
launchFile: 'instances/eagler_1_8_8.html'
}
];
// Curated Collection Presets catalog
const CURATED_PRESETS = [
{
id: 'preset-sulfur',
title: 'Sulfur Client v2.5 Obsidian',
version: '2.5',
description: 'Official Sulfur web client with WebGL shader optimization and server list.',
icon: 'assets/thumb-atm10.png',
bg: 'assets/bg-atm10.jpg',
tags: ['2.5', 'SULFUR', 'OFFICIAL'],
launchType: 'local',
launchFile: 'instances/sulfur.html'
},
{
id: 'preset-resent',
title: 'Resent Client 1.8.8',
version: '1.8.8',
description: 'Popular PvP modpack client built for Eaglercraft 1.8.8.',
icon: 'assets/thumb-vanilla8.png',
bg: 'assets/bg-vanilla8.jpg',
tags: ['1.8.8', 'PVP', 'MODDED'],
launchType: 'local',
launchFile: 'instances/resent_client.html'
},
{
id: 'preset-vanilla',
title: 'Vanilla Eagler 1.8.8',
version: '1.8.8',
description: 'Clean standard Eaglercraft 1.8.8 vanilla client build.',
icon: 'assets/thumb-vanilla8.png',
bg: 'assets/bg-vanilla8.jpg',
tags: ['1.8.8', 'VANILLA', 'CLEAN'],
launchType: 'local',
launchFile: 'instances/eagler_1_8_8.html'
}
];
// --- 2. IndexedDB Storage Manager -------------------------------------
class SulfurDBManager {
constructor() {
this.db = null;
}
async init() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = e => {
const db = e.target.result;
if (!db.objectStoreNames.contains('instances_files')) {
db.createObjectStore('instances_files', { keyPath: 'id' });
}
};
req.onsuccess = e => {
this.db = e.target.result;
resolve(this.db);
};
req.onerror = e => reject(e.target.error);
});
}
async saveClientFile(fileId, content) {
if (!this.db) await this.init();
return new Promise((resolve, reject) => {
const tx = this.db.transaction('instances_files', 'readwrite');
const store = tx.objectStore('instances_files');
const req = store.put({ id: fileId, content, updatedAt: Date.now() });
req.onsuccess = () => resolve(fileId);
req.onerror = e => reject(e.target.error);
});
}
async getClientFile(fileId) {
if (!this.db) await this.init();
return new Promise((resolve, reject) => {
const tx = this.db.transaction('instances_files', 'readonly');
const store = tx.objectStore('instances_files');
const req = store.get(fileId);
req.onsuccess = () => resolve(req.result ? req.result.content : null);
req.onerror = e => reject(e.target.error);
});
}
async deleteClientFile(fileId) {
if (!this.db) await this.init();
return new Promise((resolve, reject) => {
const tx = this.db.transaction('instances_files', 'readwrite');
const store = tx.objectStore('instances_files');
const req = store.delete(fileId);
req.onsuccess = () => resolve();
req.onerror = e => reject(e.target.error);
});
}
}
const dbManager = new SulfurDBManager();
// --- 3. State Management -----------------------------------------------
function loadState() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) throw new Error('no state');
const parsed = JSON.parse(raw);
return {
instances: parsed.instances && parsed.instances.length ? parsed.instances : DEFAULT_INSTANCES,
activeInstanceId: parsed.activeInstanceId || DEFAULT_INSTANCES[0].id,
onboardingCompleted: parsed.onboardingCompleted || false,
settings: Object.assign({ reduceMotion: false, compactSidebar: false }, parsed.settings || {})
};
} catch (e) {
return {
instances: DEFAULT_INSTANCES,
activeInstanceId: DEFAULT_INSTANCES[0].id,
onboardingCompleted: false,
settings: { reduceMotion: false, compactSidebar: false }
};
}
}
function saveState() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch (e) { /* fail-safe */ }
}
const state = loadState();
let activeSession = null;
let deferredPrompt = null; // PWA install prompt
// --- 4. Utilities & Formatters -----------------------------------------
function toast(message) {
const container = document.getElementById('toast-container');
const el = document.createElement('div');
el.className = 'toast';
el.textContent = message;
container.appendChild(el);
setTimeout(() => {
el.classList.add('leaving');
setTimeout(() => el.remove(), 220);
}, 3200);
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str || '';
return div.innerHTML;
}
function formatPlaytime(seconds) {
if (!seconds || seconds <= 0) return 'Never played';
const hrs = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
if (hrs > 0) return `${hrs}h ${mins}m played`;
return `${mins}m played`;
}
function formatLastPlayed(timestamp) {
if (!timestamp) return 'Never';
const date = new Date(timestamp);
const now = new Date();
const diffMs = now - date;
const diffMins = Math.floor(diffMs / (1000 * 60));
const diffHrs = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffMins < 2) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHrs < 24) return `${diffHrs}h ago`;
if (diffDays === 1) return 'Yesterday';
return `${diffDays}d ago`;
}
// --- 5. Startup Animation ----------------------------------------------
function runStartup() {
const startupScreen = document.getElementById('startup-screen');
const status = document.getElementById('startup-status');
const messages = ['Loading instances…', 'Initializing Sulfur engine…', 'Preparing stage…'];
let i = 0;
if (state.settings.reduceMotion) {
startupScreen.classList.add('removed');
checkOnboarding();
return;
}
const msgInterval = setInterval(() => {
i = (i + 1) % messages.length;
status.textContent = messages[i];
}, 600);
setTimeout(() => {
clearInterval(msgInterval);
startupScreen.classList.add('wipe-out');
setTimeout(() => {
startupScreen.classList.add('removed');
checkOnboarding();
}, 900);
}, 1800);
}
function checkOnboarding() {
if (!state.onboardingCompleted) {
openModal('modal-onboarding');
}
}
// --- 6. Sidebar & Stage Renderers ---------------------------------------
function renderSidebar() {
const sidebarList = document.getElementById('sidebar-instances');
sidebarList.innerHTML = '';
state.instances.forEach(inst => {
const iconDiv = document.createElement('div');
iconDiv.className = `instance-icon ${inst.id === state.activeInstanceId ? 'active' : ''}`;
iconDiv.style.backgroundImage = `url('${inst.icon}')`;
iconDiv.title = inst.title;
iconDiv.onclick = () => selectInstance(inst.id, true);
sidebarList.appendChild(iconDiv);
});
}
function renderLibrary() {
const grid = document.getElementById('library-grid');
grid.innerHTML = '';
state.instances.forEach(inst => {
const card = document.createElement('div');
card.className = `library-card ${inst.id === state.activeInstanceId ? 'active' : ''}`;
card.style.backgroundImage = `url('${inst.bg}')`;
card.innerHTML = `
${escapeHtml(inst.title)}
${inst.tags.join(' · ')} • ${formatPlaytime(inst.playtimeSeconds)}
`;
card.onclick = () => {
selectInstance(inst.id, true);
switchView('play');
};
grid.appendChild(card);
});
}
function renderDiscoverCatalog() {
const catalog = document.getElementById('discover-catalog');
catalog.innerHTML = '';
CURATED_PRESETS.forEach(preset => {
const card = document.createElement('div');
card.className = 'discover-card';
const isInstalled = state.instances.some(i => i.title === preset.title);
card.innerHTML = `
${escapeHtml(preset.title)}
${escapeHtml(preset.description)}
${preset.tags.map(t => `${t}`).join('')}
`;
catalog.appendChild(card);
if (!isInstalled) {
document.getElementById(`btn-install-${preset.id}`).onclick = () => installCuratedPreset(preset);
}
});
}
function selectInstance(id, persist) {
state.activeInstanceId = id;
renderSidebar();
renderLibrary();
loadInstance(id);
if (persist) saveState();
}
function loadInstance(id) {
const inst = state.instances.find(i => i.id === id);
if (!inst) return;
document.getElementById('background-layer').style.backgroundImage = `url('${inst.bg}')`;
document.getElementById('instance-title').innerText = inst.title;
const playtimeText = activeSession && activeSession.instanceId === id
? `Playing now • ${formatPlaytime(inst.playtimeSeconds)}`
: `${formatPlaytime(inst.playtimeSeconds)} • Last played ${formatLastPlayed(inst.lastPlayed)}`;
document.getElementById('instance-playtime').innerText = playtimeText;
const tagsContainer = document.getElementById('instance-tags');
tagsContainer.innerHTML = '';
inst.tags.forEach(tag => {
const tagSpan = document.createElement('span');
tagSpan.className = 'tag';
tagSpan.innerText = tag;
tagsContainer.appendChild(tagSpan);
});
}
function switchView(view) {
document.querySelectorAll('.nav-item[data-view]').forEach(btn => {
btn.classList.toggle('active', btn.dataset.view === view);
});
document.querySelectorAll('.view').forEach(section => {
section.classList.toggle('active', section.id === `view-${view}`);
});
if (view === 'library') renderLibrary();
if (view === 'discover') renderDiscoverCatalog();
}
// --- 7. Play Button & Session Engine -----------------------------------
async function initPlayButton() {
document.getElementById('play-btn').addEventListener('click', async () => {
const inst = state.instances.find(i => i.id === state.activeInstanceId);
if (!inst) return;
const btn = document.getElementById('play-btn');
btn.disabled = true;
btn.innerHTML = ' LAUNCHING…';
let targetUrl = inst.launchFile;
// If local IndexedDB client file, resolve Blob URL
if (inst.launchType === 'db' || targetUrl.startsWith('db:')) {
const fileId = targetUrl.replace('db:', '');
const htmlContent = await dbManager.getClientFile(fileId);
if (!htmlContent) {
toast('Error: Local client HTML file not found in database.');
btn.disabled = false;
btn.innerHTML = ' PLAY';
return;
}
const blob = new Blob([htmlContent], { type: 'text/html' });
targetUrl = URL.createObjectURL(blob);
}
setTimeout(() => {
const win = window.open(
targetUrl,
`SulfurClient_${inst.id}`,
'popup=1,width=1280,height=720,menubar=no,toolbar=no,location=no,status=no'
);
if (!win) {
toast('Browser blocked popup window — allow popups for Sulfur.');
btn.disabled = false;
btn.innerHTML = ' PLAY';
return;
}
// Start Session Tracking
inst.launchesCount = (inst.launchesCount || 0) + 1;
inst.lastPlayed = new Date().toISOString();
saveState();
startPlaytimeSession(inst, win);
btn.disabled = false;
btn.innerHTML = ' PLAYING';
}, 500);
});
}
function startPlaytimeSession(inst, win) {
if (activeSession) clearInterval(activeSession.timerId);
const startTime = Date.now();
const timerId = setInterval(() => {
if (win.closed) {
clearInterval(timerId);
const durationSec = Math.floor((Date.now() - startTime) / 1000);
inst.playtimeSeconds = (inst.playtimeSeconds || 0) + durationSec;
inst.lastPlayed = new Date().toISOString();
saveState();
activeSession = null;
loadInstance(state.activeInstanceId);
renderLibrary();
toast(`Session finished — played ${inst.title} for ${formatPlaytime(durationSec)}.`);
} else {
inst.playtimeSeconds = (inst.playtimeSeconds || 0) + 1;
if (state.activeInstanceId === inst.id) {
document.getElementById('instance-playtime').innerText = `Playing now • ${formatPlaytime(inst.playtimeSeconds)}`;
}
}
}, 1000);
activeSession = { instanceId: inst.id, startTime, timerId, win };
}
// --- 8. Instance Manager (Add, Edit, Delete, Collection Model) --------
let pendingLocalFile = null;
let pendingJsonCollection = null;
function initAddInstanceModal() {
// Open Add Modal
document.getElementById('btn-sidebar-add').addEventListener('click', () => openModal('modal-add-instance'));
document.getElementById('btn-lib-add').addEventListener('click', () => openModal('modal-add-instance'));
// Modal Tabs
document.querySelectorAll('.modal-tab').forEach(tabBtn => {
tabBtn.addEventListener('click', () => {
document.querySelectorAll('.modal-tab').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
tabBtn.classList.add('active');
document.getElementById(tabBtn.dataset.tab).classList.add('active');
});
});
// Local File Drop Zone
const dropZone = document.getElementById('file-drop-zone');
const fileInput = document.getElementById('local-file-input');
fileInput.addEventListener('change', e => {
if (e.target.files && e.target.files[0]) {
handleSelectedLocalFile(e.target.files[0]);
}
});
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('dragover'); });
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover'));
dropZone.addEventListener('drop', e => {
e.preventDefault();
dropZone.classList.remove('dragover');
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
handleSelectedLocalFile(e.dataTransfer.files[0]);
}
});
function handleSelectedLocalFile(file) {
if (!file.name.endsWith('.html') && !file.name.endsWith('.htm')) {
toast('Please select an .html or .htm file.');
return;
}
pendingLocalFile = file;
document.getElementById('drop-filename').innerText = `Selected: ${file.name} (${Math.round(file.size / 1024)} KB)`;
if (!document.getElementById('add-local-title').value.trim()) {
document.getElementById('add-local-title').value = file.name.replace(/\.[^/.]+$/, '');
}
}
// Confirm Add Local
document.getElementById('confirm-add-local').addEventListener('click', async () => {
if (!pendingLocalFile) {
toast('Please select a local HTML file first.');
return;
}
const title = document.getElementById('add-local-title').value.trim() || 'Local Client';
const tags = document.getElementById('add-local-tags').value.split(',').map(t => t.trim().toUpperCase()).filter(Boolean);
const version = document.getElementById('add-local-version').value.trim() || '1.8.8';
const fileId = `file_${Date.now()}`;
const htmlText = await pendingLocalFile.text();
await dbManager.saveClientFile(fileId, htmlText);
const newInst = {
id: `inst_${Date.now()}`,
title,
tags,
version,
icon: 'assets/thumb-atm10.png',
bg: 'assets/bg-atm10.jpg',
playtimeSeconds: 0,
lastPlayed: null,
launchesCount: 0,
launchType: 'db',
launchFile: `db:${fileId}`
};
state.instances.push(newInst);
saveState();
selectInstance(newInst.id, true);
closeModal('modal-add-instance');
toast(`Instance "${title}" added!`);
pendingLocalFile = null;
document.getElementById('drop-filename').innerText = 'Click or Drag & Drop local .html client file here';
});
// Confirm Add Hosted
document.getElementById('confirm-add-hosted').addEventListener('click', () => {
const url = document.getElementById('add-hosted-url').value.trim();
if (!url) { toast('Please enter a launch URL.'); return; }
const title = document.getElementById('add-hosted-title').value.trim() || 'Hosted Client';
const tags = document.getElementById('add-hosted-tags').value.split(',').map(t => t.trim().toUpperCase()).filter(Boolean);
const version = document.getElementById('add-hosted-version').value.trim() || '1.8.8';
const newInst = {
id: `inst_${Date.now()}`,
title,
tags,
version,
icon: 'assets/thumb-vanilla8.png',
bg: 'assets/bg-vanilla8.jpg',
playtimeSeconds: 0,
lastPlayed: null,
launchesCount: 0,
launchType: 'url',
launchFile: url
};
state.instances.push(newInst);
saveState();
selectInstance(newInst.id, true);
closeModal('modal-add-instance');
toast(`Hosted instance "${title}" added!`);
});
// Populate Curated Collection Tab
renderPresetCollectionTab();
// Import Collection JSON tab logic
const jsonInput = document.getElementById('json-file-input');
jsonInput.addEventListener('change', e => {
if (e.target.files && e.target.files[0]) handleSelectedJson(e.target.files[0]);
});
function handleSelectedJson(file) {
const reader = new FileReader();
reader.onload = e => {
try {
const data = JSON.parse(e.target.result);
if (!data.instances || !Array.isArray(data.instances)) throw new Error('Invalid schema');
pendingJsonCollection = data;
document.getElementById('json-filename').innerText = `Selected: ${file.name}`;
document.getElementById('preview-collection-name').innerText = data.name || 'Custom Collection';
document.getElementById('preview-collection-count').innerText = `${data.instances.length} instances ready to import`;
document.getElementById('json-import-preview').classList.remove('hidden');
document.getElementById('confirm-import-json').disabled = false;
} catch (err) {
toast('Invalid Sulfur collection JSON file.');
}
};
reader.readAsText(file);
}
document.getElementById('confirm-import-json').addEventListener('click', () => {
if (!pendingJsonCollection) return;
let addedCount = 0;
pendingJsonCollection.instances.forEach(item => {
const exists = state.instances.some(i => i.id === item.id || i.title === item.title);
if (!exists) {
state.instances.push({
id: item.id || `inst_${Date.now()}_${Math.random()}`,
title: item.title || 'Imported Client',
tags: item.tags || ['IMPORTED'],
icon: item.icon || 'assets/thumb-atm10.png',
bg: item.bg || 'assets/bg-atm10.jpg',
playtimeSeconds: item.playtimeSeconds || 0,
lastPlayed: item.lastPlayed || null,
launchesCount: item.launchesCount || 0,
launchType: item.launchType || 'local',
launchFile: item.launchFile || 'instances/sulfur.html'
});
addedCount++;
}
});
saveState();
renderSidebar();
renderLibrary();
closeModal('modal-add-instance');
toast(`Imported ${addedCount} instances from collection!`);
});
}
function renderPresetCollectionTab() {
const container = document.getElementById('preset-collection-list');
container.innerHTML = '';
CURATED_PRESETS.forEach(preset => {
const item = document.createElement('div');
item.className = 'collection-item';
item.innerHTML = `
${escapeHtml(preset.title)}
${preset.tags.join(' · ')} • ${escapeHtml(preset.description)}
`;
container.appendChild(item);
document.getElementById(`btn-add-preset-${preset.id}`).onclick = () => installCuratedPreset(preset);
});
}
function installCuratedPreset(preset) {
const exists = state.instances.some(i => i.title === preset.title);
if (exists) { toast('Instance is already in your launcher!'); return; }
const newInst = {
id: `inst_${Date.now()}`,
title: preset.title,
tags: preset.tags,
icon: preset.icon,
bg: preset.bg,
playtimeSeconds: 0,
lastPlayed: null,
launchesCount: 0,
launchType: preset.launchType,
launchFile: preset.launchFile
};
state.instances.push(newInst);
saveState();
selectInstance(newInst.id, true);
closeModal('modal-add-instance');
toast(`Added ${preset.title}!`);
}
// --- 9. Edit & Delete Instance -----------------------------------------
function initEditModal() {
document.getElementById('btn-edit').addEventListener('click', () => {
const inst = state.instances.find(i => i.id === state.activeInstanceId);
if (!inst) return;
document.getElementById('edit-title').value = inst.title;
document.getElementById('edit-tags').value = inst.tags.join(', ');
document.getElementById('edit-launch-file').value = inst.launchFile;
openModal('modal-edit');
});
document.getElementById('confirm-edit').addEventListener('click', () => {
const inst = state.instances.find(i => i.id === state.activeInstanceId);
if (!inst) return;
const newTitle = document.getElementById('edit-title').value.trim();
const newTags = document.getElementById('edit-tags').value
.split(',').map(t => t.trim().toUpperCase()).filter(Boolean);
const newTarget = document.getElementById('edit-launch-file').value.trim();
if (newTitle) inst.title = newTitle;
if (newTags.length) inst.tags = newTags;
if (newTarget) inst.launchFile = newTarget;
loadInstance(inst.id);
renderSidebar();
renderLibrary();
saveState();
closeModal('modal-edit');
toast('Instance saved.');
});
// Delete Instance
document.getElementById('btn-delete-instance').addEventListener('click', () => {
const inst = state.instances.find(i => i.id === state.activeInstanceId);
if (!inst) return;
document.getElementById('delete-instance-name').innerText = inst.title;
closeModal('modal-edit');
openModal('modal-confirm-delete');
});
document.getElementById('confirm-delete-action').addEventListener('click', async () => {
const instId = state.activeInstanceId;
const inst = state.instances.find(i => i.id === instId);
if (inst && (inst.launchType === 'db' || inst.launchFile.startsWith('db:'))) {
const fileId = inst.launchFile.replace('db:', '');
await dbManager.deleteClientFile(fileId);
}
state.instances = state.instances.filter(i => i.id !== instId);
if (!state.instances.length) state.instances = DEFAULT_INSTANCES;
state.activeInstanceId = state.instances[0].id;
saveState();
renderSidebar();
renderLibrary();
loadInstance(state.activeInstanceId);
closeModal('modal-confirm-delete');
toast('Instance deleted.');
});
}
// --- 10. Collection Model JSON Export/Import ----------------------------
function exportCollectionJson() {
const collectionData = {
schemaVersion: '1.0',
name: 'Sulfur Local Collection',
exportedAt: new Date().toISOString(),
instances: state.instances.map(inst => ({
id: inst.id,
title: inst.title,
tags: inst.tags,
icon: inst.icon,
bg: inst.bg,
launchType: inst.launchType,
launchFile: inst.launchType === 'db' ? 'instances/sulfur.html' : inst.launchFile,
playtimeSeconds: inst.playtimeSeconds || 0,
lastPlayed: inst.lastPlayed || null
}))
};
const blob = new Blob([JSON.stringify(collectionData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'sulfur-collection.json';
a.click();
URL.revokeObjectURL(url);
toast('Exported sulfur-collection.json!');
}
// --- 11. Onboarding Wizard & PWA Setup ---------------------------------
let currentOnboardingStep = 1;
function initOnboardingWizard() {
document.getElementById('btn-onboarding').addEventListener('click', () => {
currentOnboardingStep = 1;
updateOnboardingStep();
openModal('modal-onboarding');
});
document.getElementById('setting-onboarding-btn').addEventListener('click', () => {
closeModal('modal-settings');
currentOnboardingStep = 1;
updateOnboardingStep();
openModal('modal-onboarding');
});
document.getElementById('onboarding-next').addEventListener('click', () => {
if (currentOnboardingStep < 4) {
currentOnboardingStep++;
updateOnboardingStep();
} else {
finishOnboarding();
}
});
document.getElementById('onboarding-prev').addEventListener('click', () => {
if (currentOnboardingStep > 1) {
currentOnboardingStep--;
updateOnboardingStep();
}
});
document.getElementById('onboarding-skip').addEventListener('click', finishOnboarding);
// Capture PWA deferred prompt
window.addEventListener('beforeinstallprompt', e => {
e.preventDefault();
deferredPrompt = e;
});
document.getElementById('pwa-install-trigger').addEventListener('click', async () => {
if (deferredPrompt) {
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
if (outcome === 'accepted') {
toast('Sulfur Desktop App installed!');
}
deferredPrompt = null;
} else {
toast('To install: click your browser URL bar install button or Share → Add to Home Screen.');
}
});
}
function updateOnboardingStep() {
document.querySelectorAll('.step-indicator').forEach(ind => {
const s = parseInt(ind.dataset.step);
ind.classList.toggle('active', s <= currentOnboardingStep);
});
document.querySelectorAll('.onboarding-slide').forEach((slide, idx) => {
slide.classList.toggle('active', (idx + 1) === currentOnboardingStep);
});
document.getElementById('onboarding-prev').style.visibility = currentOnboardingStep > 1 ? 'visible' : 'hidden';
const nextBtn = document.getElementById('onboarding-next');
nextBtn.innerHTML = currentOnboardingStep === 4 ? 'Get Started ✓' : 'Next →';
}
function finishOnboarding() {
state.onboardingCompleted = true;
saveState();
closeModal('modal-onboarding');
toast('Welcome to Sulfur Launcher!');
}
// --- 12. Stats & Analytics Panel ---------------------------------------
function initStatsModal() {
document.getElementById('btn-stats').addEventListener('click', () => {
renderStatsData();
openModal('modal-stats');
});
}
function renderStatsData() {
const totalSec = state.instances.reduce((acc, i) => acc + (i.playtimeSeconds || 0), 0);
const totalLaunches = state.instances.reduce((acc, i) => acc + (i.launchesCount || 0), 0);
const sortedByTime = [...state.instances].sort((a, b) => (b.playtimeSeconds || 0) - (a.playtimeSeconds || 0));
const mostPlayed = sortedByTime[0] ? sortedByTime[0].title : 'None';
document.getElementById('stat-total-playtime').innerText = formatPlaytime(totalSec);
document.getElementById('stat-total-launches').innerText = totalLaunches.toString();
document.getElementById('stat-most-played').innerText = mostPlayed;
document.getElementById('stat-instance-count').innerText = state.instances.length.toString();
const activityList = document.getElementById('stats-instance-list');
activityList.innerHTML = '';
sortedByTime.forEach(inst => {
const item = document.createElement('div');
item.className = 'activity-item';
item.innerHTML = `
${escapeHtml(inst.title)}
${formatPlaytime(inst.playtimeSeconds)} • ${inst.launchesCount || 0} launches
`;
activityList.appendChild(item);
});
}
// --- 13. Settings & Window Controls ------------------------------------
function initSettingsModal() {
const reduceMotionInput = document.getElementById('setting-reduce-motion');
const compactSidebarInput = document.getElementById('setting-compact-sidebar');
reduceMotionInput.checked = state.settings.reduceMotion;
compactSidebarInput.checked = state.settings.compactSidebar;
document.body.classList.toggle('compact-sidebar', state.settings.compactSidebar);
document.getElementById('btn-settings').addEventListener('click', () => openModal('modal-settings'));
reduceMotionInput.addEventListener('change', () => {
state.settings.reduceMotion = reduceMotionInput.checked;
saveState();
});
compactSidebarInput.addEventListener('change', () => {
state.settings.compactSidebar = compactSidebarInput.checked;
document.body.classList.toggle('compact-sidebar', state.settings.compactSidebar);
saveState();
});
document.getElementById('setting-export-btn').addEventListener('click', exportCollectionJson);
document.getElementById('btn-lib-export').addEventListener('click', exportCollectionJson);
document.getElementById('btn-changelog').addEventListener('click', () => {
toast('v2.5 — Instance Manager, Playtime Tracker, Local Files & PWA Onboarding!');
});
}
function initWindowControls() {
const pill = document.getElementById('minimized-pill');
document.getElementById('btn-minimize').addEventListener('click', () => {
document.body.classList.add('minimized');
setTimeout(() => pill.classList.remove('hidden'), 250);
try { window.blur(); } catch (e) {}
});
pill.addEventListener('click', () => {
pill.classList.add('hidden');
document.body.classList.remove('minimized');
try { window.focus(); } catch (e) {}
});
const maxBtn = document.getElementById('btn-maximize');
const maxIconUse = document.querySelector('#maximize-icon use');
maxBtn.addEventListener('click', async () => {
try {
if (!document.fullscreenElement) {
await document.documentElement.requestFullscreen();
} else {
await document.exitFullscreen();
}
} catch (e) {
toast('Browser blocked fullscreen.');
}
});
document.addEventListener('fullscreenchange', () => {
const isFull = !!document.fullscreenElement;
maxIconUse.setAttribute('href', isFull ? '#icon-restore' : '#icon-maximize');
maxBtn.title = isFull ? 'Restore' : 'Maximize';
});
document.getElementById('btn-close').addEventListener('click', () => openModal('modal-close'));
document.getElementById('confirm-close').addEventListener('click', () => {
closeModal('modal-close');
window.close();
setTimeout(() => {
toast("Press Ctrl+W (⌘W on Mac) to close this browser tab.");
}, 300);
});
}
// Global modal helpers
function openModal(id) { document.getElementById(id).classList.add('visible'); }
function closeModal(id) { document.getElementById(id).classList.remove('visible'); }
document.addEventListener('click', e => {
if (e.target.dataset && e.target.dataset.closeModal) closeModal(e.target.dataset.closeModal);
if (e.target.classList.contains('modal-overlay')) e.target.classList.remove('visible');
});
// --- 14. Boot Application -----------------------------------------------
document.addEventListener('DOMContentLoaded', async () => {
await dbManager.init();
runStartup();
renderSidebar();
renderLibrary();
loadInstance(state.activeInstanceId);
// Bind nav views
document.querySelectorAll('.nav-item[data-view]').forEach(btn => {
btn.addEventListener('click', () => switchView(btn.dataset.view));
});
initPlayButton();
initAddInstanceModal();
initEditModal();
initOnboardingWizard();
initStatsModal();
initSettingsModal();
initWindowControls();
});