perf: voice list sessionStorage cache — instant render on reload (v1.12.20)

After a successful /api/voices fetch, results are written to sessionStorage
(key ttsvc_vc). On the next page load loadVoiceLibrary() reads the cache and
renders voices immediately before the network request completes, eliminating
the blank-list / skeleton flash entirely.

The background fetch always runs and overwrites the cache with fresh data.
Error handling is graceful: if the network fails but cache was served, the
error is suppressed (stale data stays visible). The Refresh button clears the
cache first to force a full reload cycle.

Silent-refresh logic (from v1.12.19) prevents the second loadVoiceLibrary()
call (engines.js batch E) from blanking already-rendered voices.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-06-28 02:00:48 +02:00
parent ce0f4c43bc
commit ad155f6c5c
4 changed files with 47 additions and 11 deletions

View File

@ -9,6 +9,13 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
---
## [1.12.20] — 2026-06-28
### Performance
- **Voice list sessionStorage cache** — voices are written to `sessionStorage` after every successful fetch (`ttsvc_vc`). On the next page load the list renders instantly from cache while the fresh fetch runs silently in the background. The Refresh button clears the cache first to guarantee a clean reload.
---
## [1.12.19] — 2026-06-28
### Fixed

View File

@ -1 +1 @@
1.12.19
1.12.20

View File

@ -26,7 +26,7 @@
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
<link rel="stylesheet" href="/static/style.css?v=1.12.19">
<link rel="stylesheet" href="/static/style.css?v=1.12.20">
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
@ -336,7 +336,7 @@
<script src="/static/vendor/wavesurfer-regions.min.js"></script>
<!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
<script src="/static/loader.js?v=1.12.19"></script>
<script src="/static/loader.js?v=1.12.20"></script>
</body>
</html>

View File

@ -42,6 +42,18 @@ let _activePlayVoiceId = null;
let _activePlayUrl = null;
let _libraryLoadPromise = null;
const BENCHMARK_SAMPLE_STORAGE_KEY = 'vcf-benchmark-sample-text';
const _VL_CACHE_KEY = 'ttsvc_vc';
function _vlCacheRead() {
try { return JSON.parse(sessionStorage.getItem(_VL_CACHE_KEY) || 'null'); } catch (_) { return null; }
}
function _vlCacheWrite(voices) {
try { sessionStorage.setItem(_VL_CACHE_KEY, JSON.stringify(voices)); } catch (_) {}
}
function _vlCacheClear() {
try { sessionStorage.removeItem(_VL_CACHE_KEY); } catch (_) {}
}
window._vlCacheClear = _vlCacheClear;
const _libraryFilters = {text:'', lang:'', sex:'', type:'', rating:''};
const DEFAULT_BENCHMARK_SAMPLE_TEXT = 'Hello, how are you today? Please read this sample clearly for a fair voice benchmark.';
@ -613,25 +625,42 @@ async function loadVoiceLibrary() {
_libraryLoadPromise = (async () => {
setBusyButton('refresh-voices-btn', true);
const list = $('voice-list');
const silent = _voices.length > 0; // already rendered — refresh without blanking the list
// Serve from sessionStorage cache immediately so the list is never blank on reload
const cached = _voices.length === 0 ? _vlCacheRead() : null;
if (cached && Array.isArray(cached) && cached.length) {
_voices = cached;
window._voices = _voices;
if (typeof window.updateVoiceTree === 'function') window.updateVoiceTree(_voices);
renderVoiceList();
updatePreviewVoiceMatchPanel();
status(`Loaded ${_voices.length} voices`);
}
// Always background-fetch fresh data; skip skeleton if something is already shown
const silent = _voices.length > 0;
if (list && !silent) list.innerHTML = loadingMarkup('Loading voice library', 'Scanning voices, reference text, metadata, ratings, and benchmark results.', 8);
if (!silent) { $('voice-count').textContent = 'Loading voices…'; updateLibraryInsights('loading'); status('Loading voice library…'); }
try {
const r = await fetch('/api/voices');
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); }
_voices = await r.json();
window._voices = _voices; // expose for cross-module access (Script Rehearser etc.)
const fresh = await r.json();
_vlCacheWrite(fresh);
_voices = fresh;
window._voices = _voices;
if (typeof window.updateVoiceTree === 'function') window.updateVoiceTree(_voices);
renderVoiceList();
updatePreviewVoiceMatchPanel();
if (typeof renderPerfHistory === 'function') renderPerfHistory();
status(`Loaded ${_voices.length} voices`);
} catch(e) {
if (list) list.innerHTML = '<div style="color:var(--red);padding:8px;font-size:13px">Failed to load voices: ' + (e.message||String(e)) + '</div>';
$('voice-count').textContent = 'Load failed';
updateLibraryInsights('error');
if (!silent) {
if (list) list.innerHTML = '<div style="color:var(--red);padding:8px;font-size:13px">Failed to load voices: ' + (e.message||String(e)) + '</div>';
$('voice-count').textContent = 'Load failed';
updateLibraryInsights('error');
}
status('Voice library load failed: ' + e.message);
throw e;
if (!silent) throw e;
} finally {
setBusyButton('refresh-voices-btn', false);
_libraryLoadPromise = null;
@ -640,7 +669,7 @@ async function loadVoiceLibrary() {
return _libraryLoadPromise;
}
$('refresh-voices-btn').addEventListener('click', loadVoiceLibrary);
$('refresh-voices-btn').addEventListener('click', () => { _vlCacheClear(); loadVoiceLibrary(); });
$('sync-voice-folders-btn').addEventListener('click', async () => {
$('sync-voice-folders-btn').disabled = true;
status('Syncing active_voices and hidden_voices…');