more accurate sync progress report

This commit is contained in:
olcxja 2026-10-01 14:58:58 +02:00
commit c0f6e802ed
12 changed files with 390 additions and 63 deletions

View file

@ -15,6 +15,10 @@ const state = {
games: [],
currentGame: null,
lansync: null,
// true while this device pushes a sync (and while it shows the progress
// popup): only one such popup exists, the pushing side wins over an
// incoming sync
lanPushing: false,
currentView: 'library'
}
@ -114,8 +118,13 @@ function toast(message, kind) {
// cancels any pending teardown and clears the closing state.
let modalTeardownTimer = null
let modalSwapTimer = null
// A running sync must not be thrown away by a stray click: a locked modal
// ignores the backdrop and Escape, only its own buttons (or the end of the
// operation) close it.
let modalLocked = false
function showModal(html) {
function showModal(html, opts) {
modalLocked = !!(opts && opts.locked)
if (modalTeardownTimer) {
clearTimeout(modalTeardownTimer)
modalTeardownTimer = null
@ -189,6 +198,7 @@ function closeModal() {
modal.classList.remove('closing', 'swapping', 'resizing')
modal.style.height = ''
modal.innerHTML = ''
modalLocked = false
}, 160)
}
@ -1137,6 +1147,41 @@ const LAN_CATEGORIES = [
{ id: 'prefixes', label: 'lansync.prefixes' }
]
// Byte sizes for the progress popups. Decimal units, the way a file manager
// or a download shows them, because that is what a "1.2 GB" on screen means
// to the user.
function formatBytes(bytes) {
const value = Math.max(0, Number(bytes) || 0)
if (value < 1000) return `${Math.round(value)} B`
if (value < 1000 * 1000) return `${(value / 1000).toFixed(1)} kB`
if (value < 1000 * 1000 * 1000) return `${(value / 1000 / 1000).toFixed(1)} MB`
return `${(value / 1000 / 1000 / 1000).toFixed(2)} GB`
}
// "Games: 3 · Prefixes: 1 · Wine versions: 2" - only the categories this
// sync actually carries are listed.
function lanStatsLine(items) {
if (!items) return ''
const rows = [
[items.apps, 'lansync.statsApps'],
[items.prefixes, 'lansync.statsPrefixes'],
[items.wines, 'lansync.statsWines']
]
return rows
.filter(([count]) => count > 0)
.map(([count, key]) => t(key, count))
.join(' · ')
}
// "340 MB / 1.2 GB" once the total is known (the sending side learns it as it
// plans the units, the receiving side as the manifests arrive), otherwise
// just what has moved so far - so the line is never a lying zero.
function lanBytesLine(bytes, totalBytes) {
const sent = formatBytes(bytes)
if (!totalBytes) return bytes > 0 ? t('lansync.progressSent', sent) : ''
return t('lansync.progressBytes', sent, formatBytes(totalBytes))
}
async function renderLanSync() {
const info = await window.plwl.lansyncGet()
state.lansync = info
@ -1243,17 +1288,23 @@ async function openLanSyncModal(device, errorKey) {
}
async function runLanSync(device, code, categories, force) {
// locked: a running sync is not cancellable by a stray click on the backdrop
const modal = showModal(`
<h2 id="ls-run-title"></h2>
<div class="progress-row"><progress></progress></div>
<p class="subtitle" id="ls-run-phase"></p>
<p class="subtitle" id="ls-run-count"></p>
`)
<p class="subtitle" id="ls-run-stats"></p>
<p class="subtitle" id="ls-run-bytes"></p>
`, { locked: true })
modal.querySelector('#ls-run-title').textContent = t('lansync.progress', device.name)
const bar = modal.querySelector('progress')
const phase = modal.querySelector('#ls-run-phase')
const count = modal.querySelector('#ls-run-count')
const stats = modal.querySelector('#ls-run-stats')
const bytes = modal.querySelector('#ls-run-bytes')
let active = true
state.lanPushing = true
window.plwl.onLanSyncProgress(data => {
if (!active) return
const key = 'lansync.phase.' + data.phase
@ -1266,13 +1317,22 @@ async function runLanSync(device, code, categories, force) {
bar.removeAttribute('value')
count.textContent = ''
}
stats.textContent = lanStatsLine(data.items)
bytes.textContent = lanBytesLine(data.bytes, data.totalBytes)
})
const res = await window.plwl.lansyncSync({ deviceId: device.id, code, categories, force })
active = false
let res = null
try {
res = await window.plwl.lansyncSync({ deviceId: device.id, code, categories, force })
} catch (err) {
res = { ok: false, error: (err && err.message) || '' }
} finally {
active = false
state.lanPushing = false
}
closeModal()
if (res && res.ok) {
const summary = res.summary || {}
toast(t('lansync.done', device.name, summary.uploaded || 0, summary.skipped || 0, summary.deleted || 0), 'success')
toast(t('lansync.done', device.name, summary.uploaded || 0, summary.skipped || 0, summary.deleted || 0, formatBytes(summary.bytes || 0)), 'success')
await renderLanSync()
return
}
@ -1287,6 +1347,107 @@ async function runLanSync(device, code, categories, force) {
toast(t('lansync.failed', message), 'error')
}
// --- receiving a sync (the other device is the source of truth) -------------
// A sync pushed onto THIS device must also be visible here - otherwise the
// library changes under the user's hands with no explanation. Same popup, but
// driven by the events the receiving server emits, and equally not closable
// by a click.
const RECEIVE_WATCHDOG_MS = 5 * 60 * 1000
let receiving = false
let receiveWatchdog = null
let receiveCloseTimer = null
function receiveModalParts() {
const modal = qs('#modal')
if (!receiving || !modal) return null
const title = modal.querySelector('#ls-recv-title')
if (!title) return null
return {
title,
bar: modal.querySelector('#ls-recv-modal progress'),
phase: modal.querySelector('#ls-recv-phase'),
count: modal.querySelector('#ls-recv-count'),
stats: modal.querySelector('#ls-recv-stats'),
bytes: modal.querySelector('#ls-recv-bytes')
}
}
function endReceiving(message) {
if (receiveWatchdog) {
clearTimeout(receiveWatchdog)
receiveWatchdog = null
}
if (receiveCloseTimer) {
clearTimeout(receiveCloseTimer)
receiveCloseTimer = null
}
const wasReceiving = receiving
receiving = false
if (!wasReceiving) return
if (message) toast(message, 'error')
closeModal()
}
// A sender that crashed halfway through must not leave this window locked: the
// popup closes itself if no event arrives for a while (the server drops the
// session after the same idle time).
function armReceiveWatchdog() {
if (receiveWatchdog) clearTimeout(receiveWatchdog)
receiveWatchdog = setTimeout(() => endReceiving(t('lansync.recvIdle')), RECEIVE_WATCHDOG_MS)
}
function onLanReceiving(info) {
if (!info || !info.stage) return
if (state.lanPushing) return // our own push owns the (single) popup
if (!receiving) {
// open the popup only for the start of a sync, and only when the user is
// not in the middle of another dialog (there is one modal at a time)
if (info.stage !== 'start') return
if (qs('#modal-overlay').style.display !== 'none') return
receiving = true
const modal = showModal(`
<h2 id="ls-recv-title"></h2>
<div class="progress-row" id="ls-recv-modal"><progress></progress></div>
<p class="subtitle" id="ls-recv-phase"></p>
<p class="subtitle" id="ls-recv-count"></p>
<p class="subtitle" id="ls-recv-stats"></p>
<p class="subtitle" id="ls-recv-bytes"></p>
`, { locked: true })
modal.querySelector('#ls-recv-title').textContent = t('lansync.receiving', info.from || info.deviceId || '')
armReceiveWatchdog()
return renderReceiving(info)
}
armReceiveWatchdog()
renderReceiving(info)
}
function renderReceiving(info) {
const parts = receiveModalParts()
if (!parts) return
const phaseKey = 'lansync.phase.' + info.stage
parts.phase.textContent = (info.stage === 'start' ? t('lansync.recvWaiting') : t(phaseKey, info.label || '')).trim()
if (info.total > 0) {
parts.bar.max = info.total
parts.bar.value = info.done
parts.count.textContent = t('lansync.progressFiles', info.done, info.total)
} else {
parts.bar.removeAttribute('value')
parts.count.textContent = ''
}
parts.stats.textContent = lanStatsLine(info.items)
parts.bytes.textContent = lanBytesLine(info.bytes, info.totalBytes)
if (info.stage === 'done') {
// let the totals be read, then get out of the way (the library reloads
// itself through lansync:applied)
if (receiveCloseTimer) clearTimeout(receiveCloseTimer)
receiveCloseTimer = setTimeout(() => {
receiveCloseTimer = null
endReceiving('')
}, 4000)
}
}
// Data received from another device changed the local files: reload every
// list so the views show the merged library instead of stale state.
async function reloadLocalData() {
@ -1308,8 +1469,9 @@ function wireLanSync() {
if (state.lansync) state.lansync.devices = devices
if (state.currentView === 'lansync') renderLanDevices(devices)
})
window.plwl.onLanSyncReceiving(info => onLanReceiving(info))
window.plwl.onLanSyncApplied(async info => {
toast(t('lansync.applied', info.from || '', info.files || 0), 'success')
toast(t('lansync.applied', info.from || '', info.files || 0, formatBytes(info.bytes || 0)), 'success')
await reloadLocalData()
})
qs('#ls-reroll-id').addEventListener('click', async () => {
@ -1422,10 +1584,11 @@ function wireEvents() {
toast(t('config.playtimeCleared'), 'success')
})
qs('#modal-overlay').addEventListener('click', e => {
if (modalLocked) return // a sync in progress: the backdrop is not a cancel button
if (e.target === qs('#modal-overlay')) closeModal()
})
document.addEventListener('keydown', e => {
if (e.key === 'Escape') closeModal()
if (e.key === 'Escape' && !modalLocked) closeModal()
})
}