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:
@ -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);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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.'
|
||||
: ''
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user