Merge branch 'fix/t12-laya-status-consistency' into develop (#12)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
blomios
2026-10-02 13:31:49 +02:00
6 changed files with 142 additions and 17 deletions

View File

@ -776,6 +776,30 @@ h4 {
gap: var(--space-sm);
}
.laya-component-status {
display: flex;
align-items: baseline;
gap: var(--space-xs);
margin-top: var(--space-sm);
}
#laya-service-state {
margin-top: 4px;
}
.laya-start-command {
padding: var(--space-sm);
border-radius: var(--radius-sm);
background: var(--color-bg-secondary);
overflow-x: auto;
user-select: all;
}
.laya-notice-action {
margin-left: var(--space-sm);
padding: 2px var(--space-xs);
}
.laya-progress {
margin-top: var(--space-md);
}

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=4">
<link rel="stylesheet" href="css/main.css?v=5">
<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"
@ -485,9 +485,10 @@
</div>
<div class="laya-model-panel" id="laya-model-panel">
<div class="laya-model-heading">
<div><strong>Local AI model · Laya</strong><div id="laya-model-status" class="setting-hint" role="status">Checking status…</div></div>
<span id="laya-model-size" class="setting-hint"></span>
<div><strong>Local AI · Laya</strong><div id="laya-model-status" class="setting-hint" role="status">Checking status…</div></div>
</div>
<div class="laya-component-status"><span id="laya-model-state">Model: Checking…</span><span id="laya-model-size" class="setting-hint"></span></div>
<div id="laya-service-state">Local AI service: Checking…</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">
@ -500,8 +501,11 @@
<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-check-again" hidden>Check again</button>
<button type="button" class="btn btn-ghost" id="laya-copy-command" hidden>Copy start command</button>
<button type="button" class="btn btn-ghost" id="laya-use-standard" hidden>Use Standard</button>
</div>
<div id="laya-start-command" class="laya-start-command" hidden><code>docker compose --profile laya up -d</code></div>
<p class="setting-hint">The model runs on this server. Channel names and metadata are not sent to an AI service.</p>
</div>
</div>
@ -1180,9 +1184,9 @@
<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/layaStatusPresentation.js?v=2"></script>
<script src="/js/services/channelVariantGrouping.js?v=4"></script>
<script src="/js/components/ChannelList.js?v=7"></script>
<script src="/js/components/ChannelList.js?v=8"></script>
<script src="/js/components/SourceManager.js?v=2"></script>
<script src="/js/components/EpgGuide.js?v=2"></script>
<script src="/js/pages/HomePage.js?v=2"></script>
@ -1190,7 +1194,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=5"></script>
<script src="/js/pages/Settings.js?v=6"></script>
<script src="/js/pages/WatchPage.js?v=1"></script>
<script src="/js/app.js?v=4"></script>
<script>

View File

