From beb286a9a761e816ffaffc85b0592982943dab21 Mon Sep 17 00:00:00 2001 From: FedyaLight Date: Mon, 12 Oct 2026 01:25:40 +0300 Subject: [PATCH 1/2] Remove redundant rendering and hidden input polling --- README.md | 2 + docs/performance-audit.md | 53 ++++++++++++++++++++++++ tests/README.md | 2 +- tests/displays.mjs | 5 +++ tests/performance.mjs | 84 ++++++++++++++++++++++++++++++++++----- web/js/app.js | 37 +++++++++++------ web/js/display-views.js | 38 +++++++++++++----- web/js/lan.js | 9 +++-- web/js/prompter.js | 4 +- web/js/remotes.js | 31 ++++++++++----- web/js/stage.js | 31 +++++++++------ 11 files changed, 234 insertions(+), 62 deletions(-) create mode 100644 docs/performance-audit.md diff --git a/README.md b/README.md index b3b08de..5cbb61d 100644 --- a/README.md +++ b/README.md @@ -122,3 +122,5 @@ Dependency licenses and provenance are kept beside the vendored sources: Efir's original code is licensed under [MIT](LICENSE). Third-party code, fonts and bundled runtimes retain their own licenses; see the notices linked above. + +Resource audit and measurement limits: [performance audit](docs/performance-audit.md). diff --git a/docs/performance-audit.md b/docs/performance-audit.md new file mode 100644 index 0000000..c014905 --- /dev/null +++ b/docs/performance-audit.md @@ -0,0 +1,53 @@ +# Resource audit from startup to shutdown + +Baseline: main `2dead9b`, October 12, 2026. The audit follows the browser app, macOS shell/Swift relay, Windows and Linux Tauri shell/Rust relay, and Android Activity/WebView/service/relay. The homepage star link is a separate PR; the [native resource changes](https://github.com/FedyaLight/efir/pull/4) are independent of the shared renderer changes. + +## Lifecycle coverage and decisions + +| Stage | Owners inspected | Result | +| --- | --- | --- | +| Startup and website entry | `app.js`, `environment.js`, `rtc.js`, site generation, all native bootstraps | Local hosting still avoids PeerJS and external discovery. Stop homepage animation when reduced motion is requested, its demo is outside the viewport, or the page is hidden. Resume immediately when appropriate. | +| HTTP assets | Swift `SiteHandler`, Rust `site`, Android `serveHttp` | Preserve ETag/HEAD, hashed-font caching and existing path handling. Rust whole-file/range copies are replaced with bounded streaming in the native PR. Swift mapping and Android InputStream delivery already avoid that particular double copy. | +| Connections and relay | `net.js`, Swift `RelayHub`, Rust `Hub`, Android `Client` | Keep exact relay, shared native sessions, packet queues, socket deadlines, heartbeat, sequence deduplication and reconnect. Heartbeats are needed for presence and failure detection; lowering their rate would change recovery behavior. | +| Script import, parsing and storage | controller library/editor/import/backups, `store.js`, `words.js`, `Stage` | Preserve local scripts, portable backups, word spans and Unicode segmentation used by speech/seeking. Existing separate position storage avoids rewriting the full script every frame. Do not add unbounded caches or virtualize away words needed for exact layout and speech. | +| Settings and layout | `Stage.setSettings`, per-display overrides, fonts, viewport/rotation handling | Cache the layout signature until settings change. Reuse measured geometry for appearance-only changes and identical settings, while redraws still apply colors/visibility. Real font/margin/marker/text/viewport changes continue to invalidate geometry. | +| Live prompting and preview | `Engine`, `FrameLoop`, controller, reader, `DisplayViews` | Keep frame-rate scrolling and the existing state-send cadence. Stop rewriting unchanged screen labels. Omit redundant profile/display identifiers from high-rate state packets; they remain in hello/presence and dedicated profile messages. Update previews from the latest state after returning to a visible view. | +| Voice and hardware controls | browser/native speech, `VoiceTracker`, `remotes.js` | Keep recognition, transcript cadence, rereading, neutral arming, held-button release and keyboard/media behavior. Stop Gamepad polling when unfocused or hidden, including native visibility signals. Resume on focus/foreground; update diagnostic text only when it changes. Native PR caches translations and removes duplicate speech-state IPC. | +| Hidden views and connection help | platform adapter, macOS occlusion, Tauri hide/minimize, Android pause/read-here, LAN diagnostics | Skip hidden secondary-preview restoration and stationary reader-state sends; transport presence continues. Suspend hidden connection-help UI work and refresh on return. Tauri minimize/restore visibility is forwarded in the native PR. | +| Screen/system protection | browser Wake Lock or 1 fps fallback video, macOS activity, Tauri keepawake, Android flags/service WakeLock | Preserve protection during reading and pause. Removing it can let screens or the host sleep during a live session. Android already uses its screen flag instead of browser video; external-client service CPU protection remains bounded and released when unnecessary. | +| Teardown and retries | role destroy/pagehide, frame loops, timers, speech stop, socket disconnect, service destroy | New observers/listeners/timers are removed with their owner. Keep existing retry backoff, generation guards, bounded audio queues and cancellation. Android cancelled send-watchdog tasks are removed immediately in the native PR. | + +## Shared renderer measurements + +The expanded `tests/performance.mjs` uses one controller and three isolated Chromium readers (844×390, 1600×900, 500×900), a long script, and eight-second samples. It measures real animation callbacks, DOM mutations, storage writes, WebSocket bytes and Chromium main-thread task time. HTTP screen protection remains active on the primary reader. + +| Eight-second sample | Baseline | Changed | +| --- | ---: | ---: | +| Unchanged secondary-screen label mutations, scrolling | 1476 | 0 | +| Unchanged secondary-screen label mutations, paused / settled voice | 24 / 24 | 0 / 0 | +| Controller main-thread CPU, scrolling | 9.23% | 7.86–8.33% in two samples | +| Bytes sent by controller + three readers, scrolling | 499523 | 449451 in the final sample | +| Primary reader animation callbacks, paused / settled voice | 0 / 0 | 0 / 0 | +| Hidden native controller with Gamepad enabled, 600 ms | 36 callbacks | 0 | +| Reduced-motion homepage demo, 600 ms | 36 callbacks | 0 | +| Offscreen homepage demo, 600 ms | Continuous loop in baseline | 0 | + +CPU timing is noisy and workload-dependent; these are individual runs on the same Mac, not a guaranteed percentage on every device. The robust improvements are elimination of redundant label writes, hidden polling and oversized server buffers. Wire-byte measurements cover sends by the four clients, not total network-interface traffic or a battery estimate. The server still fans out packets using its existing relay semantics. + +The [native audit](https://github.com/FedyaLight/efir/pull/4) records a deliberately large slow-download stress case: peak Rust relay RSS changed from 393.3 MiB to 12.5 MiB. Normal bundled assets are much smaller. + +## Behavioral proof + +- `tests/performance.mjs` protects three-screen resource activity, pause/voice idle behavior, screen protection, native hidden Gamepad polling, reduced-motion changes, offscreen demo suspension and resume. The baseline fails the label-churn and hidden-poll regressions for the intended reasons; the reduced-motion baseline also produces continuous frames. +- `tests/remotes.mjs` covers learning, saved keys, repeated/held input, editing/dialog protection, release and browser Gamepad signals. +- `tests/browser.mjs` covers script/settings synchronization, pause/play, mirrors, sections, reconnect, native transcript delivery/rereading, backups, wake protection and six-language behavior. Maximum measured preview error remains below one CSS pixel, with no external requests in local mode. +- `tests/displays.mjs` covers different wraps and fonts, precise previews, format shading, per-screen calibration, follower seeking, duplicate links, reload/rotation, leader failover and stale-state recovery. Its source screen is selected by its user-visible name after reconnect, rather than assuming connection arrival order. +- The responsive matrix covers six languages, two themes, seven sizes and Android hosting/standalone, desktop, local browser and hosted adapters. +- `tests/hosting.mjs` uses real PeerJS/WebRTC, delayed negotiation, broker loss and a configurable PeerServer. +- Native HTTP/WebSocket tests, Cargo and macOS builds are described in the native audit. Repository CI builds Android release/lint, macOS, Windows x64 and Linux x64/ARM64. + +## Limits of the audit + +Shared browser behavior is exercised with actual pages and production relays. Platform adapters in Chromium are contract checks, not substitute native rendering engines. The local native builds run on macOS; other compilation checks come from CI. Physical phone battery, GPU/compositor cost, real keyboard/Gamepad hardware and minimize/restore on every Windows/Linux desktop environment are not measured here. + +A long physical reading session with the intended devices is still the right boundary for battery and display timing. It should cover foreground, screen rotation, minimizing/reopening the operator, network loss and voice use. The changes do not lower scrolling frame rate, recognition quality, synchronization cadence, keep-awake protection or supported script functions to obtain the measured reductions. diff --git a/tests/README.md b/tests/README.md index 51d5a94..ee81235 100644 --- a/tests/README.md +++ b/tests/README.md @@ -40,7 +40,7 @@ On macOS, `http://0.0.0.0:18765` can provide an insecure Chromium context for | `relay.mjs` | MIME, ETag/HEAD, exact relay, no echo, ping/pong, fragmented messages | | `browser.mjs` | Sync, preview, reconnect, tap playback, mirror, voice, languages, backups, HTTP screen protection | | `hosting.mjs` | Static hosting without marker, real PeerJS broker, delayed cross-profile WebRTC, established link during broker loss | -| `performance.mjs` | Actual frame, storage, transport and main-thread activity in Chromium | +| `performance.mjs` | Three-screen frame, storage, transport and main-thread activity; hidden Gamepad and reduced-motion/offscreen demo lifecycle | | `displays.mjs` | Three readers with different wraps, format shading, actual preview geometry, targeted calibration, duplicate links, seeking, reload, rotation, leader failover and stale preview recovery | | `remotes.mjs` | Held key edges, modified keys, button learning, saved mappings, editing/dialog guards, nudge release, browser Gamepad signals | | `layout.mjs` | Six UI languages, light/dark, narrow/landscape/tablet/desktop, dialogs, icon-only exit and fullscreen labels | diff --git a/tests/displays.mjs b/tests/displays.mjs index d20a345..c5320f3 100644 --- a/tests/displays.mjs +++ b/tests/displays.mjs @@ -54,6 +54,11 @@ try { await duplicate.locator('.prompter.has-script.has-ctl').waitFor(); await wait(async () => duplicate.url() !== b.url(), 'duplicate display link receives a distinct identity'); await duplicate.close(); await ctl.reload(); await ctl.locator('.ctl.has-prompter').waitFor(); + // Reconnect order is not a screen identity. Select the source layout + // explicitly before asserting its relationship to the other previews. + await profile(a, a.locator('[data-a="display"]'), { name: 'Камера 1' }); + await wait(async () => (await ctl.locator('.pm-screen').innerText()).includes('Камера 1'), 'named leading screen appears'); + await ctl.locator('.pm-screen').selectOption({ label: 'Камера 1' }); const portrait = await context(500, 900); await portrait.goto(base + '/?screen=portrait#/p/MULTI'); await portrait.locator('.prompter.has-script.has-ctl').waitFor(); await wait(async () => await ctl.locator('.pm-screen option').count() === 3, 'three screens'); diff --git a/tests/performance.mjs b/tests/performance.mjs index d474ac2..7e03599 100644 --- a/tests/performance.mjs +++ b/tests/performance.mjs @@ -11,8 +11,8 @@ try { const mc = await browser.newContext({ viewport: { width: 1320, height: 820 } }); await mc.addInitScript(() => { window.efirNative = { version: 1, role: 'controller', capabilities: { speech: true }, postMessage() {} }; }); const pc = await browser.newContext({ viewport: { width: 844, height: 390 }, hasTouch: true, isMobile: true }); - await pc.addInitScript(() => { - window.perfStats = { raf: 0, writes: 0, writeBytes: 0, sends: 0, sendBytes: 0, states: 0, mutations: 0 }; + const instrument = () => { + window.perfStats = { raf: 0, writes: 0, writeBytes: 0, sends: 0, sendBytes: 0, states: 0, mutations: 0, metadataMutations: 0 }; const raf = window.requestAnimationFrame.bind(window); window.requestAnimationFrame = callback => raf(time => { window.perfStats.raf++; callback(time); }); const write = Storage.prototype.setItem; @@ -26,12 +26,29 @@ try { try { if (JSON.parse(data).t === 'state') window.perfStats.states++; } catch { /* Binary packet. */ } return send.call(this, data); }; - }); + }; + await mc.addInitScript(instrument); + await pc.addInitScript(instrument); const controller = await mc.newPage(), phone = await pc.newPage(); await controller.goto(base + '/#/c/LOCAL'); await phone.goto(phoneBase + '/#/p/LOCAL'); await controller.locator('.ctl.has-prompter').waitFor(); await phone.locator('.prompter.has-script.has-ctl').waitFor(); + const readers = [phone], readerContexts = [pc]; + for (const viewport of [{ width: 1600, height: 900 }, { width: 500, height: 900 }]) { + const context = await browser.newContext({ viewport }); + await context.addInitScript(instrument); + const page = await context.newPage(); + await page.goto(base + '/#/p/LOCAL'); + await page.locator('.prompter.has-script.has-ctl').waitFor(); + readers.push(page); readerContexts.push(context); + } + await controller.evaluate(() => { + window.metadataObserver = new MutationObserver(records => { + window.perfStats.metadataMutations += records.filter(record => record.target.parentElement?.closest('.screen-label,.screen-ready,.screen-config') || record.target.closest?.('.screen-label,.screen-ready,.screen-config')).length; + }); + window.metadataObserver.observe(document.querySelector('.screen-cards'), { attributes: true, childList: true, characterData: true, subtree: true }); + }); await controller.locator('button[data-view="edit"]').first().click(); await controller.locator('.ed-text').fill('# Начало\n' + 'Проверяем ресурсы телефона во время длинного спокойного чтения. '.repeat(700) + '\n\n# Финал\nСпасибо за внимание.'); await controller.locator('button[data-view="live"]').first().click(); @@ -46,19 +63,22 @@ try { window.stageObserver = new MutationObserver(records => window.perfStats.mutations += records.length); window.stageObserver.observe(document.querySelector('.stage'), { attributes: true, childList: true, characterData: true, subtree: true }); }); - const cdp = await pc.newCDPSession(phone); - await cdp.send('Performance.enable'); - const metrics = async () => Object.fromEntries((await cdp.send('Performance.getMetrics')).metrics.map(m => [m.name, m.value])); + const pages = [controller, ...readers]; + const cdps = await Promise.all([mc, ...readerContexts].map((context, i) => context.newCDPSession(pages[i]))); + for (const session of cdps) await session.send('Performance.enable'); + const cdp = cdps[1]; async function sample(name) { await cdp.send('HeapProfiler.collectGarbage'); const videoFrames = () => phone.evaluate(() => document.querySelector('video')?.getVideoPlaybackQuality?.().totalVideoFrames || 0); const beforeVideo = await videoFrames(); - await phone.evaluate(() => { for (const key of Object.keys(window.perfStats)) window.perfStats[key] = 0; }); - const before = await metrics(); + await Promise.all(pages.map(page => page.evaluate(() => { for (const key of Object.keys(window.perfStats)) window.perfStats[key] = 0; }))); + const allMetrics = () => Promise.all(cdps.map(async session => Object.fromEntries((await session.send('Performance.getMetrics')).metrics.map(m => [m.name, m.value])))); + const beforeAll = await allMetrics(), before = beforeAll[1]; await delay(duration); - const after = await metrics(); + const afterAll = await allMetrics(), after = afterAll[1]; + const devices = await Promise.all(pages.map(async (page, i) => ({ role: i ? 'reader' : 'controller', ...await page.evaluate(() => window.perfStats), taskCpuPercent: +(100 * (afterAll[i].TaskDuration - beforeAll[i].TaskDuration) / (afterAll[i].Timestamp - beforeAll[i].Timestamp)).toFixed(3) }))); const stats = await phone.evaluate(() => ({ ...window.perfStats, videoPlaying: !!document.querySelector('video') && !document.querySelector('video').paused })); - result.cases.push({ name, ...stats, taskCpuPercent: +(100 * (after.TaskDuration - before.TaskDuration) / (after.Timestamp - before.Timestamp)).toFixed(3), scriptMs: +((after.ScriptDuration - before.ScriptDuration) * 1000).toFixed(2), layouts: after.LayoutCount - before.LayoutCount, styleRecalculations: after.RecalcStyleCount - before.RecalcStyleCount, decodedVideoFrames: await videoFrames() - beforeVideo, heapMB: +(after.JSHeapUsedSize / 1048576).toFixed(2), domNodes: after.Nodes }); + result.cases.push({ name, ...stats, devices, taskCpuPercent: +(100 * (after.TaskDuration - before.TaskDuration) / (after.Timestamp - before.Timestamp)).toFixed(3), scriptMs: +((after.ScriptDuration - before.ScriptDuration) * 1000).toFixed(2), layouts: after.LayoutCount - before.LayoutCount, styleRecalculations: after.RecalcStyleCount - before.RecalcStyleCount, decodedVideoFrames: await videoFrames() - beforeVideo, heapMB: +(after.JSHeapUsedSize / 1048576).toFixed(2), domNodes: after.Nodes }); } await sample('paused-http-video'); await controller.locator('[data-a="toggle"]').click(); @@ -69,11 +89,53 @@ try { await phone.locator('.prompter.voice-active').waitFor(); await delay(2500); await sample('voice-settled-http-video'); + // The native host may hide a WebView while document.hidden stays false. + // Enabled Gamepad input must stop polling then, and resume on return. + await controller.bringToFront(); + await controller.locator('[data-a="keys"]').click(); + await controller.locator('.remote-gamepad').check(); + await controller.locator('.modal-x').click(); + await controller.locator('.modal-wrap').waitFor({ state: 'detached' }); + await delay(300); + await controller.evaluate(() => window.efirNative.onVisible(false)); + await delay(300); + await controller.evaluate(() => window.perfStats.raf = 0); + await delay(600); + result.nativeHiddenFrames = await controller.evaluate(() => window.perfStats.raf); + assert.equal(result.nativeHiddenFrames, 0, 'hidden native controller stops Gamepad and preview frames'); + await controller.evaluate(() => window.efirNative.onVisible(true)); + await delay(300); + assert.ok(await controller.evaluate(() => window.perfStats.raf) > 0, 'Gamepad polling resumes with the visible focused window'); + await controller.locator('[data-a="keys"]').click(); + await controller.locator('.remote-gamepad').uncheck(); + await controller.locator('.modal-x').click(); + await controller.locator('.modal-wrap').waitFor({ state: 'detached' }); + const home = await browser.newContext({ reducedMotion: 'reduce', viewport: { width: 390, height: 640 } }); + await home.addInitScript(instrument); + await home.route('**/efir-local.json', route => route.fulfill({ status: 404, body: '' })); + const demo = await home.newPage(); + await demo.goto(base); await demo.locator('.home').waitFor(); + await demo.evaluate(() => document.fonts.ready); await delay(500); + await demo.evaluate(() => window.perfStats.raf = 0); await delay(600); + result.reducedMotionDemoFrames = await demo.evaluate(() => window.perfStats.raf); + assert.ok(result.reducedMotionDemoFrames <= 2, 'reduced-motion demo has no continuous animation'); + const demoOffset = () => demo.locator('.demo .scroller').evaluate(el => new DOMMatrix(getComputedStyle(el).transform).m42); + const oldOffset = await demoOffset(); + await demo.emulateMedia({ reducedMotion: 'no-preference' }); await delay(600); + assert.notEqual(await demoOffset(), oldOffset, 'demo resumes when reduced motion is disabled'); + await demo.evaluate(() => scrollTo(0, document.body.scrollHeight)); + await demo.waitForFunction(() => document.querySelector('.demo').getBoundingClientRect().bottom <= 0); + await delay(300); await demo.evaluate(() => window.perfStats.raf = 0); await delay(600); + result.offscreenDemoFrames = await demo.evaluate(() => window.perfStats.raf); + assert.ok(result.offscreenDemoFrames <= 2, 'offscreen demo stops drawing'); + await demo.evaluate(() => scrollTo(0, 0)); await delay(300); + assert.ok(await demo.evaluate(() => window.perfStats.raf) > 0, 'onscreen demo resumes'); + console.log(JSON.stringify(result, null, 2)); for (const sample of result.cases) { + assert.ok(sample.devices[0].metadataMutations <= 8, 'unchanged display labels do not churn during playback'); assert.ok(sample.videoPlaying && sample.decodedVideoFrames > 0, 'screen protection stays active'); assert.ok(sample.decodedVideoFrames <= duration / 1000 * 2 + 3, 'wake video decode budget'); assert.ok(sample.writeBytes < 1024, 'position saves do not rewrite the script'); if (sample.name !== 'scrolling-http-video') assert.ok(sample.raf <= 8, 'no continuous frames while stationary'); } - console.log(JSON.stringify(result, null, 2)); } finally { await browser.close(); } diff --git a/web/js/app.js b/web/js/app.js index 486f490..7a721c1 100644 --- a/web/js/app.js +++ b/web/js/app.js @@ -7,6 +7,7 @@ import { mountController } from './controller.js'; import { mountPrompter } from './prompter.js'; import { icon } from './ui.js'; import { Stage, Engine } from './stage.js'; +import { FrameLoop } from './frames.js'; const app = document.getElementById('app'); @@ -100,19 +101,30 @@ function mountHome(root) { const engine = new Engine(stage); const releaseLanguage = onLanguageChange(() => { stage.setScript(t('sample.demo')); engine.pos = stage.clamp(engine.pos); updateDownload(); }); engine.speed = 3.2; - engine.play(0); - let raf = 0, last = performance.now(), wait = 0; - const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches; - const loop = (t) => { - const dt = Math.min(0.1, (t - last) / 1000); last = t; - if (!reduce) { - if (!engine.playing) { wait += dt; if (wait > 2) { wait = 0; engine.pos = 0; engine.play(0); } } - engine.step(dt); - } + const motion = matchMedia('(prefers-reduced-motion: reduce)'); + let inView = true, restart = 0; + const frames = new FrameLoop(stage, engine, () => { stage.render(engine.snapshot()); - raf = requestAnimationFrame(loop); + if (!motion.matches && !engine.playing && !restart) restart = setTimeout(() => { + restart = 0; + if (platform.visible && inView && !motion.matches) { engine.pos = 0; engine.play(0); frames.request(); } + }, 2000); + }); + const updateDemo = () => { + const active = platform.visible && inView; + if (!active || motion.matches) { clearTimeout(restart); restart = 0; } + if (motion.matches) engine.pause(); + else if (active) engine.play(0); + frames.setActive(active); if (active) frames.request(); }; - raf = requestAnimationFrame(loop); + const demoObserver = typeof IntersectionObserver === 'function' ? new IntersectionObserver(entries => { + inView = entries[0].isIntersecting; updateDemo(); + }) : null; + demoObserver?.observe(el.querySelector('.demo')); + document.addEventListener('visibilitychange', updateDemo); + platform.events.addEventListener('visible', updateDemo); + motion.addEventListener('change', updateDemo); + updateDemo(); const input = el.querySelector('#roomInput'); input.addEventListener('input', () => { @@ -129,7 +141,8 @@ function mountHome(root) { if (c.length < 3) { input.focus(); input.classList.add('shake'); setTimeout(() => input.classList.remove('shake'), 500); return; } location.hash = `#/${b.dataset.role}/${c}`; }); - return { destroy() { releaseTranslation(); releaseLanguage(); cancelAnimationFrame(raf); stage.destroy(); } }; + return { destroy() { releaseTranslation(); releaseLanguage(); frames.destroy(); stage.destroy(); clearTimeout(restart); demoObserver?.disconnect(); + document.removeEventListener('visibilitychange', updateDemo); platform.events.removeEventListener('visible', updateDemo); motion.removeEventListener('change', updateDemo); } }; } if (localMode) await document.fonts?.ready; diff --git a/web/js/display-views.js b/web/js/display-views.js index f9ea8ea..e22fa4c 100644 --- a/web/js/display-views.js +++ b/web/js/display-views.js @@ -1,11 +1,16 @@ import { t } from './i18n.js'; import { Stage, Engine } from './stage.js'; import { FrameLoop } from './frames.js'; +import { platform } from './platform.js'; import { restoreSession } from './session.js'; import { cleanDisplayProfile, displaySettings, DISPLAY_FIELDS } from './display-profile.js'; import { modal } from './ui.js'; const esc = value => String(value).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); +function restorePreview(record) { + restoreSession(record.stage, record.engine, record.state); + record.engine.voiceLive = record.state.voiceState === 'hearing'; +} export const screenId = peer => peer?.hello?.displayId || peer?.id; export function profileDialog(profile, settings, change) { let current = cleanDisplayProfile(profile); @@ -67,6 +72,12 @@ export class DisplayViews { this.host = host; this.configure = configure; this.profile = profile; this.ready = ready; this.active = true; this.records = new Map(); this.primary = null; this.script = ''; this.settings = null; this.staleTimer = setInterval(() => this.checkStale(), 1000); + this.onVisible = () => { + if (!platform.visible || !this.active) return; + for (const record of this.records.values()) if (record.peer && record.state && !record.stale) restorePreview(record); + }; + document.addEventListener('visibilitychange', this.onVisible); + platform.events.addEventListener('visible', this.onVisible); } sync(peers, primary, settings, script, mirror) { this.primary = primary; this.settings = settings; this.script = script; this.mirror = mirror; @@ -123,19 +134,21 @@ export class DisplayViews { const p = record.peer, profile = p ? this.profile(p) : null; if (p) record.name = profile.name || p.hello?.name || t('Суфлёр'); record.el.classList.toggle('offline', !p); - record.el.querySelector('.screen-label').textContent = `${record.name || t('Суфлёр')} · ${record.stage.w}×${record.stage.h}`; - record.el.querySelector('.screen-ready').textContent = !p ? t('Отключён — последний кадр') : !record.state ? t('Синхронизация…') : record.stale ? t('Нет свежих данных — последний кадр') : this.ready(p); - record.el.querySelector('.screen-config').disabled = !p; - record.el.querySelector('.screen-config').textContent = t('Настройки этого экрана'); + const text = (selector, value) => { const el = record.el.querySelector(selector); if (el.textContent !== value) el.textContent = value; }; + text('.screen-label', `${record.name || t('Суфлёр')} · ${record.stage.w}×${record.stage.h}`); + text('.screen-ready', !p ? t('Отключён — последний кадр') : !record.state ? t('Синхронизация…') : record.stale ? t('Нет свежих данных — последний кадр') : this.ready(p)); + const button = record.el.querySelector('.screen-config'); + if (button.disabled !== !p) button.disabled = !p; + text('.screen-config', t('Настройки этого экрана')); } state(peer, state) { const record = this.records.get(screenId(peer)); if (!record) return; - record.state = state; record.seen = performance.now(); record.stale = false; record.el.classList.remove('stale'); + record.state = state; record.seen = performance.now(); + if (record.stale) { record.stale = false; record.el.classList.remove('stale'); } record.stage.setViewport(state.vw, state.vh); this.label(record); - if (!this.active) return; + if (!this.active || !platform.visible) return; record.frames.setActive(true); - restoreSession(record.stage, record.engine, state); - record.engine.voiceLive = state.voiceState === 'hearing'; + restorePreview(record); record.frames.request(); } fit(width, height, others, compare) { @@ -154,7 +167,7 @@ export class DisplayViews { const enabled = active && !!record.peer && !!record.state && !record.stale; if (enabled && !record.frames.active) { record.stage.invalidate(); - if (record.state && record.peer) restoreSession(record.stage, record.engine, record.state); + if (record.state && record.peer) restorePreview(record); } record.frames.setActive(enabled); } @@ -169,5 +182,10 @@ export class DisplayViews { } } stages() { return [...this.records.values()].filter(r => r.peer).map(r => r.stage); } - destroy() { clearInterval(this.staleTimer); for (const r of this.records.values()) { r.frames.destroy(); r.stage.destroy(); } this.records.clear(); } + destroy() { + clearInterval(this.staleTimer); + document.removeEventListener('visibilitychange', this.onVisible); + platform.events.removeEventListener('visible', this.onVisible); + for (const r of this.records.values()) { r.frames.destroy(); r.stage.destroy(); } this.records.clear(); + } } diff --git a/web/js/lan.js b/web/js/lan.js index 578f82f..3c039f2 100644 --- a/web/js/lan.js +++ b/web/js/lan.js @@ -1,6 +1,7 @@ import { t, translateUI, onLanguageChange } from './i18n.js'; import { localMode } from './environment.js'; import { copyText } from './ui.js'; +import { platform } from './platform.js'; import { probeSignaling } from './rtc.js'; // Help WebRTC expose local host candidates. @@ -174,14 +175,15 @@ export function mountLanHelp(host, room, isConnected, { eager = true, always = f box.querySelector('[data-lan="diag"]').onclick = renderDiag; async function update() { - if (disposed) return; + if (disposed || !platform.visible) return; paintDiag(); const connected = isConnected(); const failing = room.status === 'nop2p' || (eager && !connected && Date.now() - started > 9000); box.classList.toggle('show', always || (!connected && failing)); box.classList.toggle('done', unlockedNow); const btn = box.querySelector('[data-lan="unlock"]'); - btn.textContent = unlockedNow ? t('Разрешено здесь ✓ — теперь на втором устройстве') : t('Разрешить поиск в локальной сети'); + const text = unlockedNow ? t('Разрешено здесь ✓ — теперь на втором устройстве') : t('Разрешить поиск в локальной сети'); + if (btn.textContent !== text) btn.textContent = text; // Reuse an existing microphone permission. if (!connected && failing && !autoTried && !unlockedNow) { autoTried = true; @@ -191,7 +193,8 @@ export function mountLanHelp(host, room, isConnected, { eager = true, always = f const timer = setInterval(update, 1500); const onStatus = () => update(); room.addEventListener('status', onStatus); + document.addEventListener('visibilitychange', onStatus); platform.events.addEventListener('visible', onStatus); update(); const releaseLanguage = onLanguageChange(update); - return { update, destroy() { disposed = true; probeAbort?.abort(); releaseTranslation(); releaseLanguage(); clearInterval(timer); room.removeEventListener('status', onStatus); box.remove(); } }; + return { update, destroy() { disposed = true; probeAbort?.abort(); releaseTranslation(); releaseLanguage(); clearInterval(timer); room.removeEventListener('status', onStatus); document.removeEventListener('visibilitychange', onStatus); platform.events.removeEventListener('visible', onStatus); box.remove(); } }; } diff --git a/web/js/prompter.js b/web/js/prompter.js index c737582..74b56dc 100644 --- a/web/js/prompter.js +++ b/web/js/prompter.js @@ -476,7 +476,7 @@ export function mountPrompter(root, code) { // Drawing and state exchange let lastSent = 0, prevSig = '', stateFlush = 0; function sendState(force = false) { - if (!room.peersOf('controller').length || document.hidden) return; + if (!room.peersOf('controller').length || !platform.visible) return; const now = performance.now(); const sig = `${engine.pos.toFixed(1)}|${engine.playing}|${engine.speed}|${Math.ceil(engine.cd)}|${engine.mode}|${engine.voiceIdx}|${voiceState}|${stage.w}x${stage.h}|${stage.max}|${stage.layout}|${commandTag}|${JSON.stringify(profile)}|${script?.id}:${script?.updated}|${syncTag}`; if (!force && sig === prevSig) return; @@ -487,7 +487,7 @@ export function mountPrompter(root, code) { prevSig = sig; lastSent = now; room.send('state', { ...captureSession(stage, engine, script ? script.id + ':' + script.updated : null), - command: commandTag, displayId, profile, + command: commandTag, pos: engine.pos, playing: engine.playing, speed: engine.speed, cd: engine.cd, mode: engine.mode, voiceIdx: engine.voiceIdx, voiceState, max: stage.max, vw: stage.w, vh: stage.h, sid: script ? script.id + ':' + script.updated : null, diff --git a/web/js/remotes.js b/web/js/remotes.js index 63e50d0..3348da2 100644 --- a/web/js/remotes.js +++ b/web/js/remotes.js @@ -1,6 +1,7 @@ // Device-local input bindings. HID keyboards need no device permission. import { t } from './i18n.js'; import { modal } from './ui.js'; +import { platform } from './platform.js'; const KEY = 'efir.remotes.v1'; const ACTIONS = { @@ -59,7 +60,7 @@ export function mountRemotes(handlers) { config.bindings[token] = learning; learning = null; save(); renderDialog(); } function onKey(e) { - if (e.defaultPrevented || e.isComposing || e.key === 'Process' || e.key === 'Dead' || e.ctrlKey || e.metaKey || e.altKey) return; + if (!platform.visible || e.defaultPrevented || e.isComposing || e.key === 'Process' || e.key === 'Dead' || e.ctrlKey || e.metaKey || e.altKey) return; const token = tokenFor(e); if (learning) { if (e.key === 'Escape') { e.preventDefault(); e.stopImmediatePropagation(); learning = null; renderDialog(); return; } @@ -86,15 +87,13 @@ export function mountRemotes(handlers) { document.addEventListener('keydown', onKey, true); document.addEventListener('keyup', onUp, true); document.addEventListener('visibilitychange', onHidden); - window.addEventListener('blur', release); // Explicit opt-in: axes/buttons vary by hardware, so learn rather than guess. function poll() { raf = 0; - if (dead || !config.gamepad || document.hidden) return; + if (!canPoll()) { release(); return; } const signals = new Set(); let padCount = 0, padError = typeof navigator.getGamepads !== 'function'; - if (!document.hasFocus()) { release(); raf = requestAnimationFrame(poll); return; } if (document.hasFocus()) { try { for (const pad of navigator.getGamepads?.() || []) { @@ -106,7 +105,10 @@ export function mountRemotes(handlers) { } catch { padError = true; } } const status = dialog?.el.querySelector('.remote-pad-status'); - if (status) status.textContent = t(padError ? 'Gamepad недоступен в этом браузере. Используйте режим клавиатуры.' : 'Gamepad: {count}. Для обнаружения нажмите кнопку на подключённом пульте.', { count: padCount }); + if (status) { + const text = t(padError ? 'Gamepad недоступен в этом браузере. Используйте режим клавиатуры.' : 'Gamepad: {count}. Для обнаружения нажмите кнопку на подключённом пульте.', { count: padCount }); + if (status.textContent !== text) status.textContent = text; + } if (!padArmed) { if (!signals.size) padArmed = true; } else { for (const token of [...pressed.keys()]) if (token.startsWith('pad:') && !signals.has(token)) stop(token); @@ -117,8 +119,14 @@ export function mountRemotes(handlers) { } raf = requestAnimationFrame(poll); } - const startPoll = () => { if (!raf && config.gamepad && !dead && !document.hidden) raf = requestAnimationFrame(poll); }; - document.addEventListener('visibilitychange', startPoll); + const canPoll = () => !dead && config.gamepad && platform.visible && document.hasFocus(); + const stopPoll = () => { cancelAnimationFrame(raf); raf = 0; release(); }; + const startPoll = () => { if (!raf && canPoll()) raf = requestAnimationFrame(poll); }; + const onActivity = () => { if (!platform.visible) release(); canPoll() ? startPoll() : stopPoll(); }; + document.addEventListener('visibilitychange', onActivity); + platform.events.addEventListener('visible', onActivity); + window.addEventListener('blur', stopPoll); + window.addEventListener('focus', startPoll); startPoll(); function renderDialog() { @@ -133,10 +141,10 @@ export function mountRemotes(handlers) {
${Object.entries(bindings()).filter(([, a]) => a === action).map(([token]) => ``).join('')}
`).join('')}

${t('Пульт работает, пока это окно активно. Фирменные пульты без режима клавиатуры или Gamepad требуют приложения производителя. Медиаклавиши могут перехватываться системой.')}

`; - body.querySelector('.remote-gamepad').onchange = e => { release(); config.gamepad = e.target.checked; save(); cancelAnimationFrame(raf); raf = 0; startPoll(); }; + body.querySelector('.remote-gamepad').onchange = e => { release(); config.gamepad = e.target.checked; save(); onActivity(); }; body.querySelectorAll('[data-learn]').forEach(b => b.onclick = () => { release(); learning = b.dataset.learn; renderDialog(); }); body.querySelectorAll('[data-unbind]').forEach(b => b.onclick = () => { release(); config.bindings[b.dataset.unbind] = 'none'; save(); renderDialog(); }); - body.querySelector('[data-remote-reset]').onclick = () => { release(); config = { bindings: {}, gamepad: false }; learning = null; save(); renderDialog(); }; + body.querySelector('[data-remote-reset]').onclick = () => { stopPoll(); config = { bindings: {}, gamepad: false }; learning = null; save(); renderDialog(); }; } return { open() { @@ -148,8 +156,9 @@ export function mountRemotes(handlers) { destroy() { dead = true; release(); dialog?.close(); cancelAnimationFrame(raf); document.removeEventListener('keydown', onKey, true); document.removeEventListener('keyup', onUp, true); - document.removeEventListener('visibilitychange', onHidden); document.removeEventListener('visibilitychange', startPoll); - window.removeEventListener('blur', release); + document.removeEventListener('visibilitychange', onHidden); document.removeEventListener('visibilitychange', onActivity); + platform.events.removeEventListener('visible', onActivity); + window.removeEventListener('blur', stopPoll); window.removeEventListener('focus', startPoll); }, }; } diff --git a/web/js/stage.js b/web/js/stage.js index 7cd2a84..a47f1b0 100644 --- a/web/js/stage.js +++ b/web/js/stage.js @@ -123,13 +123,17 @@ export class Stage { } setSettings(s) { + if (this.s && Object.keys({ ...this.s, ...s }).every(key => this.s[key] === s[key])) return; + const nextLayout = layoutSignature(s), changedLayout = nextLayout !== this._layout; const changedFont = !this.s || this.s.fontFamily !== s.fontFamily || this.s.fontWeight !== s.fontWeight; this.s = { ...s }; + this._layout = nextLayout; if (changedFont && document.fonts) { document.fonts.load(`${s.fontWeight} ${s.fontSize}px "${s.fontFamily}"`, 'Эфир ABC').then(() => this.invalidate()).catch(() => {}); } this._applyVars(); - this.invalidate(); + if (changedLayout) this.invalidate(); + else this.onInvalidate?.(); } setScript(text) { @@ -189,7 +193,7 @@ export class Stage { this._applyMirror(); } - setShowMirror(v) { this.showMirror = v; this._applyMirror(); } + setShowMirror(v) { if (this.showMirror === v) return; this.showMirror = v; this._applyMirror(); } _applyMirror() { const s = this.s; if (!s) return; @@ -228,7 +232,7 @@ export class Stage { this.secTops = [...this.text.querySelectorAll('.sec')].map(e => e.offsetTop); } - get layout() { return layoutSignature(this.s || {}); } + get layout() { return this._layout || layoutSignature({}); } get markerY() { return (this.s ? this.s.markerPos : 40) * this.h / 100; } @@ -324,15 +328,18 @@ export class Stage { } // HUD - const pps = this.pxPerSec(st.speed || 1); - const elapsed = pos / pps, remain = (this.max - pos) / pps; - const t = fmt(elapsed), r = '−' + fmt(remain); - if (this.hudEl.textContent !== t) this.hudEl.textContent = t; - if (this.hudRm.textContent !== r) this.hudRm.textContent = r; - const progress = this.max ? pos / this.max : 0; - if (progress !== this._lastProgress) { - this._lastProgress = progress; - this.hudBar.style.transform = `scaleX(${progress})`; + if (this.s?.showTimer) { + const pps = this.pxPerSec(st.speed || 1); + const t = fmt(pos / pps), r = '−' + fmt((this.max - pos) / pps); + if (this.hudEl.textContent !== t) this.hudEl.textContent = t; + if (this.hudRm.textContent !== r) this.hudRm.textContent = r; + } + if (this.s?.showProgress) { + const progress = this.max ? pos / this.max : 0; + if (progress !== this._lastProgress) { + this._lastProgress = progress; + this.hudBar.style.transform = `scaleX(${progress})`; + } } return pos; } From 777a6f52f07e87ee2090794065c5574faa9433b0 Mon Sep 17 00:00:00 2001 From: FedyaLight Date: Mon, 12 Oct 2026 01:26:04 +0300 Subject: [PATCH 2/2] Record the final resource validation sample --- docs/performance-audit.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/performance-audit.md b/docs/performance-audit.md index c014905..7d0eb9c 100644 --- a/docs/performance-audit.md +++ b/docs/performance-audit.md @@ -25,8 +25,8 @@ The expanded `tests/performance.mjs` uses one controller and three isolated Chro | --- | ---: | ---: | | Unchanged secondary-screen label mutations, scrolling | 1476 | 0 | | Unchanged secondary-screen label mutations, paused / settled voice | 24 / 24 | 0 / 0 | -| Controller main-thread CPU, scrolling | 9.23% | 7.86–8.33% in two samples | -| Bytes sent by controller + three readers, scrolling | 499523 | 449451 in the final sample | +| Controller main-thread CPU, scrolling | 9.23% | 7.86–8.50% in three samples | +| Bytes sent by controller + three readers, scrolling | 499523 | 449037 in the final sample | | Primary reader animation callbacks, paused / settled voice | 0 / 0 | 0 / 0 | | Hidden native controller with Gamepad enabled, 600 ms | 36 callbacks | 0 | | Reduced-motion homepage demo, 600 ms | 36 callbacks | 0 |