From e082fedf19fe3476974f435d57247b974f094596 Mon Sep 17 00:00:00 2001 From: olcxja Date: Sat, 26 Sep 2026 09:33:39 +0200 Subject: [PATCH] only latest.log, better notification animation, better playtime updates --- renderer/app.js | 53 +++++++++++++++++++++++++++++++++++----------- renderer/style.css | 8 +++---- src/main.js | 8 +++---- src/runner.js | 46 ++++++++++++++++++++++++++++++++++------ 4 files changed, 88 insertions(+), 27 deletions(-) diff --git a/renderer/app.js b/renderer/app.js index 42b95a6..d01e971 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -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) { - const el = document.createElement('div') - el.className = 'toast' + (kind ? ' ' + kind : '') - el.textContent = message - qs('#toasts').appendChild(el) - requestAnimationFrame(() => requestAnimationFrame(() => el.classList.add('show'))) - setTimeout(() => { - el.classList.add('hide') - setTimeout(() => el.remove(), 300) - }, 4000) + const container = qs('#toasts') + flipShift(container, () => { + const el = document.createElement('div') + el.className = 'toast' + (kind ? ' ' + kind : '') + el.textContent = message + container.appendChild(el) + requestAnimationFrame(() => requestAnimationFrame(() => el.classList.add('show'))) + setTimeout(() => { + 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) { @@ -318,12 +339,12 @@ function renderLibrary() { card.querySelector('h3').textContent = game.name const playBtn = card.querySelector('[data-act=play]') const killBtn = card.querySelector('[data-act=kill]') - const lastMeta = card.querySelectorAll('.meta')[1] + const metas = card.querySelectorAll('.meta') const applyRunning = isRunning => { playBtn.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) playBtn.onclick = () => playGame(game) killBtn.onclick = async () => { @@ -373,10 +394,14 @@ async function refreshLibraryData() { for (const [id, entry] of runningCards) { entry.applyRunning(runningSet.has(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') 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(() => { 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() { diff --git a/renderer/style.css b/renderer/style.css index e32d5ec..e8801c5 100644 --- a/renderer/style.css +++ b/renderer/style.css @@ -601,21 +601,21 @@ select { max-width: 24rem; box-shadow: 0 0.5rem 2rem rgba(0, 0, 0, 0.4); opacity: 0; - transform: translateY(-1rem); + transform: translateX(1.5rem); transition: opacity 0.2s ease, - transform 0.25s var(--spring), + transform 0.3s ease-out, background-color 0.15s ease; } .toast.error { border-color: var(--big-red); } .toast.success { border-color: var(--big-green); } .toast.show { opacity: 1; - transform: translateY(0); + transform: translateX(0); } .toast.hide { opacity: 0; - transform: translateY(-0.5rem); + transform: translateX(1rem); } .progress-row { diff --git a/src/main.js b/src/main.js index 8bc33da..1414e49 100644 --- a/src/main.js +++ b/src/main.js @@ -274,12 +274,12 @@ ipcMain.handle('shell:showItemInFolder', (e, p) => { }) ipcMain.handle('games:getLogs', (e, id) => { - const dir = path.join(DIRS.DATA, 'logs') - let files = [] + const file = require('./runner').latestLogPath(id) try { - files = require('fs').readdirSync(dir).filter(f => f.startsWith(id + '-')).sort() + fs.accessSync(file) + return [file] } catch {} - return files.map(f => path.join(dir, f)) + return [] }) ipcMain.handle('games:kill', (e, id) => { diff --git a/src/runner.js b/src/runner.js index 60ef235..b0070ca 100644 --- a/src/runner.js +++ b/src/runner.js @@ -77,6 +77,38 @@ function parseArgs(args) { 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 (-.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 }) { const isProton = wine && wine.type === 'proton' const { env } = buildEnv({ game, wine }) @@ -107,20 +139,20 @@ function runGame({ game, wine, systemWine, onExit }) { } catch (err) { return reject(err) } - const logFile = path.join(DIRS.DATA, 'logs', `${game.id}-${Date.now()}.log`) - fs.mkdirSync(path.dirname(logFile), { recursive: true }) - const ws = fs.createWriteStream(logFile) + const logFile = latestLogPath(game.id) + const ws = openLatestLog(game.id) child.stdout.pipe(ws) child.stderr.pipe(ws) child.on('error', reject) child.unref() const startedAt = Date.now() + const stopTicker = startPlaytimeTicker({ game, startedAt }) child.on('exit', (code, signal) => { + stopTicker() ws.end() - const playedMs = Date.now() - startedAt - const counted = playedMs > GRACE_MS ? Math.round((playedMs - GRACE_MS) / 1000) : 0 + const counted = Math.max(0, Math.round((Date.now() - startedAt - GRACE_MS) / 1000)) require('./library').updateGame(game.id, { - lastPlayed: Date.now(), + lastPlayed: startedAt, playSeconds: (game.playSeconds || 0) + counted }) 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 }