The model was reported as ready while the AI runtime was actually unreachable, and the Download action stayed visible afterwards. Distinguish model-downloaded from runtime-available in layaStatusPresentation, derive the Download action from the real combined state, and align Settings and ChannelList rendering on that single source of truth. Cover the combined status cases with tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
873 lines
39 KiB
JavaScript
873 lines
39 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();
|
|
|
|
// 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();
|
|
}
|
|
|
|
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);
|
|
});
|
|
});
|
|
|
|
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);
|
|
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 = '';
|
|
}
|
|
|
|
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;
|