only latest.log, better notification animation, better playtime updates
This commit is contained in:
parent
53a02cc7da
commit
e082fedf19
4 changed files with 88 additions and 27 deletions
|
|
@ -70,16 +70,37 @@ function setButtonIcons() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Smoothly animate elements of `container` between their previous and new
|
||||||
|
// positions after `mutate()` changes the layout (FLIP technique).
|
||||||
|
function flipShift(container, mutate) {
|
||||||
|
const entries = [...container.children].map(el => ({ el, top: el.getBoundingClientRect().top }))
|
||||||
|
mutate()
|
||||||
|
for (const { el, top } of entries) {
|
||||||
|
if (!el.isConnected) continue
|
||||||
|
const delta = top - el.getBoundingClientRect().top
|
||||||
|
if (!delta) continue
|
||||||
|
el.style.transition = 'none'
|
||||||
|
el.style.transform = `translateY(${delta}px)`
|
||||||
|
void el.offsetHeight // flush styles so the shift back animates
|
||||||
|
el.style.transition = ''
|
||||||
|
el.style.transform = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function toast(message, kind) {
|
function toast(message, kind) {
|
||||||
const el = document.createElement('div')
|
const container = qs('#toasts')
|
||||||
el.className = 'toast' + (kind ? ' ' + kind : '')
|
flipShift(container, () => {
|
||||||
el.textContent = message
|
const el = document.createElement('div')
|
||||||
qs('#toasts').appendChild(el)
|
el.className = 'toast' + (kind ? ' ' + kind : '')
|
||||||
requestAnimationFrame(() => requestAnimationFrame(() => el.classList.add('show')))
|
el.textContent = message
|
||||||
setTimeout(() => {
|
container.appendChild(el)
|
||||||
el.classList.add('hide')
|
requestAnimationFrame(() => requestAnimationFrame(() => el.classList.add('show')))
|
||||||
setTimeout(() => el.remove(), 300)
|
setTimeout(() => {
|
||||||
}, 4000)
|
el.classList.add('hide')
|
||||||
|
// after the fade-out, free the slot with an animated shift instead of a jump
|
||||||
|
setTimeout(() => flipShift(container, () => el.remove()), 300)
|
||||||
|
}, 4000)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function showModal(html) {
|
function showModal(html) {
|
||||||
|
|
@ -318,12 +339,12 @@ function renderLibrary() {
|
||||||
card.querySelector('h3').textContent = game.name
|
card.querySelector('h3').textContent = game.name
|
||||||
const playBtn = card.querySelector('[data-act=play]')
|
const playBtn = card.querySelector('[data-act=play]')
|
||||||
const killBtn = card.querySelector('[data-act=kill]')
|
const killBtn = card.querySelector('[data-act=kill]')
|
||||||
const lastMeta = card.querySelectorAll('.meta')[1]
|
const metas = card.querySelectorAll('.meta')
|
||||||
const applyRunning = isRunning => {
|
const applyRunning = isRunning => {
|
||||||
playBtn.style.display = isRunning ? 'none' : ''
|
playBtn.style.display = isRunning ? 'none' : ''
|
||||||
killBtn.style.display = isRunning ? '' : 'none'
|
killBtn.style.display = isRunning ? '' : 'none'
|
||||||
}
|
}
|
||||||
runningCards.set(game.id, { applyRunning, lastMeta, game })
|
runningCards.set(game.id, { applyRunning, lastMeta: metas[1], playtimeMeta: metas[2], game })
|
||||||
window.plwl.gamesIsRunning(game.id).then(applyRunning)
|
window.plwl.gamesIsRunning(game.id).then(applyRunning)
|
||||||
playBtn.onclick = () => playGame(game)
|
playBtn.onclick = () => playGame(game)
|
||||||
killBtn.onclick = async () => {
|
killBtn.onclick = async () => {
|
||||||
|
|
@ -373,10 +394,14 @@ async function refreshLibraryData() {
|
||||||
for (const [id, entry] of runningCards) {
|
for (const [id, entry] of runningCards) {
|
||||||
entry.applyRunning(runningSet.has(id))
|
entry.applyRunning(runningSet.has(id))
|
||||||
const fresh = state.games.find(g => g.id === id)
|
const fresh = state.games.find(g => g.id === id)
|
||||||
if (fresh && entry.lastMeta) {
|
if (!fresh) continue
|
||||||
|
if (entry.lastMeta) {
|
||||||
const last = fresh.lastPlayed ? new Date(fresh.lastPlayed).toLocaleDateString() : t('library.never')
|
const last = fresh.lastPlayed ? new Date(fresh.lastPlayed).toLocaleDateString() : t('library.never')
|
||||||
entry.lastMeta.textContent = t('library.lastPlayed', last)
|
entry.lastMeta.textContent = t('library.lastPlayed', last)
|
||||||
}
|
}
|
||||||
|
if (entry.playtimeMeta) {
|
||||||
|
entry.playtimeMeta.textContent = t('library.playtime', fmtPlaytime(fresh.playSeconds || 0))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -384,6 +409,10 @@ function wireRealtime() {
|
||||||
window.plwl.onGamesChanged(() => {
|
window.plwl.onGamesChanged(() => {
|
||||||
if (state.currentView === 'library') refreshLibraryData()
|
if (state.currentView === 'library') refreshLibraryData()
|
||||||
})
|
})
|
||||||
|
// pick up live playtime/lastPlayed updates while a game is running
|
||||||
|
setInterval(() => {
|
||||||
|
if (state.currentView === 'library' && runningCards.size) refreshLibraryData()
|
||||||
|
}, 15000)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startImportFlow() {
|
async function startImportFlow() {
|
||||||
|
|
|
||||||
|
|
@ -601,21 +601,21 @@ select {
|
||||||
max-width: 24rem;
|
max-width: 24rem;
|
||||||
box-shadow: 0 0.5rem 2rem rgba(0, 0, 0, 0.4);
|
box-shadow: 0 0.5rem 2rem rgba(0, 0, 0, 0.4);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(-1rem);
|
transform: translateX(1.5rem);
|
||||||
transition:
|
transition:
|
||||||
opacity 0.2s ease,
|
opacity 0.2s ease,
|
||||||
transform 0.25s var(--spring),
|
transform 0.3s ease-out,
|
||||||
background-color 0.15s ease;
|
background-color 0.15s ease;
|
||||||
}
|
}
|
||||||
.toast.error { border-color: var(--big-red); }
|
.toast.error { border-color: var(--big-red); }
|
||||||
.toast.success { border-color: var(--big-green); }
|
.toast.success { border-color: var(--big-green); }
|
||||||
.toast.show {
|
.toast.show {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateY(0);
|
transform: translateX(0);
|
||||||
}
|
}
|
||||||
.toast.hide {
|
.toast.hide {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(-0.5rem);
|
transform: translateX(1rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-row {
|
.progress-row {
|
||||||
|
|
|
||||||
|
|
@ -274,12 +274,12 @@ ipcMain.handle('shell:showItemInFolder', (e, p) => {
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('games:getLogs', (e, id) => {
|
ipcMain.handle('games:getLogs', (e, id) => {
|
||||||
const dir = path.join(DIRS.DATA, 'logs')
|
const file = require('./runner').latestLogPath(id)
|
||||||
let files = []
|
|
||||||
try {
|
try {
|
||||||
files = require('fs').readdirSync(dir).filter(f => f.startsWith(id + '-')).sort()
|
fs.accessSync(file)
|
||||||
|
return [file]
|
||||||
} catch {}
|
} catch {}
|
||||||
return files.map(f => path.join(dir, f))
|
return []
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('games:kill', (e, id) => {
|
ipcMain.handle('games:kill', (e, id) => {
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,38 @@ function parseArgs(args) {
|
||||||
|
|
||||||
const GRACE_MS = 60000
|
const GRACE_MS = 60000
|
||||||
|
|
||||||
|
// Live playtime: update stats while the game is still running (not only on exit).
|
||||||
|
function startPlaytimeTicker({ game, startedAt, onTick }) {
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
const counted = Math.max(0, Math.round((Date.now() - startedAt - GRACE_MS) / 1000))
|
||||||
|
require('./library').updateGame(game.id, {
|
||||||
|
lastPlayed: startedAt,
|
||||||
|
playSeconds: (game.playSeconds || 0) + counted
|
||||||
|
})
|
||||||
|
if (onTick) onTick()
|
||||||
|
}, 30000)
|
||||||
|
return () => clearInterval(timer)
|
||||||
|
}
|
||||||
|
|
||||||
|
function latestLogPath(gameId) {
|
||||||
|
return path.join(DIRS.DATA, 'logs', `${gameId}-latest.log`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openLatestLog(gameId) {
|
||||||
|
const logFile = latestLogPath(gameId)
|
||||||
|
fs.mkdirSync(path.dirname(logFile), { recursive: true })
|
||||||
|
// Keep only the latest run's log: truncate the file from the previous session
|
||||||
|
// and clean up logs left by older versions (<id>-<timestamp>.log).
|
||||||
|
try {
|
||||||
|
for (const f of fs.readdirSync(path.dirname(logFile))) {
|
||||||
|
if (f.startsWith(`${gameId}-`) && f.endsWith('.log') && f !== `${gameId}-latest.log`) {
|
||||||
|
fs.rmSync(path.join(path.dirname(logFile), f), { force: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return fs.createWriteStream(logFile, { flags: 'w' })
|
||||||
|
}
|
||||||
|
|
||||||
function runGame({ game, wine, systemWine, onExit }) {
|
function runGame({ game, wine, systemWine, onExit }) {
|
||||||
const isProton = wine && wine.type === 'proton'
|
const isProton = wine && wine.type === 'proton'
|
||||||
const { env } = buildEnv({ game, wine })
|
const { env } = buildEnv({ game, wine })
|
||||||
|
|
@ -107,20 +139,20 @@ function runGame({ game, wine, systemWine, onExit }) {
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return reject(err)
|
return reject(err)
|
||||||
}
|
}
|
||||||
const logFile = path.join(DIRS.DATA, 'logs', `${game.id}-${Date.now()}.log`)
|
const logFile = latestLogPath(game.id)
|
||||||
fs.mkdirSync(path.dirname(logFile), { recursive: true })
|
const ws = openLatestLog(game.id)
|
||||||
const ws = fs.createWriteStream(logFile)
|
|
||||||
child.stdout.pipe(ws)
|
child.stdout.pipe(ws)
|
||||||
child.stderr.pipe(ws)
|
child.stderr.pipe(ws)
|
||||||
child.on('error', reject)
|
child.on('error', reject)
|
||||||
child.unref()
|
child.unref()
|
||||||
const startedAt = Date.now()
|
const startedAt = Date.now()
|
||||||
|
const stopTicker = startPlaytimeTicker({ game, startedAt })
|
||||||
child.on('exit', (code, signal) => {
|
child.on('exit', (code, signal) => {
|
||||||
|
stopTicker()
|
||||||
ws.end()
|
ws.end()
|
||||||
const playedMs = Date.now() - startedAt
|
const counted = Math.max(0, Math.round((Date.now() - startedAt - GRACE_MS) / 1000))
|
||||||
const counted = playedMs > GRACE_MS ? Math.round((playedMs - GRACE_MS) / 1000) : 0
|
|
||||||
require('./library').updateGame(game.id, {
|
require('./library').updateGame(game.id, {
|
||||||
lastPlayed: Date.now(),
|
lastPlayed: startedAt,
|
||||||
playSeconds: (game.playSeconds || 0) + counted
|
playSeconds: (game.playSeconds || 0) + counted
|
||||||
})
|
})
|
||||||
if (onExit) onExit(code, signal)
|
if (onExit) onExit(code, signal)
|
||||||
|
|
@ -156,4 +188,4 @@ function runTool({ game, wine, tool, args, systemWine }) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { buildEnv, runGame, runTool, parseArgs }
|
module.exports = { buildEnv, runGame, runTool, parseArgs, latestLogPath }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue