'use strict'
const api = window.rpInstaller
const state = {
packs: [],
selectedKey: null,
installing: false,
installed: false,
resourcepackPath: ''
}
let I18N = {}
function tt(key, params) {
var parts = String(key).split('.')
var cur = I18N
for (var i = 0; i < parts.length; i++) {
if (cur && typeof cur === 'object' && parts[i] in cur) {
cur = cur[parts[i]]
} else {
return key
}
}
if (typeof cur !== 'string') return key
if (!params) return cur
return cur.replace(/\{\{\s*(\w+)\s*\}\}/g, function (_m, name) {
return name in params ? String(params[name]) : '{{' + name + '}}'
})
}
const pageHost = document.getElementById('pageHost')
const stepIndicator = document.getElementById('stepIndicator')
const logViewer = document.getElementById('logViewer')
const logBody = document.getElementById('logBody')
const logToggle = document.getElementById('logToggle')
logToggle.addEventListener('click', function () {
logViewer.classList.toggle('collapsed')
if (logViewer.classList.contains('collapsed')) {
logViewer.style.height = '36px'
logToggle.textContent = tt('logViewer.expand')
} else {
logViewer.style.height = ''
logToggle.textContent = tt('logViewer.collapse')
}
})
api.onLog(function (line) {
logViewer.hidden = false
logBody.textContent += line + '\n'
logBody.scrollTop = logBody.scrollHeight
})
function applyStaticI18n() {
document.title = tt('app.title')
var h1 = document.querySelector('.appHeader h1')
if (h1) h1.textContent = tt('app.title')
var stepLis = stepIndicator.querySelectorAll('li')
stepLis.forEach(function (item) {
var idx = item.getAttribute('data-step')
if (idx === '1') item.textContent = tt('stepIndicator.step1')
else if (idx === '2') item.textContent = tt('stepIndicator.step2')
else if (idx === '3') item.textContent = tt('stepIndicator.step3')
})
var logH2 = logViewer.querySelector('header h2')
if (logH2) logH2.textContent = tt('logViewer.heading')
logToggle.textContent = tt('logViewer.collapse')
}
function setActiveStep(step) {
stepIndicator.querySelectorAll('li').forEach(function (item) {
var index = Number(item.getAttribute('data-step'))
item.classList.remove('active', 'done')
if (index < step) item.classList.add('done')
if (index === step) item.classList.add('active')
})
}
function clearPage() { pageHost.innerHTML = '' }
// ── 1단계: 음악퀴즈 선택 ────────────────────────────
function renderStep1() {
setActiveStep(1)
clearPage()
var section = document.createElement('section')
section.className = 'page'
section.innerHTML =
'
' + escapeHtml(tt('step1.heading')) + ' ' +
'' + escapeHtml(tt('common.loading')) + '
' +
'' + escapeHtml(tt('common.next')) + '
'
pageHost.appendChild(section)
var listEl = section.querySelector('#packList')
var nextBtn = section.querySelector('#next')
function renderList() {
listEl.innerHTML = ''
if (state.packs.length === 0) {
listEl.innerHTML = '' + escapeHtml(tt('common.noPacks')) + '
'
return
}
state.packs.forEach(function (pack) {
var card = document.createElement('button')
card.type = 'button'
card.className = 'choiceCard'
if (state.selectedKey === pack.key) card.classList.add('selected')
var verLabel = pack.mcVersion
? escapeHtml(tt('common.mcVersionLabel', { version: pack.mcVersion }))
: ''
card.innerHTML =
'' + escapeHtml(pack.name) + ' ' +
'' + verLabel +
escapeHtml(tt('common.trackImageCount', { music: pack.list.music.length, image: pack.list.images.length })) +
' '
card.addEventListener('click', function () {
state.selectedKey = pack.key
nextBtn.disabled = false
renderList()
})
listEl.appendChild(card)
})
}
nextBtn.addEventListener('click', function () {
if (!state.selectedKey) return
api.selectPack(state.selectedKey).then(function () {
renderAgreement()
}).catch(function (err) {
alert(err.message || tt('common.selectFailed'))
})
})
api.loadPacks().then(function (packs) {
state.packs = packs || []
renderList()
}).catch(function (err) {
listEl.innerHTML = '' +
escapeHtml(tt('common.listLoadFailed', { message: err.message || '' })) +
'
'
})
}
// 약관 동의 페이지: 1단계 직후, 2단계 설치 진입 전에 노출.
// v0.3.4~ : 사이트의 visibility 토글에 따라 표시할 약관이 결정된다. 명시적으로 빈 목록(terms:[])
// 정상 응답일 때만 단계를 건너뛰고, 네트워크/서버 오류는 차단 후 다시 시도 UI를 보여준다.
function renderAgreement() {
setActiveStep(1)
clearPage()
var loadingSection = document.createElement('section')
loadingSection.className = 'page'
loadingSection.innerHTML = '' + escapeHtml(tt('agreement.heading')) + ' ' +
'' + escapeHtml(tt('agreement.loading')) + '
'
pageHost.appendChild(loadingSection)
api.getTermsList().then(function (res) {
if (!res || !res.ok) {
showAgreementError((res && res.message) || 'unknown')
return
}
var terms = (res.terms || []).map(function (t) {
return { id: t.kind, tab: t.label }
})
if (terms.length === 0) {
renderStep2()
return
}
clearPage()
renderAgreementWithKinds(terms)
}).catch(function (err) {
showAgreementError(err && err.message ? err.message : 'unknown')
})
}
// 약관 목록을 못 받아왔을 때: 사용자에게 오류 + 다시 시도 옵션. 동의 없이 설치 단계로
// 자동 진입하지 않도록 next 버튼을 두지 않는다.
function showAgreementError(message) {
clearPage()
var section = document.createElement('section')
section.className = 'page'
section.innerHTML =
'' + escapeHtml(tt('agreement.heading')) + ' ' +
'' + escapeHtml(tt('agreement.listLoadFailed', { message: message })) + '
' +
'' +
'' + escapeHtml(tt('common.back')) + ' ' +
'' + escapeHtml(tt('agreement.retry')) + ' ' +
'
'
pageHost.appendChild(section)
section.querySelector('#back').addEventListener('click', renderStep1)
section.querySelector('#retry').addEventListener('click', renderAgreement)
}
// 약관을 한 건씩 페이지로 보여준다. 각 약관마다 "해당 약관에 동의합니다" 체크가 있고,
// 끝까지 스크롤해 읽어야 체크할 수 있으며, [다음] 을 누르면 다음 약관 페이지가 화면
// 맨 위에서부터 나온다. 마지막 약관까지 동의하면 다음 단계(step2)로 진행한다.
function renderAgreementWithKinds(KINDS) {
var idx = 0
var accepted = {} // kind -> true (뒤로 갔다 와도 동의 상태 유지)
var cache = {} // kind -> 렌더된 HTML
var section = document.createElement('section')
section.className = 'page'
pageHost.appendChild(section)
function renderCurrent() {
var k = KINDS[idx]
var isLast = idx === KINDS.length - 1
section.innerHTML =
'' + escapeHtml(tt('agreement.heading')) + ' ' +
'' + escapeHtml(tt('agreement.stepLabel', { current: idx + 1, total: KINDS.length, label: k.tab })) + '
' +
'' + escapeHtml(tt('agreement.loading')) + '
' +
'' + escapeHtml(tt('agreement.readToBottom')) + '
' +
' ' +
escapeHtml(tt('agreement.agreeThis')) + ' ' +
'
' +
'' +
' ' + escapeHtml(tt('common.back')) + ' ' +
' ' + escapeHtml(tt('common.next')) + ' ' +
'
'
var body = section.querySelector('#agBody')
var accept = section.querySelector('#agAccept')
var nextBtn = section.querySelector('#next')
var msg = section.querySelector('#agMsg')
var hint = section.querySelector('#agHint')
// 새 약관 페이지는 화면 맨 위에서부터 보이도록 스크롤을 올린다.
if (pageHost) pageHost.scrollTop = 0
function markReadable() {
if (!accept.disabled) return
accept.disabled = false
hint.textContent = ''
}
function checkScrolled() {
if (body.scrollHeight - body.scrollTop - body.clientHeight <= 6) markReadable()
}
body.addEventListener('scroll', checkScrolled)
accept.addEventListener('change', function () {
accepted[k.id] = accept.checked
nextBtn.disabled = !accept.checked
if (accept.checked) { msg.textContent = ''; msg.classList.remove('error') }
})
nextBtn.addEventListener('click', function () {
if (!accept.checked) {
msg.textContent = tt('agreement.agreeRequired')
msg.classList.add('error')
return
}
if (isLast) renderStep2()
else { idx++; renderCurrent() }
})
section.querySelector('#back').addEventListener('click', function () {
if (idx === 0) renderStep1()
else { idx--; renderCurrent() }
})
function afterLoad() {
body.scrollTop = 0
if (accepted[k.id]) {
accept.disabled = false
accept.checked = true
nextBtn.disabled = false
hint.textContent = ''
}
setTimeout(function () {
if (body.scrollHeight - body.clientHeight <= 6) markReadable()
else checkScrolled()
}, 0)
}
if (cache[k.id]) {
body.innerHTML = cache[k.id]
afterLoad()
} else {
body.textContent = tt('agreement.loading')
api.getTerm(k.id).then(function (res) {
if (!res.ok) {
body.innerHTML = '' + escapeHtml(tt('agreement.loadFailed', { message: res.message || '' })) + '
'
markReadable()
return
}
var html = renderTermsMarkdown(res.content || '')
cache[k.id] = html
body.innerHTML = html
afterLoad()
}).catch(function (err) {
body.innerHTML = '' + escapeHtml(tt('agreement.loadFailed', { message: err.message })) + '
'
markReadable()
})
}
}
renderCurrent()
}
// 인스톨러용 미니 markdown 렌더러. 사이트 termsEditor 와 같은 규칙을 처리한다.
function renderTermsMarkdown(src) {
function escHtml(s) {
return s.replace(/&/g, '&').replace(//g, '>')
}
function inline(s) {
s = escHtml(s)
s = s.replace(/`([^`]+)`/g, '$1')
s = s.replace(/\*\*([^*]+)\*\*/g, '$1 ')
s = s.replace(/(^|\W)\*([^*\n]+)\*(?=\W|$)/g, '$1$2 ')
s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1 ')
s = s.replace(/(^|[\s(])(https?:\/\/[^\s)]+)/g, function (m, p, u) {
return p + '' + u + ' '
})
return s
}
var lines = src.replace(/\r\n/g, '\n').split('\n')
var out = []
var i = 0
var stack = null
function closeList() { if (stack) { out.push('' + stack + '>'); stack = null } }
while (i < lines.length) {
var line = lines[i]
var fence = /^```(\w*)\s*$/.exec(line)
if (fence) {
closeList()
var code = []; i += 1
while (i < lines.length && !/^```\s*$/.test(lines[i])) { code.push(lines[i]); i += 1 }
if (i < lines.length) i += 1
out.push('' + escHtml(code.join('\n')) + ' ')
continue
}
var togStart = /^:::toggle\s+(.+)$/.exec(line)
if (togStart) {
closeList()
var summary = togStart[1]; var body2 = []; i += 1
while (i < lines.length && !/^:::\s*$/.test(lines[i])) { body2.push(lines[i]); i += 1 }
if (i < lines.length) i += 1
out.push('' + inline(summary) + ' ' + renderTermsMarkdown(body2.join('\n')) + ' ')
continue
}
var h = /^(#{1,6})\s+(.*)$/.exec(line)
if (h) {
closeList()
out.push('' + inline(h[2]) + ' ')
i += 1; continue
}
if (/^---+\s*$/.test(line)) { closeList(); out.push(' '); i += 1; continue }
if (/^>\s?/.test(line)) {
closeList()
var q = []
while (i < lines.length && /^>\s?/.test(lines[i])) { q.push(lines[i].replace(/^>\s?/, '')); i += 1 }
out.push('' + renderTermsMarkdown(q.join('\n')) + ' ')
continue
}
var ol = /^\s*\d+\.\s+(.*)$/.exec(line)
if (ol) {
if (stack !== 'ol') { closeList(); out.push(''); stack = 'ol' }
out.push('' + inline(ol[1]) + ' '); i += 1; continue
}
var ul = /^\s*[-*]\s+(.*)$/.exec(line)
if (ul) {
if (stack !== 'ul') { closeList(); out.push(''); stack = 'ul' }
out.push('' + inline(ul[1]) + ' '); i += 1; continue
}
if (/^\s*$/.test(line)) { closeList(); i += 1; continue }
closeList()
var para = [line]; i += 1
while (i < lines.length && !/^\s*$/.test(lines[i])
&& !/^(#{1,6})\s+/.test(lines[i])
&& !/^\s*[-*]\s+/.test(lines[i])
&& !/^\s*\d+\.\s+/.test(lines[i])
&& !/^>/.test(lines[i])
&& !/^---+\s*$/.test(lines[i])
&& !/^```/.test(lines[i])
&& !/^:::/.test(lines[i])) {
para.push(lines[i]); i += 1
}
out.push('' + inline(para.join('\n').replace(/\n/g, ' ')) + '
')
}
closeList()
return out.join('\n')
}
// ── 2단계: 설치 진행 ────────────────────────────────
function renderStep2() {
setActiveStep(2)
clearPage()
var pack = null
for (var i = 0; i < state.packs.length; i++) {
if (state.packs[i].key === state.selectedKey) { pack = state.packs[i]; break }
}
var musicTotal = pack ? pack.list.music.length : 0
var imageTotal = pack ? pack.list.images.length : 0
var section = document.createElement('section')
section.className = 'page'
section.innerHTML =
'' + escapeHtml(tt('step2.heading')) + ' ' +
'' + tt('step2.description') + '
' +
'' +
' ' + escapeHtml(tt('step2.chipYtdlp')) + ' ' +
' ' + escapeHtml(tt('step2.chipFfmpeg')) + ' ' +
'
' +
'' +
'
' + escapeHtml(tt('step2.musicHeading')) + ' ' +
'
' + escapeHtml(tt('step2.musicSub', { count: musicTotal })) + '
' +
'
' +
'
' +
'' +
'
' + escapeHtml(tt('step2.imageHeading')) + ' ' +
'
' + escapeHtml(tt('step2.imageSub', { count: imageTotal })) + '
' +
'
' +
'
' +
'' +
'
' + escapeHtml(tt('step2.packageHeading')) + ' ' +
'
' + escapeHtml(tt('step2.packageWaiting')) + '
' +
'
' +
'' +
' ' +
' ' + escapeHtml(tt('common.cancel')) + ' ' +
'
'
pageHost.appendChild(section)
var musicGrid = section.querySelector('#musicGrid')
var imageGrid = section.querySelector('#imageGrid')
var chipYtdlp = section.querySelector('#chip-ytdlp')
var chipFfmpeg = section.querySelector('#chip-ffmpeg')
var pkgSub = section.querySelector('#pkg-sub')
var cancelBtn = section.querySelector('#cancel')
function buildCard(idx) {
var card = document.createElement('div')
card.className = 'progressCard pending'
card.setAttribute('data-idx', String(idx))
card.innerHTML =
'' + idx + ' ○
' +
'
' +
'' + escapeHtml(tt('step2.cardWaiting')) + '
'
return card
}
for (var m = 1; m <= musicTotal; m++) musicGrid.appendChild(buildCard(m))
for (var k = 1; k <= imageTotal; k++) imageGrid.appendChild(buildCard(k))
function updateCard(grid, index, percent, status) {
var card = grid.querySelector('[data-idx="' + index + '"]')
if (!card) return
card.classList.remove('pending', 'running', 'done', 'error')
card.classList.add(status)
var bar = card.querySelector('.bar > span')
if (bar) bar.style.width = Math.max(0, Math.min(100, percent)) + '%'
var pct = card.querySelector('.pct')
var icon = card.querySelector('.icon')
if (status === 'done') {
if (pct) pct.textContent = tt('step2.cardDone')
if (icon) icon.textContent = '✓'
if (bar) bar.style.width = '100%'
} else if (status === 'error') {
if (pct) pct.textContent = tt('step2.cardError')
if (icon) icon.textContent = '✕'
} else if (status === 'running') {
if (pct) pct.textContent = Math.round(percent) + '%'
if (icon) icon.textContent = '⏳'
} else {
if (pct) pct.textContent = tt('step2.cardWaiting')
if (icon) icon.textContent = '○'
}
}
var stopProgress = api.onProgress(function (payload) {
if (!payload || typeof payload !== 'object') return
if (payload.phase === 'prep') {
if (payload.done) {
chipYtdlp.classList.remove('active'); chipYtdlp.classList.add('done')
chipFfmpeg.classList.remove('active'); chipFfmpeg.classList.add('done')
return
}
if (payload.message && payload.message.indexOf('yt-dlp') >= 0) {
chipYtdlp.classList.add('active')
} else if (payload.message && payload.message.indexOf('ffmpeg') >= 0) {
chipYtdlp.classList.remove('active'); chipYtdlp.classList.add('done')
chipFfmpeg.classList.add('active')
}
return
}
if (payload.phase === 'item') {
var grid = payload.kind === 'music' ? musicGrid : imageGrid
updateCard(grid, payload.index, payload.percent || 0, payload.status)
return
}
if (payload.phase === 'package') {
pkgSub.textContent = payload.done
? tt('step2.packageDone')
: (payload.message || tt('step2.packageBuilding'))
return
}
})
// 사용자가 취소를 눌렀는지 추적. 취소 흐름에서는 installFailed 알림을 띄우지 않고
// 조용히 step1 로 돌아간다.
var cancelInitiated = false
cancelBtn.addEventListener('click', function () {
if (!state.installing || cancelInitiated) return
cancelInitiated = true
cancelBtn.disabled = true
cancelBtn.textContent = tt('agreement.cancelling')
// 사용자에게 어느 단계든 즉시 "취소 중" 신호가 보이도록 패키지 섹션 상태 갱신.
pkgSub.textContent = tt('agreement.cancelling')
api.cancelInstall()
})
// 페이지 진입 즉시 설치 시작
state.installing = true
logViewer.hidden = false
api.startInstall().then(function (result) {
state.installing = false
state.installed = true
state.resourcepackPath = (result && result.resourcepackPath) || ''
if (stopProgress) stopProgress()
renderStep3()
}).catch(function (err) {
state.installing = false
if (stopProgress) stopProgress()
if (cancelInitiated) {
// 취소: backend 가 임시 파일을 이미 정리했음. 조용히 처음 단계로.
renderStep1()
return
}
// 그 외 오류: 받아둔 음악·사진은 보존되어 있으므로 '재시도' 로 이어받을 수 있다.
showInstallError((err && err.message) || String(err))
})
}
// 설치 실패 화면: 이어받기('재시도')와 처음으로('처음으로') 선택지를 제공한다.
// 재시도 시 이미 받아둔 곡·사진은 건너뛰고 실패한 지점부터 이어서 설치한다.
function showInstallError(message) {
setActiveStep(2)
clearPage()
var section = document.createElement('section')
section.className = 'page'
section.innerHTML =
'' + escapeHtml(tt('step2.heading')) + ' ' +
'' + escapeHtml(tt('install.errorMessage', { message: message })) + '
' +
'' + escapeHtml(tt('install.resumeHint')) + '
' +
'' +
' ' + escapeHtml(tt('install.startOver')) + ' ' +
' ' + escapeHtml(tt('install.retry')) + ' ' +
'
'
pageHost.appendChild(section)
section.querySelector('#retry').addEventListener('click', function () {
// 같은 음악퀴즈로 설치를 다시 시작. backend 가 받아둔 산출물을 건너뛴다.
renderStep2()
})
section.querySelector('#startOver').addEventListener('click', function () {
// 이어받지 않고 처음으로: 받아둔 임시 파일을 정리한 뒤 1단계로.
api.discardInstall().then(function () {
renderStep1()
}).catch(function () {
renderStep1()
})
})
}
// ── 3단계: 완료 ────────────────────────────────────
function renderStep3() {
setActiveStep(3)
clearPage()
var section = document.createElement('section')
section.className = 'page'
section.innerHTML =
'' + escapeHtml(tt('step3.heading')) + ' ' +
'' + escapeHtml(tt('step3.message')) + '
' +
'' + escapeHtml(tt('step3.applyNotice')) + '
' +
(state.resourcepackPath
? '' + escapeHtml(state.resourcepackPath) + '
'
: '') +
'' +
' ' + escapeHtml(tt('common.openFolder')) + ' ' +
' ' + escapeHtml(tt('common.confirm')) + ' ' +
'
'
pageHost.appendChild(section)
section.querySelector('#openFolder').addEventListener('click', function () {
api.openResourcepackFolder()
})
section.querySelector('#finish').addEventListener('click', function () {
api.quit()
})
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return c === '&' ? '&' : c === '<' ? '<' : c === '>' ? '>' : c === '"' ? '"' : '''
})
}
;(async function () {
try { I18N = (await api.loadLocale()) || {} } catch (_) { I18N = {} }
applyStaticI18n()
renderStep1()
})()