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:
@ -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');
|
||||
|
||||
63
public/js/services/layaStatusPresentation.js
Normal file
63
public/js/services/layaStatusPresentation.js
Normal 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 };
|
||||
});
|
||||
Reference in New Issue
Block a user