@ -33,6 +33,7 @@ class ChannelList {
this.channelLanguagePreferences = this.loadChannelLanguagePreferences();
this.layaLanguageResults = new Map();
this.languageDetectionNotice = '';
this.languageDetectionFallback = false;
this._languageDetectionRequest = 0;
this.loadCollapsedState();
@ -335,6 +336,7 @@ class ChannelList {
const safeMode = ['algo', 'laya', 'hybrid'].includes(mode) ? mode : 'algo';
this.layaLanguageResults.clear();
this.languageDetectionNotice = '';
this.languageDetectionFallback = false;
if (this.channels.length > 0) {
this.render();
void this.refreshLayaLanguages(safeMode);
@ -346,6 +348,7 @@ class ChannelList {
if (mode === 'algo') {
this.layaLanguageResults.clear();
this.languageDetectionNotice = '';
this.languageDetectionFallback = false;
return;
}
@ -359,6 +362,15 @@ class ChannelList {
});
const results = new Map();
try {
const status = await API.laya.getStatus();
if (!status?.modelPresent || !status?.runtimeAvailable) {
if (requestId !== this._languageDetectionRequest) return;
this.layaLanguageResults.clear();
this.languageDetectionFallback = true;
this.languageDetectionNotice = 'Local AI service is unavailable. Standard channel matching is being used.';
this.render();
return;
}
for (let index = 0; index < descriptors.length; index += 64) {
const response = await API.laya.detectLanguages(descriptors.slice(index, index + 64));
(response.results || []).forEach(result => results.set(String(result.id), result));
@ -366,12 +378,12 @@ class ChannelList {
if (requestId !== this._languageDetectionRequest) return;
this.layaLanguageResults = results;
this.languageDetectionNotice = '';
this.languageDetectionFallback = false;
} catch (err) {
if (requestId !== this._languageDetectionRequest) return;
this.layaLanguageResults.clear();
this.languageDetectionNotice = mode === 'hybrid'
? 'Local AI is unavailable; using standard channel matching.'
: 'Local AI is unavailable. Download or start the model in Settings.';
this.languageDetectionFallback = true;
this.languageDetectionNotice = 'Local AI service is unavailable. Standard channel matching is being used.';
console.warn('[ChannelList] Local AI language detection unavailable:', err.message);
}
this.render();
@ -405,7 +417,9 @@ class ChannelList {
selectedVariants: this.selectedVariants,
preferredLanguage: this.preferredLanguage,
languagePreferences: this.channelLanguagePreferences,
languageMode: window.app?.player?.settings?.languageDetectionMode || 'algo',
languageMode: this.languageDetectionFallback
? 'algo'
: (window.app?.player?.settings?.languageDetectionMode || 'algo'),
languageResults: this.layaLanguageResults,
isFavorite: channel => this.isFavorite(channel.sourceId, channel.id),
isHidden: channel => this.isHidden('channel', channel.sourceId, channel.streamId || channel.id)
@ -420,6 +434,15 @@ class ChannelList {
notice.className = 'laya-language-notice';
notice.setAttribute('role', 'status');
notice.textContent = this.languageDetectionNotice;
const settingsButton = document.createElement('button');
settingsButton.type = 'button';
settingsButton.className = 'btn btn-ghost laya-notice-action';
settingsButton.textContent = 'Open settings';
settingsButton.addEventListener('click', () => {
window.app?.navigateTo?.('settings');
window.app?.pages?.settings?.switchTab?.('player');
});
notice.appendChild(settingsButton);
this.container.appendChild(notice);
}

View File

@ -126,6 +126,8 @@ class SettingsPage {
}
});
document.getElementById('laya-use-standard')?.addEventListener('click', () => this.useStandardLanguageDetection());
document.getElementById('laya-check-again')?.addEventListener('click', () => void this.loadLayaStatus());
document.getElementById('laya-copy-command')?.addEventListener('click', () => void this.copyLayaStartCommand());
document.getElementById('laya-show-details')?.addEventListener('click', () => {
const details = document.getElementById('laya-error-details');
const button = document.getElementById('laya-show-details');
@ -149,11 +151,16 @@ class SettingsPage {
renderLayaStatus(status = {}) {
const statusElement = document.getElementById('laya-model-status');
const modelStateElement = document.getElementById('laya-model-state');
const serviceStateElement = document.getElementById('laya-service-state');
const sizeElement = document.getElementById('laya-model-size');
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 checkAgainButton = document.getElementById('laya-check-again');
const copyCommandButton = document.getElementById('laya-copy-command');
const startCommand = document.getElementById('laya-start-command');
const progress = document.getElementById('laya-download-progress');
const progressTrack = document.getElementById('laya-progress-track');
const progressBar = document.getElementById('laya-progress-bar');
@ -165,12 +172,17 @@ class SettingsPage {
const mode = this.app.player?.settings?.languageDetectionMode || 'algo';
const presentation = LayaStatusPresentation.presentLayaStatus(status, mode);
statusElement.textContent = presentation.statusLabel;
modelStateElement.textContent = presentation.modelLabel;
serviceStateElement.textContent = presentation.serviceLabel;
const size = status.sizeBytes || status.runtime?.modelSizeBytes;
sizeElement.textContent = size ? this.formatBytes(size) : '';
sizeElement.textContent = size ? `· ${this.formatBytes(size)}` : '';
downloadButton.hidden = !presentation.showDownload;
downloadButton.textContent = presentation.downloadLabel;
removeButton.hidden = !presentation.showRemove;
useStandardButton.hidden = !presentation.showUseStandard;
checkAgainButton.hidden = !presentation.showCheckAgain;
copyCommandButton.hidden = !presentation.showStartCommand;
startCommand.hidden = !presentation.showStartCommand;
downloadButton.disabled = presentation.busy || Boolean(this.layaOperationPending);
removeButton.disabled = presentation.busy || Boolean(this.layaOperationPending);
@ -199,6 +211,7 @@ class SettingsPage {
details.hidden = true;
details.textContent = presentation.errorDetail;
if (presentation.errorMessage) messageElement.textContent = presentation.errorMessage;
else if (presentation.availabilityMessage) messageElement.textContent = presentation.availabilityMessage;
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.';
@ -266,6 +279,24 @@ class SettingsPage {
this.renderLayaStatus(this.layaStatus);
}
async copyLayaStartCommand() {
const command = 'docker compose --profile laya up -d';
const button = document.getElementById('laya-copy-command');
try {
await navigator.clipboard.writeText(command);
button.textContent = 'Copied';
} catch {
const code = document.querySelector('#laya-start-command code');
const selection = window.getSelection();
const range = document.createRange();
range.selectNodeContents(code);
selection.removeAllRanges();
selection.addRange(range);
button.textContent = 'Command selected';
}
setTimeout(() => { button.textContent = 'Copy start command'; }, 2000);
}
async initTranscodingSettings() {
// Encoder settings
const hwEncoderSelect = document.getElementById('setting-hw-encoder');

View File

@ -37,11 +37,24 @@
const progressPercent = determined ? Math.max(0, Math.min(100, Math.round(percentValue))) : null;
const error = errorParts(status);
const failed = phase === 'failed';
const modelPresent = status.modelPresent === true;
const runtimeAvailable = status.runtimeAvailable === true;
const ready = modelPresent && runtimeAvailable && !busy && !failed;
const statusLabel = ready
? 'Ready'
: failed || busy
? PHASE_LABELS[phase]
: modelPresent && !runtimeAvailable
? 'Local AI service unavailable'
: !modelPresent
? 'Model not downloaded'
: PHASE_LABELS[phase];
return {
phase,
statusLabel: status.modelPresent && phase === 'idle'
? (status.runtimeAvailable ? 'Model ready' : 'Model downloaded · runtime unavailable')
: PHASE_LABELS[phase],
ready,
statusLabel,
modelLabel: `Model: ${modelPresent ? 'Downloaded' : 'Not downloaded'}`,
serviceLabel: `Local AI service: ${runtimeAvailable ? 'Available' : 'Unavailable'}`,
busy,
showProgress: phase === 'downloading',
progressIndeterminate: phase === 'downloading' && progressPercent === null,
@ -53,9 +66,14 @@
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'
showDownload: !busy && !modelPresent && (phase === 'idle' || failed),
showRemove: !busy && modelPresent,
showUseStandard: failed && mode !== 'algo',
showCheckAgain: !busy && modelPresent && !runtimeAvailable,
showStartCommand: !busy && modelPresent && !runtimeAvailable,
availabilityMessage: modelPresent && !runtimeAvailable
? 'The model is installed, but the Local AI service is not running. Standard channel matching is being used.'
: ''
};
}

View File

@ -41,3 +41,28 @@ test('ready models expose removal without download actions', () => {
assert.equal(view.showRemove, true);
assert.equal(view.showDownload, false);
});
test('installed model with unavailable service is not reported ready', () => {
const view = presentLayaStatus({
phase: 'idle',
modelPresent: true,
runtimeAvailable: false,
sizeBytes: 10
}, 'hybrid');
assert.equal(view.ready, false);
assert.equal(view.statusLabel, 'Local AI service unavailable');
assert.equal(view.modelLabel, 'Model: Downloaded');
assert.equal(view.serviceLabel, 'Local AI service: Unavailable');
assert.equal(view.showDownload, false);
assert.equal(view.showRemove, true);
assert.equal(view.showCheckAgain, true);
assert.equal(view.showStartCommand, true);
assert.equal(view.availabilityMessage, 'The model is installed, but the Local AI service is not running. Standard channel matching is being used.');
});
test('missing model keeps download available', () => {
const view = presentLayaStatus({ phase: 'idle', modelPresent: false, runtimeAvailable: false });
assert.equal(view.showDownload, true);
assert.equal(view.showRemove, false);
assert.equal(view.showCheckAgain, false);
});