fix: show Laya model download progress and surface errors in Settings (#12)

Track download progress in layaService (bytes/total, phase, error
state) and expose it through the laya status route. Render a progress
bar during model download in Settings via a layaStatusPresentation
service, and display download/status errors inline instead of failing
silently. Cover progress tracking and status presentation with tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
blomios
2026-10-02 11:49:36 +02:00
parent d134c9790d
commit d276d3b4c3
8 changed files with 486 additions and 48 deletions

View File

@ -776,6 +776,60 @@ h4 {
gap: var(--space-sm);
}
.laya-progress {
margin-top: var(--space-md);
}
.laya-progress-track {
position: relative;
height: 8px;
overflow: hidden;
border-radius: 999px;
background: var(--color-bg-secondary);
}
.laya-progress-bar {
display: block;
width: 0;
height: 100%;
border-radius: inherit;
background: var(--color-accent);
transition: width var(--transition-fast);
}
.laya-progress-track.indeterminate .laya-progress-bar {
width: 35%;
animation: laya-progress-indeterminate 1.2s ease-in-out infinite;
}
@keyframes laya-progress-indeterminate {
from { transform: translateX(-120%); }
to { transform: translateX(360%); }
}
@media (prefers-reduced-motion: reduce) {
.laya-progress-track.indeterminate .laya-progress-bar {
animation: none;
width: 100%;
opacity: .45;
}
}
.laya-details-toggle {
margin-top: var(--space-sm);
padding-left: 0;
}
.laya-error-details {
margin-top: var(--space-xs);
padding: var(--space-sm);
border-radius: var(--radius-sm);
background: var(--color-bg-secondary);
color: var(--color-text-muted);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.laya-model-actions {
justify-content: flex-start;
margin: var(--space-md) 0;

View File

@ -10,7 +10,7 @@
<title>nodecast-tv | IPTV Player</title>
<meta name="description" content="Stream live TV, movies, and series with EPG support">
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="stylesheet" href="css/main.css?v=3">
<link rel="stylesheet" href="css/main.css?v=4">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Outfit:wght@800&display=swap"
@ -489,9 +489,18 @@
<span id="laya-model-size" class="setting-hint"></span>
</div>
<div id="laya-model-message" class="setting-hint"></div>
<div id="laya-download-progress" class="laya-progress" hidden>
<div id="laya-progress-track" class="laya-progress-track" role="progressbar" aria-label="Local AI model download progress">
<span id="laya-progress-bar" class="laya-progress-bar"></span>
</div>
<div id="laya-progress-text" class="setting-hint"></div>
</div>
<button type="button" class="btn btn-ghost laya-details-toggle" id="laya-show-details" aria-expanded="false" hidden>Show details</button>
<pre id="laya-error-details" class="laya-error-details" hidden></pre>
<div class="laya-model-actions">
<button type="button" class="btn btn-primary" id="laya-download-model">Download model</button>
<button type="button" class="btn btn-ghost" id="laya-remove-model">Remove downloaded model</button>
<button type="button" class="btn btn-ghost" id="laya-use-standard" hidden>Use Standard</button>
</div>
<p class="setting-hint">The model runs on this server. Channel names and metadata are not sent to an AI service.</p>
</div>
@ -1171,6 +1180,7 @@
<script src="/js/components/VideoPlayer.js?v=5"></script>
<script src="/js/services/channelLanguageDetection.js?v=1"></script>
<script src="/js/services/channelLanguageFusion.js?v=1"></script>
<script src="/js/services/layaStatusPresentation.js?v=1"></script>
<script src="/js/services/channelVariantGrouping.js?v=4"></script>
<script src="/js/components/ChannelList.js?v=7"></script>
<script src="/js/components/SourceManager.js?v=2"></script>
@ -1180,7 +1190,7 @@
<script src="/js/pages/Guide.js?v=2"></script>
<script src="/js/pages/MoviesPage.js?v=4"></script>
<script src="/js/pages/SeriesPage.js?v=1"></script>
<script src="/js/pages/Settings.js?v=4"></script>
<script src="/js/pages/Settings.js?v=5"></script>
<script src="/js/pages/WatchPage.js?v=1"></script>
<script src="/js/app.js?v=4"></script>
<script>

View File

@ -125,6 +125,14 @@ class SettingsPage {
this.runLayaModelAction('remove');
}
});
document.getElementById('laya-use-standard')?.addEventListener('click', () => this.useStandardLanguageDetection());
document.getElementById('laya-show-details')?.addEventListener('click', () => {
const details = document.getElementById('laya-error-details');
const button = document.getElementById('laya-show-details');
const expanded = button.getAttribute('aria-expanded') === 'true';
button.setAttribute('aria-expanded', String(!expanded));
details.hidden = expanded;
});
void this.loadLayaStatus();
}
@ -135,6 +143,7 @@ class SettingsPage {
this.layaStatus = { runtimeAvailable: false, modelPresent: false, error: err.message };
}
this.renderLayaStatus(this.layaStatus);
if (this.layaStatus?.downloading) this.startLayaStatusPolling();
return this.layaStatus;
}
@ -144,25 +153,56 @@ class SettingsPage {
const messageElement = document.getElementById('laya-model-message');
const downloadButton = document.getElementById('laya-download-model');
const removeButton = document.getElementById('laya-remove-model');
const useStandardButton = document.getElementById('laya-use-standard');
const progress = document.getElementById('laya-download-progress');
const progressTrack = document.getElementById('laya-progress-track');
const progressBar = document.getElementById('laya-progress-bar');
const progressText = document.getElementById('laya-progress-text');
const detailsButton = document.getElementById('laya-show-details');
const details = document.getElementById('laya-error-details');
if (!statusElement) return;
const modelPresent = status.modelPresent === true;
const runtimeAvailable = status.runtimeAvailable === true;
statusElement.textContent = modelPresent
? (runtimeAvailable ? 'Model ready' : 'Model downloaded · runtime unavailable')
: (runtimeAvailable ? 'Model not downloaded' : 'Local AI runtime unavailable');
const size = status.modelSizeBytes || status.sizeBytes || status.runtime?.modelSizeBytes;
sizeElement.textContent = size ? this.formatBytes(size) : '';
downloadButton.hidden = modelPresent;
removeButton.hidden = !modelPresent;
downloadButton.disabled = Boolean(this.layaOperationPending);
removeButton.disabled = Boolean(this.layaOperationPending);
const mode = this.app.player?.settings?.languageDetectionMode || 'algo';
if (this.layaOperationPending) messageElement.textContent = this.layaOperationPending;
else if (mode !== 'algo' && (!modelPresent || !runtimeAvailable)) {
const presentation = LayaStatusPresentation.presentLayaStatus(status, mode);
statusElement.textContent = presentation.statusLabel;
const size = status.sizeBytes || status.runtime?.modelSizeBytes;
sizeElement.textContent = size ? this.formatBytes(size) : '';
downloadButton.hidden = !presentation.showDownload;
downloadButton.textContent = presentation.downloadLabel;
removeButton.hidden = !presentation.showRemove;
useStandardButton.hidden = !presentation.showUseStandard;
downloadButton.disabled = presentation.busy || Boolean(this.layaOperationPending);
removeButton.disabled = presentation.busy || Boolean(this.layaOperationPending);
progress.hidden = !presentation.showProgress;
progressTrack.classList.toggle('indeterminate', presentation.progressIndeterminate);
if (presentation.progressPercent !== null) {
progressTrack.setAttribute('aria-valuemin', '0');
progressTrack.setAttribute('aria-valuemax', '100');
progressTrack.setAttribute('aria-valuenow', String(presentation.progressPercent));
progressBar.style.width = `${presentation.progressPercent}%`;
progressText.textContent = `${presentation.progressPercent}%${presentation.bytesDownloaded !== null ? ` · ${this.formatBytes(presentation.bytesDownloaded)} downloaded` : ''}`;
progressTrack.setAttribute('aria-valuetext', progressText.textContent);
} else {
progressTrack.removeAttribute('aria-valuemin');
progressTrack.removeAttribute('aria-valuemax');
progressTrack.removeAttribute('aria-valuenow');
progressBar.style.width = '';
progressText.textContent = presentation.bytesDownloaded !== null
? `${this.formatBytes(presentation.bytesDownloaded)} downloaded`
: 'Downloading…';
progressTrack.setAttribute('aria-valuetext', progressText.textContent);
}
detailsButton.hidden = !presentation.errorDetail;
detailsButton.setAttribute('aria-expanded', 'false');
details.hidden = true;
details.textContent = presentation.errorDetail;
if (presentation.errorMessage) messageElement.textContent = presentation.errorMessage;
else if (this.layaOperationPending) messageElement.textContent = this.layaOperationPending;
else if (mode !== 'algo' && (!status.modelPresent || !status.runtimeAvailable)) {
messageElement.textContent = 'Local AI is not ready. Download the model and ensure the runtime is running. Live TV will remain available.';
} else messageElement.textContent = status.error || '';
} else messageElement.textContent = '';
}
formatBytes(bytes) {
@ -176,24 +216,56 @@ class SettingsPage {
async runLayaModelAction(action) {
if (this.layaOperationPending) return;
this.layaOperationPending = action === 'download' ? 'Downloading model…' : 'Removing model…';
this.layaStatus = {
...(this.layaStatus || {}),
phase: action === 'download' ? 'preparing' : 'deleting',
downloading: action === 'download'
};
this.renderLayaStatus(this.layaStatus);
const poll = setInterval(() => void this.loadLayaStatus(), 2000);
try {
this.layaStatus = action === 'download'
? await API.laya.downloadModel('multilingual')
: await API.laya.removeModel();
this.layaOperationPending = '';
this.renderLayaStatus(this.layaStatus);
if (this.layaStatus?.downloading) this.startLayaStatusPolling();
else await this.loadLayaStatus();
} catch (err) {
this.layaStatus = { ...(this.layaStatus || {}), error: err.message };
} finally {
clearInterval(poll);
this.layaOperationPending = '';
await this.loadLayaStatus();
if (this.app.player.settings.languageDetectionMode !== 'algo') {
this.app.channelList.setLanguageDetectionMode(this.app.player.settings.languageDetectionMode);
}
}
}
startLayaStatusPolling() {
if (this.layaPollTimer) return;
this.layaPollTimer = setInterval(async () => {
if (this.layaPollInFlight) return;
this.layaPollInFlight = true;
try {
const status = await this.loadLayaStatus();
if (!status?.downloading && ['ready', 'failed', 'idle'].includes(status?.phase)) {
clearInterval(this.layaPollTimer);
this.layaPollTimer = null;
if (status.phase === 'ready' && this.app.player.settings.languageDetectionMode !== 'algo') {
this.app.channelList.setLanguageDetectionMode(this.app.player.settings.languageDetectionMode);
}
}
} finally {
this.layaPollInFlight = false;
}
}, 1000);
}
useStandardLanguageDetection() {
const radio = document.querySelector('input[name="language-detection-mode"][value="algo"]');
if (radio) radio.checked = true;
this.app.player.settings.languageDetectionMode = 'algo';
this.app.player.saveSettings();
this.app.channelList.setLanguageDetectionMode('algo');
this.renderLayaStatus(this.layaStatus);
}
async initTranscodingSettings() {
// Encoder settings
const hwEncoderSelect = document.getElementById('setting-hw-encoder');

View File

@ -0,0 +1,63 @@
(function (root, factory) {
const service = factory();
if (typeof module === 'object' && module.exports) module.exports = service;
if (root) root.LayaStatusPresentation = service;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
const PHASE_LABELS = {
idle: 'Model not downloaded',
checking: 'Checking local AI model…',
downloading: 'Downloading model…',
verifying: 'Verifying model…',
preparing: 'Preparing download…',
ready: 'Model ready',
failed: 'Model download failed',
deleting: 'Removing model…'
};
function errorParts(status) {
const value = status.error || status.lastError;
const fallback = status.lastError;
if (!value) return { message: '', detail: '' };
if (typeof value === 'string') return {
message: value,
detail: typeof fallback === 'object' ? String(fallback.detail || '') : ''
};
return {
message: String(value.message || 'The local AI model operation failed.'),
detail: String(value.detail || '')
};
}
function presentLayaStatus(status = {}, mode = 'algo') {
const phase = PHASE_LABELS[status.phase] ? status.phase : 'idle';
const busy = Boolean(status.downloading) || ['checking', 'downloading', 'verifying', 'preparing', 'deleting'].includes(phase);
const percentValue = Number(status.progressPercent);
const determined = phase === 'downloading' && status.progressPercent !== null &&
status.progressPercent !== undefined && Number.isFinite(percentValue);
const progressPercent = determined ? Math.max(0, Math.min(100, Math.round(percentValue))) : null;
const error = errorParts(status);
const failed = phase === 'failed';
return {
phase,
statusLabel: status.modelPresent && phase === 'idle'
? (status.runtimeAvailable ? 'Model ready' : 'Model downloaded · runtime unavailable')
: PHASE_LABELS[phase],
busy,
showProgress: phase === 'downloading',
progressIndeterminate: phase === 'downloading' && progressPercent === null,
progressPercent,
bytesDownloaded: status.bytesDownloaded !== null && status.bytesDownloaded !== undefined && Number.isFinite(Number(status.bytesDownloaded))
? Number(status.bytesDownloaded) : null,
totalBytes: status.totalBytes !== null && status.totalBytes !== undefined && Number.isFinite(Number(status.totalBytes))
? Number(status.totalBytes) : null,
errorMessage: failed ? (error.message || 'The local AI model could not be downloaded.') : '',
errorDetail: failed ? error.detail : '',
downloadLabel: failed ? 'Retry download' : 'Download model',
showDownload: !busy && !status.modelPresent && (phase === 'idle' || failed),
showRemove: !busy && Boolean(status.modelPresent) && !failed,
showUseStandard: failed && mode !== 'algo'
};
}
return { presentLayaStatus };
});