Files
Nodecast/public/js/pages/Settings.js
blomios a6759b116d fix: track Laya language batches and normalize labels (#22)
Report post-projection Laya language detection as a busy analysis phase and normalize Laya language labels before caching and display.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-05 23:42:11 +02:00

1006 lines
46 KiB
JavaScript

/**
* Settings Page Controller
*/
class SettingsPage {
constructor(app) {
this.app = app;
this.tabs = document.querySelectorAll('.tabs .tab');
this.tabContents = document.querySelectorAll('.tab-content');
this.init();
}
init() {
// Tab switching
this.tabs.forEach(tab => {
tab.addEventListener('click', () => this.switchTab(tab.dataset.tab));
});
// Player settings
this.initPlayerSettings();
// Transcoding settings
this.initTranscodingSettings();
// User management (admin only)
this.initUserManagement();
}
initPlayerSettings() {
const arrowKeysToggle = document.getElementById('setting-arrow-keys');
const overlayDurationInput = document.getElementById('setting-overlay-duration');
const defaultVolumeSlider = document.getElementById('setting-default-volume');
const volumeValueDisplay = document.getElementById('volume-value');
const rememberVolumeToggle = document.getElementById('setting-remember-volume');
const autoPlayNextToggle = document.getElementById('setting-autoplay-next');
const groupLiveChannelsToggle = document.getElementById('setting-group-live-channels');
// Load current settings
if (this.app.player?.settings) {
arrowKeysToggle.checked = this.app.player.settings.arrowKeysChangeChannel;
overlayDurationInput.value = this.app.player.settings.overlayDuration;
defaultVolumeSlider.value = this.app.player.settings.defaultVolume;
volumeValueDisplay.textContent = this.app.player.settings.defaultVolume + '%';
rememberVolumeToggle.checked = this.app.player.settings.rememberVolume;
autoPlayNextToggle.checked = this.app.player.settings.autoPlayNextEpisode;
groupLiveChannelsToggle.checked = this.app.player.settings.liveChannelListMode !== 'flat';
}
// Arrow keys toggle
arrowKeysToggle.addEventListener('change', () => {
this.app.player.settings.arrowKeysChangeChannel = arrowKeysToggle.checked;
this.app.player.saveSettings();
});
// Overlay duration
overlayDurationInput.addEventListener('change', () => {
this.app.player.settings.overlayDuration = parseInt(overlayDurationInput.value) || 5;
this.app.player.saveSettings();
});
// Default volume slider
defaultVolumeSlider.addEventListener('input', () => {
const value = defaultVolumeSlider.value;
volumeValueDisplay.textContent = value + '%';
this.app.player.settings.defaultVolume = parseInt(value);
this.app.player.saveSettings();
});
// Remember volume toggle
rememberVolumeToggle.addEventListener('change', () => {
this.app.player.settings.rememberVolume = rememberVolumeToggle.checked;
this.app.player.saveSettings();
});
// Auto-play next episode toggle
autoPlayNextToggle.addEventListener('change', () => {
this.app.player.settings.autoPlayNextEpisode = autoPlayNextToggle.checked;
this.app.player.saveSettings();
});
groupLiveChannelsToggle.addEventListener('change', () => {
const mode = groupLiveChannelsToggle.checked ? 'grouped' : 'flat';
this.app.player.settings.liveChannelListMode = mode;
this.app.channelList.setListMode(mode);
this.app.player.saveSettings();
});
this.initLayaSettings();
this.initLiveAnalysis();
// EPG refresh interval
const epgRefreshSelect = document.getElementById('epg-refresh-interval');
if (epgRefreshSelect && this.app.player?.settings) {
// Load saved value from player settings
epgRefreshSelect.value = this.app.player.settings.epgRefreshInterval || '24';
// Save on change - server will restart its sync timer via PUT /api/settings
epgRefreshSelect.addEventListener('change', () => {
this.app.player.settings.epgRefreshInterval = epgRefreshSelect.value;
this.app.player.saveSettings();
});
}
// Update last refreshed display
this.updateEpgLastRefreshed();
}
initLiveAnalysis() {
document.getElementById('live-analysis-button')?.addEventListener('click', () => void this.runLiveAnalysis());
document.getElementById('live-analysis-details-toggle')?.addEventListener('click', () => {
const button = document.getElementById('live-analysis-details-toggle');
const details = document.getElementById('live-analysis-error-details');
const expanded = button.getAttribute('aria-expanded') === 'true';
button.setAttribute('aria-expanded', String(!expanded));
details.hidden = expanded;
});
void this.loadLiveAnalysisStatus();
}
async loadLiveAnalysisStatus() {
try {
this.liveAnalysisStatus = await API.live.getProjectionStatus();
} catch (err) {
this.liveAnalysisStatus = { status: 'error', error: err.message };
}
this.renderLiveAnalysisStatus(this.liveAnalysisStatus);
const presentation = LiveAnalysisPresentation.present(this.liveAnalysisStatus);
if (presentation.busy) this.startLiveAnalysisPolling();
return this.liveAnalysisStatus;
}
renderLiveAnalysisStatus(status = {}) {
const button = document.getElementById('live-analysis-button');
const statusElement = document.getElementById('live-analysis-status');
const progress = document.getElementById('live-analysis-progress');
const track = document.getElementById('live-analysis-progress-track');
const bar = document.getElementById('live-analysis-progress-bar');
const progressText = document.getElementById('live-analysis-progress-text');
const fallback = document.getElementById('live-analysis-fallback');
const detailsButton = document.getElementById('live-analysis-details-toggle');
const details = document.getElementById('live-analysis-error-details');
if (!button || !statusElement) return;
const presentation = LiveAnalysisPresentation.present(status);
button.textContent = presentation.buttonLabel;
button.disabled = presentation.busy;
statusElement.textContent = presentation.statusLabel;
progress.hidden = !presentation.busy;
track.classList.toggle('indeterminate', presentation.progressIndeterminate);
if (presentation.progressPercent === null) {
track.removeAttribute('aria-valuemin');
track.removeAttribute('aria-valuemax');
track.removeAttribute('aria-valuenow');
bar.style.width = '';
} else {
track.setAttribute('aria-valuemin', '0');
track.setAttribute('aria-valuemax', '100');
track.setAttribute('aria-valuenow', String(presentation.progressPercent));
bar.style.width = `${presentation.progressPercent}%`;
}
progressText.textContent = presentation.progressText;
track.setAttribute('aria-valuetext', presentation.progressText);
const mode = this.app.player?.settings?.languageDetectionMode || 'algo';
fallback.textContent = LiveAnalysisPresentation.layaFallbackMessage(mode, this.layaStatus);
detailsButton.hidden = !presentation.errorDetail;
detailsButton.setAttribute('aria-expanded', 'false');
details.hidden = true;
details.textContent = presentation.errorDetail;
}
async runLiveAnalysis() {
const button = document.getElementById('live-analysis-button');
if (button?.disabled) return;
this.liveAnalysisStatus = { ...(this.liveAnalysisStatus || {}), status: 'queued', processed: 0 };
this.renderLiveAnalysisStatus(this.liveAnalysisStatus);
try {
const response = await API.live.rebuildProjection();
this.liveAnalysisStatus = response.projection || response;
this.renderLiveAnalysisStatus(this.liveAnalysisStatus);
this.startLiveAnalysisPolling();
} catch (err) {
this.liveAnalysisStatus = { status: 'error', error: err.message };
this.renderLiveAnalysisStatus(this.liveAnalysisStatus);
}
}
startLiveAnalysisPolling() {
if (this.liveAnalysisPollTimer) return;
this.liveAnalysisPollTimer = setInterval(async () => {
if (this.liveAnalysisPollInFlight) return;
this.liveAnalysisPollInFlight = true;
try {
const status = await this.loadLiveAnalysisStatus();
const presentation = LiveAnalysisPresentation.present(status);
if (!presentation.busy) {
clearInterval(this.liveAnalysisPollTimer);
this.liveAnalysisPollTimer = null;
if (presentation.complete) {
const mode = this.app.player?.settings?.languageDetectionMode || 'algo';
await this.app.channelList?.loadChannels({
refreshLanguages: mode !== 'laya' && mode !== 'hybrid'
});
if (mode === 'laya' || mode === 'hybrid') {
this.liveAnalysisStatus = {
status: 'building',
phase: 'laya_language',
languageProcessed: null,
languageTotal: null
};
this.renderLiveAnalysisStatus(this.liveAnalysisStatus);
await this.app.channelList?.refreshLayaLanguages(mode, {
onProgress: ({ processed, total }) => {
this.liveAnalysisStatus = {
status: 'building',
phase: 'laya_language',
languageProcessed: processed,
languageTotal: total
};
this.renderLiveAnalysisStatus(this.liveAnalysisStatus);
}
});
}
this.liveAnalysisStatus = status;
this.renderLiveAnalysisStatus(status);
}
}
} finally {
this.liveAnalysisPollInFlight = false;
}
}, 1000);
}
initLayaSettings() {
const radios = document.querySelectorAll('input[name="language-detection-mode"]');
const currentMode = this.app.player?.settings?.languageDetectionMode || 'algo';
radios.forEach(radio => {
radio.checked = radio.value === currentMode;
radio.addEventListener('change', () => {
if (!radio.checked) return;
this.app.player.settings.languageDetectionMode = radio.value;
this.app.player.saveSettings();
this.app.channelList.setLanguageDetectionMode(radio.value);
this.renderLayaStatus(this.layaStatus);
this.renderLiveAnalysisStatus(this.liveAnalysisStatus);
});
});
document.getElementById('laya-download-model')?.addEventListener('click', () => this.runLayaModelAction('download'));
document.getElementById('laya-remove-model')?.addEventListener('click', () => {
if (confirm('Remove local AI model?\n\nYour sources, favorites, and hidden channels will not be deleted.')) {
this.runLayaModelAction('remove');
}
});
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');
const expanded = button.getAttribute('aria-expanded') === 'true';
button.setAttribute('aria-expanded', String(!expanded));
details.hidden = expanded;
});
void this.loadLayaStatus();
}
async loadLayaStatus() {
try {
this.layaStatus = await API.laya.getStatus();
} catch (err) {
this.layaStatus = { runtimeAvailable: false, modelPresent: false, error: err.message };
}
this.renderLayaStatus(this.layaStatus);
const mode = this.app.player?.settings?.languageDetectionMode || 'algo';
if (mode !== 'algo' && this.layaStatus?.modelPresent && this.layaStatus?.runtimeAvailable &&
this.app.channelList?.languageDetectionNotice) {
void this.app.channelList.refreshLayaLanguages(mode);
}
if (this.layaStatus?.downloading) this.startLayaStatusPolling();
return this.layaStatus;
}
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');
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 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)}` : '';
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);
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 (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.';
} else messageElement.textContent = '';
if (this.liveAnalysisStatus) this.renderLiveAnalysisStatus(this.liveAnalysisStatus);
}
formatBytes(bytes) {
const value = Number(bytes);
if (!Number.isFinite(value) || value <= 0) return '';
const units = ['B', 'KB', 'MB', 'GB'];
const index = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1);
return `${(value / (1024 ** index)).toFixed(index > 1 ? 1 : 0)} ${units[index]}`;
}
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);
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 };
this.layaOperationPending = '';
await this.loadLayaStatus();
}
}
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 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');
const maxResolutionSelect = document.getElementById('setting-max-resolution');
const qualitySelect = document.getElementById('setting-quality');
// Stream processing (use -tc suffix IDs from Transcoding tab)
const forceProxyToggle = document.getElementById('setting-force-proxy-tc');
const autoTranscodeToggle = document.getElementById('setting-auto-transcode-tc');
const forceTranscodeToggle = document.getElementById('setting-force-transcode-tc');
const forceVideoTranscodeToggle = document.getElementById('setting-force-video-transcode-tc');
const forceRemuxToggle = document.getElementById('setting-force-remux-tc');
const streamFormatSelect = document.getElementById('setting-stream-format-tc');
const maxDirectBitrateSelect = document.getElementById('setting-max-direct-bitrate');
const allowHardwareFallbackToggle = document.getElementById('setting-allow-hardware-fallback');
// User-Agent (Transcoding tab versions)
const userAgentSelect = document.getElementById('setting-user-agent-tc');
const userAgentCustomInput = document.getElementById('setting-user-agent-custom-tc');
const customUaContainer = document.getElementById('custom-user-agent-container-tc');
// Fetch settings directly from API to avoid race condition with VideoPlayer
let s;
try {
s = await API.settings.get();
} catch (err) {
console.warn('[Settings] Failed to load settings from API, using player defaults:', err);
s = this.app.player?.settings || {};
}
if (hwEncoderSelect) hwEncoderSelect.value = s.hwEncoder || 'auto';
if (maxResolutionSelect) maxResolutionSelect.value = s.maxResolution || '1080p';
if (qualitySelect) qualitySelect.value = s.quality || 'medium';
if (forceProxyToggle) forceProxyToggle.checked = s.forceProxy === true;
if (autoTranscodeToggle) autoTranscodeToggle.checked = s.autoTranscode !== false;
if (forceTranscodeToggle) forceTranscodeToggle.checked = s.forceTranscode === true;
if (forceVideoTranscodeToggle) forceVideoTranscodeToggle.checked = s.forceVideoTranscode === true;
if (forceRemuxToggle) forceRemuxToggle.checked = s.forceRemux || false;
if (streamFormatSelect) streamFormatSelect.value = s.streamFormat || 'm3u8';
if (maxDirectBitrateSelect) maxDirectBitrateSelect.value = String(s.maxDirectBitrateKbps || 0);
if (allowHardwareFallbackToggle) allowHardwareFallbackToggle.checked = s.allowHardwareFallback !== false;
if (userAgentSelect) userAgentSelect.value = s.userAgentPreset || 'chrome';
if (userAgentCustomInput) userAgentCustomInput.value = s.userAgentCustom || '';
if (customUaContainer) {
customUaContainer.style.display = userAgentSelect?.value === 'custom' ? 'flex' : 'none';
}
// Event listeners for encoder settings
hwEncoderSelect?.addEventListener('change', () => {
this.app.player.settings.hwEncoder = hwEncoderSelect.value;
this.app.player.saveSettings();
});
maxResolutionSelect?.addEventListener('change', () => {
this.app.player.settings.maxResolution = maxResolutionSelect.value;
this.app.player.saveSettings();
});
qualitySelect?.addEventListener('change', () => {
this.app.player.settings.quality = qualitySelect.value;
this.app.player.saveSettings();
});
// Audio Mix Preset
const audioMixSelect = document.getElementById('setting-audio-mix');
if (audioMixSelect) {
audioMixSelect.value = s.audioMixPreset || 'auto';
audioMixSelect.addEventListener('change', () => {
this.app.player.settings.audioMixPreset = audioMixSelect.value;
this.app.player.saveSettings();
});
}
// Upscaling Settings
const upscaleEnabledToggle = document.getElementById('setting-upscale-enabled');
const upscaleMethodSelect = document.getElementById('setting-upscale-method');
const upscaleTargetSelect = document.getElementById('setting-upscale-target');
const upscaleMethodContainer = document.getElementById('upscale-method-container');
const upscaleTargetContainer = document.getElementById('upscale-target-container');
// Helper to toggle upscale options visibility
const toggleUpscaleOptions = (enabled) => {
if (upscaleMethodContainer) upscaleMethodContainer.style.display = enabled ? 'flex' : 'none';
if (upscaleTargetContainer) upscaleTargetContainer.style.display = enabled ? 'flex' : 'none';
};
// Load upscaling settings
if (upscaleEnabledToggle) {
upscaleEnabledToggle.checked = s.upscaleEnabled || false;
toggleUpscaleOptions(upscaleEnabledToggle.checked);
}
if (upscaleMethodSelect) upscaleMethodSelect.value = s.upscaleMethod || 'hardware';
if (upscaleTargetSelect) upscaleTargetSelect.value = s.upscaleTarget || '1080p';
// Upscaling event handlers
upscaleEnabledToggle?.addEventListener('change', () => {
this.app.player.settings.upscaleEnabled = upscaleEnabledToggle.checked;
this.app.player.saveSettings();
toggleUpscaleOptions(upscaleEnabledToggle.checked);
});
upscaleMethodSelect?.addEventListener('change', () => {
this.app.player.settings.upscaleMethod = upscaleMethodSelect.value;
this.app.player.saveSettings();
});
upscaleTargetSelect?.addEventListener('change', () => {
this.app.player.settings.upscaleTarget = upscaleTargetSelect.value;
this.app.player.saveSettings();
});
// Stream processing toggles
forceProxyToggle?.addEventListener('change', () => {
this.app.player.settings.forceProxy = forceProxyToggle.checked;
this.app.player.saveSettings();
});
autoTranscodeToggle?.addEventListener('change', () => {
this.app.player.settings.autoTranscode = autoTranscodeToggle.checked;
this.app.player.saveSettings();
});
forceTranscodeToggle?.addEventListener('change', () => {
this.app.player.settings.forceTranscode = forceTranscodeToggle.checked;
this.app.player.saveSettings();
});
forceVideoTranscodeToggle?.addEventListener('change', () => {
this.app.player.settings.forceVideoTranscode = forceVideoTranscodeToggle.checked;
this.app.player.saveSettings();
});
forceRemuxToggle?.addEventListener('change', () => {
this.app.player.settings.forceRemux = forceRemuxToggle.checked;
this.app.player.saveSettings();
});
streamFormatSelect?.addEventListener('change', () => {
this.app.player.settings.streamFormat = streamFormatSelect.value;
this.app.player.saveSettings();
});
maxDirectBitrateSelect?.addEventListener('change', () => {
this.app.player.settings.maxDirectBitrateKbps = parseInt(maxDirectBitrateSelect.value, 10) || 0;
this.app.player.saveSettings();
});
allowHardwareFallbackToggle?.addEventListener('change', () => {
this.app.player.settings.allowHardwareFallback = allowHardwareFallbackToggle.checked;
this.app.player.saveSettings();
});
// User-Agent handlers
const toggleCustomInput = () => {
if (customUaContainer) {
customUaContainer.style.display = userAgentSelect?.value === 'custom' ? 'flex' : 'none';
}
};
userAgentSelect?.addEventListener('change', () => {
this.app.player.settings.userAgentPreset = userAgentSelect.value;
this.app.player.saveSettings();
toggleCustomInput();
});
userAgentCustomInput?.addEventListener('change', () => {
this.app.player.settings.userAgentCustom = userAgentCustomInput.value;
this.app.player.saveSettings();
});
}
/**
* Load and display hardware info in Transcoding tab
*/
async loadHardwareInfo() {
const container = document.getElementById('hw-info-container');
if (!container) return;
try {
const response = await fetch('/api/settings/hw-info');
if (!response.ok) throw new Error('Failed to fetch hardware info');
const hwInfo = await response.json();
const detected = [];
// Only show detected hardware
if (hwInfo.nvidia?.available) {
detected.push(`<div class="hw-info-item hw-available">
<span class="hw-badge">✓ NVIDIA</span>
<span class="hw-name">${hwInfo.nvidia.name}</span>
</div>`);
}
if (hwInfo.amf?.available) {
detected.push(`<div class="hw-info-item hw-available">
<span class="hw-badge">✓ AMD</span>
<span class="hw-name">${hwInfo.amf.name || 'Available'}</span>
</div>`);
}
if (hwInfo.qsv?.available) {
detected.push(`<div class="hw-info-item hw-available">
<span class="hw-badge">✓ Intel QSV</span>
<span class="hw-name">Available</span>
</div>`);
}
if (hwInfo.vaapi?.available) {
detected.push(`<div class="hw-info-item hw-available">
<span class="hw-badge">✓ VAAPI</span>
<span class="hw-name">${hwInfo.vaapi.device || 'Available'}</span>
</div>`);
}
let html;
if (detected.length > 0) {
html = `<div class="hw-info-grid">${detected.join('')}</div>`;
html += `<p class="hint" style="margin-top: var(--space-sm);">Recommended encoder: <strong>${hwInfo.recommended}</strong></p>`;
} else {
html = `<p class="hint">No GPU acceleration detected. Using software encoding.</p>`;
}
container.innerHTML = html;
} catch (err) {
console.error('Error loading hardware info:', err);
container.innerHTML = '<p class="hint error">Failed to load hardware info</p>';
}
}
initUserManagement() {
// User tab visibility is handled in show() method
// when currentUser is available
// Handle add user form
const addUserForm = document.getElementById('add-user-form');
if (addUserForm) {
addUserForm.addEventListener('submit', async (e) => {
e.preventDefault();
const username = document.getElementById('new-username').value;
const password = document.getElementById('new-password').value;
const role = document.getElementById('new-role').value;
try {
await API.users.create({ username, password, role });
alert('User created successfully!');
addUserForm.reset();
this.loadUsers();
} catch (err) {
alert('Error creating user: ' + err.message);
}
});
}
}
async loadUsers() {
const userList = document.getElementById('user-list');
if (!userList) return;
try {
const users = await API.users.getAll();
// Store users in memory for easy access during edit
this.users = users;
if (users.length === 0) {
userList.innerHTML = '<tr><td colspan="5" class="hint">No users found</td></tr>';
return;
}
userList.innerHTML = users.map(user => {
const isSSO = !!user.oidcId;
const typeBadge = isSSO
? '<span class="user-badge user-badge-sso">SSO</span>'
: '<span class="user-badge user-badge-local">Local</span>';
const roleBadge = user.role === 'admin'
? '<span class="user-badge user-badge-admin">Admin</span>'
: '<span class="user-badge user-badge-viewer">Viewer</span>';
return `
<tr>
<td>
<div style="display:flex;align-items:center;gap:8px;">
<strong>${user.username}</strong>
${typeBadge}
</div>
</td>
<td>${user.email || '<span class="hint">-</span>'}</td>
<td>${roleBadge}</td>
<td>${user.createdAt ? new Date(user.createdAt).toLocaleDateString() : 'N/A'}</td>
<td>
<button class="btn btn-sm btn-secondary" onclick="window.app.pages.settings.openEditUserModal(${user.id})">Edit</button>
<button class="btn btn-sm btn-error" onclick="window.app.pages.settings.deleteUser(${user.id}, '${user.username}')">Delete</button>
</td>
</tr>
`}).join('');
} catch (err) {
console.error('Error loading users:', err);
userList.innerHTML = '<tr><td colspan="5" class="hint">Error loading users</td></tr>';
}
}
openEditUserModal(userId) {
console.log('openEditUserModal called with ID:', userId, 'Type:', typeof userId);
console.log('Current users list:', this.users);
const user = this.users.find(u => u.id === userId);
if (!user) {
console.error('User not found in this.users cache!');
console.log('Available IDs:', this.users.map(u => u.id));
return;
}
console.log('User found:', user);
const modal = document.getElementById('edit-user-modal');
console.log('Modal element:', modal);
if (!modal) {
console.error('CRITICAL: Modal element #edit-user-modal not found in DOM!');
alert('Error: Modal not found. Please refresh the page.');
return;
}
const isSSO = !!user.oidcId;
console.log('Is SSO user:', isSSO);
// Populate form with null checks
try {
const editId = document.getElementById('edit-user-id');
const editUsername = document.getElementById('edit-username');
const editEmail = document.getElementById('edit-email');
const editRole = document.getElementById('edit-role');
const editPassword = document.getElementById('edit-password');
console.log('Form elements found:', { editId, editUsername, editEmail, editRole, editPassword });
if (editId) editId.value = user.id;
if (editUsername) editUsername.value = user.username;
if (editEmail) editEmail.value = user.email || '';
if (editRole) editRole.value = user.role;
if (editPassword) editPassword.value = '';
// Handle SSO specific UI
const passwordHint = document.getElementById('edit-password-hint');
const oidcGroup = document.getElementById('oidc-info-group');
const oidcIdDisplay = document.getElementById('edit-oidc-id');
if (isSSO) {
if (editPassword) {
editPassword.disabled = true;
editPassword.placeholder = "Managed by SSO Provider";
}
if (passwordHint) passwordHint.textContent = "Password cannot be changed for SSO users.";
if (oidcGroup) oidcGroup.classList.remove('hidden');
if (oidcIdDisplay) oidcIdDisplay.textContent = user.oidcId;
} else {
if (editPassword) {
editPassword.disabled = false;
editPassword.placeholder = "Leave blank to keep current";
}
if (passwordHint) passwordHint.textContent = "Optional. Leave blank to keep unchanged.";
if (oidcGroup) oidcGroup.classList.add('hidden');
}
// Show modal
console.log('Adding active class to modal...');
modal.classList.add('active');
console.log('Modal classes after add:', modal.classList.toString());
// Setup Close/Cancel handlers (once)
this.setupModalHandlers(modal);
console.log('Modal should now be visible!');
} catch (err) {
console.error('Error populating modal:', err);
alert('Error opening edit modal: ' + err.message);
}
}
setupModalHandlers(modal) {
if (this.modalHandlersSetup) return;
const closeBtn = document.getElementById('edit-user-close');
const cancelBtn = document.getElementById('edit-user-cancel');
const saveBtn = document.getElementById('edit-user-save');
const closeModal = () => modal.classList.remove('active');
closeBtn.onclick = closeModal;
cancelBtn.onclick = closeModal;
// Click outside to close
modal.onclick = (e) => {
if (e.target === modal) closeModal();
};
// Save Handler
saveBtn.onclick = async () => {
const userId = document.getElementById('edit-user-id').value;
const updates = {
username: document.getElementById('edit-username').value,
role: document.getElementById('edit-role').value
};
const newPassword = document.getElementById('edit-password').value;
if (newPassword && !document.getElementById('edit-password').disabled) {
updates.password = newPassword;
}
try {
await API.users.update(userId, updates);
// alert('User updated successfully!'); // Optional: Replace with toast?
closeModal();
this.loadUsers();
} catch (err) {
alert('Error updating user: ' + err.message);
}
};
this.modalHandlersSetup = true;
}
async deleteUser(userId, username) {
if (!confirm(`Are you sure you want to delete user "${username}"?`)) {
return;
}
try {
await API.users.delete(userId);
this.loadUsers();
} catch (err) {
alert('Error deleting user: ' + err.message);
}
}
switchTab(tabName) {
this.tabs.forEach(t => t.classList.toggle('active', t.dataset.tab === tabName));
this.tabContents.forEach(c => c.classList.toggle('active', c.id === `tab-${tabName}`));
// Load content browser when switching to that tab
if (tabName === 'content') {
this.app.sourceManager.loadContentSources();
}
// Load users when switching to users tab
if (tabName === 'users') {
this.loadUsers();
}
// Load hardware info when switching to transcode tab
if (tabName === 'transcode') {
this.loadHardwareInfo();
}
}
async show() {
// Show users tab for admin
if (this.app.currentUser && this.app.currentUser.role === 'admin') {
const usersTab = document.getElementById('users-tab');
if (usersTab) {
usersTab.style.display = 'block';
}
}
// Load sources when page is shown
await this.app.sourceManager.loadSources();
// Refresh ALL player settings from server
if (this.app.player?.settings) {
const s = this.app.player.settings;
// Player settings
const arrowKeysToggle = document.getElementById('setting-arrow-keys');
const overlayDurationInput = document.getElementById('setting-overlay-duration');
const defaultVolumeSlider = document.getElementById('setting-default-volume');
const volumeValueDisplay = document.getElementById('volume-value');
const rememberVolumeToggle = document.getElementById('setting-remember-volume');
const autoPlayNextToggle = document.getElementById('setting-autoplay-next');
const groupLiveChannelsToggle = document.getElementById('setting-group-live-channels');
const forceProxyToggle = document.getElementById('setting-force-proxy');
const forceTranscodeToggle = document.getElementById('setting-force-transcode');
const forceRemuxToggle = document.getElementById('setting-force-remux');
const autoTranscodeToggle = document.getElementById('setting-auto-transcode');
const epgRefreshSelect = document.getElementById('epg-refresh-interval');
const streamFormatSelect = document.getElementById('setting-stream-format');
if (arrowKeysToggle) arrowKeysToggle.checked = s.arrowKeysChangeChannel;
if (overlayDurationInput) overlayDurationInput.value = s.overlayDuration;
if (defaultVolumeSlider) defaultVolumeSlider.value = s.defaultVolume;
if (volumeValueDisplay) volumeValueDisplay.textContent = s.defaultVolume + '%';
if (rememberVolumeToggle) rememberVolumeToggle.checked = s.rememberVolume;
if (autoPlayNextToggle) autoPlayNextToggle.checked = s.autoPlayNextEpisode;
if (groupLiveChannelsToggle) groupLiveChannelsToggle.checked = s.liveChannelListMode !== 'flat';
document.querySelectorAll('input[name="language-detection-mode"]').forEach(radio => {
radio.checked = radio.value === (s.languageDetectionMode || 'algo');
});
if (forceProxyToggle) forceProxyToggle.checked = s.forceProxy || false;
if (forceTranscodeToggle) forceTranscodeToggle.checked = s.forceTranscode || false;
if (forceRemuxToggle) forceRemuxToggle.checked = s.forceRemux || false;
if (autoTranscodeToggle) autoTranscodeToggle.checked = s.autoTranscode || false;
if (epgRefreshSelect) epgRefreshSelect.value = s.epgRefreshInterval || '24';
if (streamFormatSelect) streamFormatSelect.value = s.streamFormat || 'm3u8';
// User-Agent settings
const userAgentSelect = document.getElementById('setting-user-agent');
const userAgentCustomInput = document.getElementById('setting-user-agent-custom');
const customUaContainer = document.getElementById('custom-user-agent-container');
if (userAgentSelect) {
userAgentSelect.value = s.userAgentPreset || 'chrome';
if (customUaContainer) {
customUaContainer.style.display = userAgentSelect.value === 'custom' ? 'flex' : 'none';
}
}
if (userAgentCustomInput) userAgentCustomInput.value = s.userAgentCustom || '';
}
// Update EPG last refreshed display
this.updateEpgLastRefreshed();
void this.loadLayaStatus();
}
/**
* Update the EPG last refreshed display
*/
async updateEpgLastRefreshed() {
const display = document.getElementById('epg-last-refreshed');
if (!display) return;
try {
// Fetch last sync time from server
const response = await fetch('/api/settings/sync-status');
if (!response.ok) throw new Error('Failed to fetch sync status');
const data = await response.json();
if (data.lastSyncTime) {
const lastRefreshTime = new Date(data.lastSyncTime);
// Format as relative time or absolute
const now = new Date();
const diffMs = now - lastRefreshTime;
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
let text;
if (diffMins < 1) {
text = 'Just now';
} else if (diffMins < 60) {
text = `${diffMins} minute${diffMins === 1 ? '' : 's'} ago`;
} else if (diffHours < 24) {
text = `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`;
} else {
// Use absolute time for older refreshes
text = lastRefreshTime.toLocaleString();
}
display.textContent = text;
display.title = lastRefreshTime.toLocaleString(); // Full timestamp on hover
} else {
display.textContent = 'Never';
display.title = 'Sync has not run yet since server started';
}
} catch (err) {
console.error('Error fetching sync status:', err);
display.textContent = 'Unknown';
display.title = 'Could not fetch sync status';
}
}
hide() {
// Page is hidden
}
}
window.SettingsPage = SettingsPage;