Files
EuerChat/ui/app.js
T

2677 lines
93 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// EuerIRC Client Frontend Application
(function () {
'use strict';
// --- MIRC IRC Color Palette (Optimized for Dark Mode readability) ---
const mircColors = [
'#ffffff', // 0: White
'#555a64', // 1: Black (visible dark-grey for dark background)
'#3b82f6', // 2: Blue
'#10b981', // 3: Green
'#ef4444', // 4: Red
'#b91c1c', // 5: Brown / Dark Red
'#a855f7', // 6: Purple
'#f97316', // 7: Orange
'#facc15', // 8: Yellow
'#4ade80', // 9: Light Green
'#14b8a6', // 10: Teal / Cyan
'#38bdf8', // 11: Light Cyan
'#60a5fa', // 12: Light Blue
'#f472b6', // 13: Pink / Magenta
'#9ca3af', // 14: Grey
'#e5e7eb' // 15: Light Grey
];
// Font mappings
const fontMap = {
system: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
monospace: 'Consolas, "Courier New", Courier, monospace',
segoe: '"Segoe UI", -apple-system, sans-serif',
inter: '"Inter", -apple-system, sans-serif',
arial: 'Arial, sans-serif',
verdana: 'Verdana, sans-serif',
trebuchet: '"Trebuchet MS", sans-serif'
};
// Density mappings
const densityMap = {
compact: { lineHeight: '1.25', gap: '3px' },
normal: { lineHeight: '1.5', gap: '6px' },
cozy: { lineHeight: '1.8', gap: '10px' }
};
// Direktverbindung: feste Daten des Radio-Chats, nur Name und optionales Passwort sind frei.
const RADIO_IRC = { host: 'irc.euer.tv', port: 6697, channel: '#erde', ssl: true };
// Default settings
const defaultSettings = {
fontFamily: 'system',
customFont: '',
fontSize: 14,
lineHeight: 'normal',
timestampFormat: 'hm',
ircColors: true,
emotesEnabled: true,
emoteSize: 26,
highlightNick: true,
autoScroll: true,
showMediaArea: true,
activeConnection: 'direct',
// Direct Connection Settings
directHost: 'irc.euer.tv',
directPort: 6697,
directNick: '',
directChannel: '#erde',
directPass: '',
directSsl: true,
directAutoConnect: false,
// ZNC Bouncer Connection Settings
zncHost: '',
zncPort: 8250,
zncUser: '',
zncPass: '',
zncNick: '',
zncChannel: '#erde',
zncSsl: false,
zncAutoConnect: false,
// Nur fuer selbstsignierte Zertifikate (unsicher); wird manuell in der config.json gesetzt
acceptInvalidCerts: false,
// Oberflaechen-Skalierung in Prozent
uiScale: 100,
// Manuell sortierte Reihenfolge der Kanal-Tabs
channelOrder: [],
autoUpdateCheck: true,
// Lua & Modi settings
luaEnabled: false,
luaFileName: '',
normalNick: '',
modiNick: '',
internPass: '',
nickservPasswords: {}
};
let currentSettings = { ...defaultSettings };
// Aeltere Nachrichten werden verworfen, damit Speicher und DOM bei Dauerbetrieb nicht wachsen.
const MAX_MESSAGES_PER_CHANNEL = 1000;
// --- State ---
const state = {
connected: false,
connecting: false,
currentChannel: '#erde',
currentNick: '',
channels: {
'#erde': {
topic: 'Verbinde mit irc.euer.tv:6697...',
messages: [],
users: [],
unread: 0
},
'*server*': {
topic: 'IRC Server Systemmeldungen & Konsole',
messages: [],
users: [],
unread: 0
}
},
emotes: {
baseUrl: 'https://chat.euer.tv/static/emocache/',
map: {},
sortedKeys: []
},
isModiActive: false
};
function getTauri() {
return window.__TAURI__ || null;
}
// --- DOM Elements ---
const el = {
statusDot: document.getElementById('statusDot'),
statusText: document.getElementById('statusText'),
statusPing: document.getElementById('statusPing'),
btnHeaderConnect: document.getElementById('btnHeaderConnect'),
headerChanName: document.getElementById('headerChanName'),
headerTopic: document.getElementById('headerTopic'),
btnModiToggle: document.getElementById('btnModiToggle'),
modiToggleLabel: document.getElementById('modiToggleLabel'),
modiDot: document.getElementById('modiDot'),
channelList: document.getElementById('channelList'),
messagesContainer: document.getElementById('messagesContainer'),
chatInput: document.getElementById('chatInput'),
btnSend: document.getElementById('btnSend'),
userCount: document.getElementById('userCount'),
userList: document.getElementById('userList'),
displayNick: document.getElementById('displayNick'),
displayHost: document.getElementById('displayHost'),
userAvatar: document.getElementById('userAvatar'),
// Emote picker
btnEmotePicker: document.getElementById('btnEmotePicker'),
emotePicker: document.getElementById('emotePicker'),
btnCloseEmote: document.getElementById('btnCloseEmote'),
emoteSearch: document.getElementById('emoteSearch'),
emoteGrid: document.getElementById('emoteGrid'),
emoteTabs: document.querySelectorAll('.emote-tab'),
// Unified Settings Modal
btnSettingsModal: document.getElementById('btnSettingsModal'),
settingsModal: document.getElementById('settingsModal'),
btnCloseSettingsModal: document.getElementById('btnCloseSettingsModal'),
btnSaveSettings: document.getElementById('btnSaveSettings'),
btnResetSettings: document.getElementById('btnResetSettings'),
settingsTabs: document.querySelectorAll('.modal-tabs .tab-btn'),
tabBtnRadio: document.getElementById('tabBtnRadio'),
// Display pane
optFontFamily: document.getElementById('optFontFamily'),
optFontCustom: document.getElementById('optFontCustom'),
optFontSize: document.getElementById('optFontSize'),
valFontSize: document.getElementById('valFontSize'),
optLineHeight: document.getElementById('optLineHeight'),
optTimestamp: document.getElementById('optTimestamp'),
optUiScale: document.getElementById('optUiScale'),
// Updates
updCurrentVersion: document.getElementById('updCurrentVersion'),
optAutoUpdate: document.getElementById('optAutoUpdate'),
btnCheckUpdate: document.getElementById('btnCheckUpdate'),
updStatusText: document.getElementById('updStatusText'),
updateModal: document.getElementById('updateModal'),
updSummary: document.getElementById('updSummary'),
updNotes: document.getElementById('updNotes'),
updProgress: document.getElementById('updProgress'),
updProgressBar: document.getElementById('updProgressBar'),
updModalStatus: document.getElementById('updModalStatus'),
btnUpdateInstall: document.getElementById('btnUpdateInstall'),
btnUpdateLater: document.getElementById('btnUpdateLater'),
btnCloseUpdate: document.getElementById('btnCloseUpdate'),
optIrcColors: document.getElementById('optIrcColors'),
optEmotes: document.getElementById('optEmotes'),
optEmoteSize: document.getElementById('optEmoteSize'),
optHighlight: document.getElementById('optHighlight'),
optAutoScroll: document.getElementById('optAutoScroll'),
optShowHeaderTrack: document.getElementById('optShowHeaderTrack'),
optShowMediaArea: document.getElementById('optShowMediaArea'),
settingsPreview: document.getElementById('settingsPreview'),
// Server & ZNC pane
btnTypeDirect: document.getElementById('btnTypeDirect'),
btnTypeZnc: document.getElementById('btnTypeZnc'),
formDirect: document.getElementById('formDirect'),
formZnc: document.getElementById('formZnc'),
directHost: document.getElementById('directHost'),
directPort: document.getElementById('directPort'),
directNick: document.getElementById('directNick'),
directChannel: document.getElementById('directChannel'),
directPass: document.getElementById('directPass'),
directSsl: document.getElementById('directSsl'),
directAutoConnect: document.getElementById('directAutoConnect'),
btnSaveDirect: document.getElementById('btnSaveDirect'),
btnConnectNow: document.getElementById('btnConnectNow'),
btnClearDirect: document.getElementById('btnClearDirect'),
directSaveFeedback: document.getElementById('directSaveFeedback'),
zncHost: document.getElementById('zncHost'),
zncPort: document.getElementById('zncPort'),
zncUser: document.getElementById('zncUser'),
zncPass: document.getElementById('zncPass'),
zncNick: document.getElementById('zncNick'),
zncChannel: document.getElementById('zncChannel'),
zncSsl: document.getElementById('zncSsl'),
zncAutoConnect: document.getElementById('zncAutoConnect'),
btnSaveZnc: document.getElementById('btnSaveZnc'),
btnConnectZncNow: document.getElementById('btnConnectZncNow'),
btnClearZnc: document.getElementById('btnClearZnc'),
zncSaveFeedback: document.getElementById('zncSaveFeedback'),
// Radio & Modi pane
cfgNormalNick: document.getElementById('cfgNormalNick'),
cfgModiNick: document.getElementById('cfgModiNick'),
cfgInternPass: document.getElementById('cfgInternPass'),
tabBtnRadio: document.getElementById('tabBtnRadio'),
secRadio: document.getElementById('secRadio'),
// Lua pane
tabBtnLua: document.getElementById('tabBtnLua'),
secLua: document.getElementById('secLua'),
luaPickerBox: document.getElementById('luaPickerBox'),
luaModiDetails: document.getElementById('luaModiDetails'),
btnSelectLua: document.getElementById('btnSelectLua'),
luaFileInput: document.getElementById('luaFileInput'),
luaFileName: document.getElementById('luaFileName'),
chkEnableLua: document.getElementById('chkEnableLua'),
scriptStatusText: document.getElementById('scriptStatusText'),
loadedScriptsList: document.getElementById('loadedScriptsList'),
btnAddChan: document.getElementById('btnAddChan'),
// Header & Track Area
headerLeft: document.querySelector('.header-left'),
headerArtist: document.getElementById('headerArtist'),
headerTrack: document.getElementById('headerTrack'),
headerCoverBox: document.getElementById('headerCoverBox'),
streamCoverImg: document.getElementById('streamCoverImg'),
// Sidebar Media Area (Cover/Track, Video, Listeners, Grußbox)
sidebarMediaArea: document.getElementById('sidebarMediaArea'),
sidebarTrackHeader: document.getElementById('sidebarTrackHeader'),
sidebarCoverBox: document.getElementById('sidebarCoverBox'),
sidebarCoverImg: document.getElementById('sidebarCoverImg'),
sidebarTrackInfo: document.getElementById('sidebarTrackInfo'),
sidebarArtist: document.getElementById('sidebarArtist'),
sidebarTrack: document.getElementById('sidebarTrack'),
sidebarBottomBar: document.getElementById('sidebarBottomBar'),
statTotalListeners: document.getElementById('statTotalListeners'),
statVideoListeners: document.getElementById('statVideoListeners'),
statAudioListeners: document.getElementById('statAudioListeners'),
btnOpenGrussbox: document.getElementById('btnOpenGrussbox'),
grussboxModal: document.getElementById('grussboxModal'),
btnCloseGrussbox: document.getElementById('btnCloseGrussbox'),
btnCancelGruss: document.getElementById('btnCancelGruss'),
btnSubmitGruss: document.getElementById('btnSubmitGruss'),
gbNameInput: document.getElementById('gbNameInput'),
gbTextInput: document.getElementById('gbTextInput'),
gbFeedback: document.getElementById('gbFeedback'),
// Resizer & Sidebar
sidebarResizer: document.getElementById('sidebarResizer'),
sidebarUsers: document.querySelector('.sidebar-users'),
videoQualitySelect: document.getElementById('videoQualitySelect'),
// Sidebar Video Player Controls
btnStreamMute: document.getElementById('btnStreamMute'),
streamMuteIcon: document.getElementById('streamMuteIcon'),
streamVolSlider: document.getElementById('streamVolSlider'),
btnResyncStream: document.getElementById('btnResyncStream'),
streamVideo: document.getElementById('streamVideo'),
// User Context Menu Popover
userContextMenu: document.getElementById('userContextMenu'),
ctxUserPrefix: document.getElementById('ctxUserPrefix'),
ctxUserNick: document.getElementById('ctxUserNick')
};
// --- Settings Management ---
async function loadSettings() {
// Fruehere Versionen haben Einstellungen (inkl. Passwoertern) im localStorage dupliziert.
try {
localStorage.removeItem('euerirc_settings');
localStorage.removeItem('euerirc_last_nick');
} catch (e) { /* ignorieren */ }
const tauri = getTauri();
if (tauri && tauri.core) {
try {
const fileContent = await tauri.core.invoke('load_app_config');
if (fileContent) {
currentSettings = { ...defaultSettings, ...JSON.parse(fileContent) };
}
} catch (err) {
// config.json existiert beim ersten Start noch nicht und wird beim Speichern angelegt
}
}
applySettings(currentSettings);
}
function setConnectionTab(type) {
currentSettings.activeConnection = type;
if (type === 'znc') {
if (el.btnTypeZnc) el.btnTypeZnc.classList.add('active');
if (el.btnTypeDirect) el.btnTypeDirect.classList.remove('active');
if (el.formZnc) {
el.formZnc.classList.remove('hidden');
el.formZnc.classList.add('active');
}
if (el.formDirect) {
el.formDirect.classList.add('hidden');
el.formDirect.classList.remove('active');
}
} else {
if (el.btnTypeDirect) el.btnTypeDirect.classList.add('active');
if (el.btnTypeZnc) el.btnTypeZnc.classList.remove('active');
if (el.formDirect) {
el.formDirect.classList.remove('hidden');
el.formDirect.classList.add('active');
}
if (el.formZnc) {
el.formZnc.classList.add('hidden');
el.formZnc.classList.remove('active');
}
}
}
let appliedUiScale = 100;
function applyUiScale(percent) {
const scale = Number(percent) || 100;
if (scale === appliedUiScale) return;
const tauri = getTauri();
if (!tauri || !tauri.core) return;
tauri.core.invoke('set_ui_zoom', { factor: scale / 100 })
.then(() => { appliedUiScale = scale; })
.catch((err) => console.warn('Skalierung fehlgeschlagen:', err));
}
function applySettings(s) {
const root = document.documentElement;
let fontCss = fontMap.system;
if (s.customFont && s.customFont.trim().length > 0) {
fontCss = `"${s.customFont.trim()}", sans-serif`;
} else if (s.fontFamily && s.fontFamily !== 'system') {
fontCss = fontMap[s.fontFamily] || `"${s.fontFamily}", sans-serif`;
}
const density = densityMap[s.lineHeight] || densityMap.normal;
root.style.setProperty('--chat-font-family', fontCss);
root.style.setProperty('--chat-font-size', `${s.fontSize}px`);
root.style.setProperty('--chat-line-height', density.lineHeight);
root.style.setProperty('--chat-msg-gap', density.gap);
root.style.setProperty('--chat-emote-size', `${s.emoteSize}px`);
applyUiScale(s.uiScale);
if (el.optUiScale) el.optUiScale.value = String(s.uiScale || 100);
if (el.optAutoUpdate) el.optAutoUpdate.checked = s.autoUpdateCheck !== false;
// Visibility toggles
if (el.sidebarMediaArea) {
const isVisible = s.showMediaArea !== false;
el.sidebarMediaArea.classList.toggle('hidden', !isVisible);
if (!isVisible) {
stopStreamPlayer();
} else {
if (!hlsInstance && (!el.streamVideo || el.streamVideo.paused || !el.streamVideo.src)) {
startStreamPlayer();
}
}
}
// Populate modal controls
if (el.optFontFamily) el.optFontFamily.value = s.fontFamily || 'system';
if (el.optFontCustom) el.optFontCustom.value = s.customFont || '';
if (el.optFontSize) el.optFontSize.value = s.fontSize;
if (el.valFontSize) el.valFontSize.textContent = `${s.fontSize}px`;
if (el.optLineHeight) el.optLineHeight.value = s.lineHeight;
if (el.optTimestamp) el.optTimestamp.value = s.timestampFormat;
if (el.optIrcColors) el.optIrcColors.checked = s.ircColors;
if (el.optEmotes) el.optEmotes.checked = s.emotesEnabled;
if (el.optEmoteSize) el.optEmoteSize.value = s.emoteSize;
if (el.optHighlight) el.optHighlight.checked = s.highlightNick;
if (el.optAutoScroll) el.optAutoScroll.checked = s.autoScroll;
if (el.optShowMediaArea) el.optShowMediaArea.checked = s.showMediaArea !== false;
// Direct Connection fields
const directNick = s.directNick || s.normalNick || '';
if (el.directNick) el.directNick.value = directNick;
if (el.directPass) el.directPass.value = s.directPass || '';
if (el.directAutoConnect) el.directAutoConnect.checked = !!s.directAutoConnect;
// ZNC Connection fields
if (el.zncHost) el.zncHost.value = s.zncHost || '';
if (el.zncPort) el.zncPort.value = s.zncPort || 8250;
if (el.zncUser) el.zncUser.value = s.zncUser || '';
if (el.zncPass) el.zncPass.value = s.zncPass || '';
if (el.zncNick) el.zncNick.value = s.zncNick || '';
if (el.zncChannel) el.zncChannel.value = s.zncChannel || '#erde';
if (el.zncSsl) el.zncSsl.checked = !!s.zncSsl;
if (el.zncAutoConnect) el.zncAutoConnect.checked = !!s.zncAutoConnect;
// Connection tab activation
setConnectionTab(s.activeConnection || 'direct');
// Radio & Modi fields
const activeNick = (s.activeConnection === 'znc' && s.zncNick) ? s.zncNick : directNick;
if (el.cfgNormalNick) el.cfgNormalNick.value = s.normalNick || activeNick || '';
if (el.cfgModiNick) el.cfgModiNick.value = s.modiNick || '';
if (el.cfgInternPass) el.cfgInternPass.value = s.internPass || '';
// Lua fields
if (el.chkEnableLua) el.chkEnableLua.checked = !!s.luaEnabled;
if (el.luaFileName && s.luaFileName) el.luaFileName.textContent = s.luaFileName;
updateLuaUI();
updatePreview();
}
function updateLuaUI() {
const isLoaded = !!(currentSettings.luaEnabled && currentSettings.luaFileName);
if (el.luaModiDetails) {
el.luaModiDetails.classList.toggle('hidden', !isLoaded);
}
if (isLoaded) {
if (el.btnModiToggle) el.btnModiToggle.classList.remove('hidden');
if (el.tabBtnRadio) el.tabBtnRadio.classList.remove('hidden');
if (el.chkEnableLua) el.chkEnableLua.checked = true;
if (el.luaFileName) el.luaFileName.textContent = currentSettings.luaFileName;
if (el.scriptStatusText) {
el.scriptStatusText.textContent = `✓ Aktiv: ${currentSettings.luaFileName}`;
el.scriptStatusText.style.color = 'var(--accent-green)';
}
} else {
if (el.btnModiToggle) el.btnModiToggle.classList.add('hidden');
if (el.tabBtnRadio) el.tabBtnRadio.classList.add('hidden');
if (el.chkEnableLua) el.chkEnableLua.checked = false;
if (el.luaFileName) el.luaFileName.textContent = 'Keine Datei ausgewählt';
if (el.scriptStatusText) {
el.scriptStatusText.textContent = '○ Inaktiv (Kein Skript geladen)';
el.scriptStatusText.style.color = 'var(--text-muted)';
}
const activeTab = document.querySelector('.modal-tabs .tab-btn.active');
if (activeTab && activeTab.dataset.tabTarget === 'secRadio') {
const displayTab = document.querySelector('.modal-tabs .tab-btn[data-tab-target="secDisplay"]');
if (displayTab) displayTab.click();
}
}
}
async function persistConfigToFileAndStorage() {
const tauri = getTauri();
if (tauri && tauri.core) {
try {
await tauri.core.invoke('save_app_config', {
configContent: JSON.stringify(currentSettings, null, 2)
});
} catch (err) {
console.warn('Dateispeicherung fehlgeschlagen:', err);
}
}
}
async function saveDirectSettings(showNotification = true) {
const nick = el.directNick ? el.directNick.value.trim() : (currentSettings.directNick || '');
const pass = el.directPass ? el.directPass.value.trim() : '';
const autoConn = el.directAutoConnect ? el.directAutoConnect.checked : false;
currentSettings.activeConnection = 'direct';
currentSettings.directHost = RADIO_IRC.host;
currentSettings.directPort = RADIO_IRC.port;
currentSettings.directNick = nick;
currentSettings.directChannel = RADIO_IRC.channel;
currentSettings.directPass = pass;
currentSettings.directSsl = RADIO_IRC.ssl;
currentSettings.directAutoConnect = autoConn;
if (nick) currentSettings.normalNick = nick;
state.currentNick = nick;
if (el.displayNick) el.displayNick.textContent = nick || 'Nicht verbunden';
if (el.userAvatar) el.userAvatar.textContent = nick ? nick[0].toUpperCase() : '?';
await persistConfigToFileAndStorage();
if (showNotification && el.directSaveFeedback) {
el.directSaveFeedback.textContent = '✅ Direkt-Einstellungen gespeichert!';
el.directSaveFeedback.classList.remove('hidden');
setTimeout(() => {
if (el.directSaveFeedback) el.directSaveFeedback.classList.add('hidden');
}, 3500);
}
}
async function saveZncSettings(showNotification = true) {
const host = el.zncHost ? el.zncHost.value.trim() : '';
const port = parseInt(el.zncPort ? el.zncPort.value : '8250', 10) || 8250;
const user = el.zncUser ? el.zncUser.value.trim() : '';
const pass = el.zncPass ? el.zncPass.value.trim() : '';
const nick = (el.zncNick && el.zncNick.value.trim()) || (user ? user.split('/')[0] : 'Hoerer');
const channel = (el.zncChannel && el.zncChannel.value.trim()) || '#erde';
const ssl = el.zncSsl ? el.zncSsl.checked : false;
const autoConn = el.zncAutoConnect ? el.zncAutoConnect.checked : false;
currentSettings.activeConnection = 'znc';
currentSettings.zncHost = host;
currentSettings.zncPort = port;
currentSettings.zncUser = user;
currentSettings.zncPass = pass;
currentSettings.zncNick = nick;
currentSettings.zncChannel = channel;
currentSettings.zncSsl = ssl;
currentSettings.zncAutoConnect = autoConn;
currentSettings.normalNick = nick;
state.currentNick = nick;
if (el.displayNick) el.displayNick.textContent = nick;
if (el.userAvatar) el.userAvatar.textContent = nick ? nick[0].toUpperCase() : '?';
await persistConfigToFileAndStorage();
if (showNotification && el.zncSaveFeedback) {
el.zncSaveFeedback.textContent = '✅ ZNC-Einstellungen gespeichert!';
el.zncSaveFeedback.classList.remove('hidden');
setTimeout(() => {
if (el.zncSaveFeedback) el.zncSaveFeedback.classList.add('hidden');
}, 3500);
}
}
function clearDirectForm() {
if (el.directNick) el.directNick.value = '';
if (el.directPass) el.directPass.value = '';
if (el.directAutoConnect) el.directAutoConnect.checked = false;
}
function clearZncForm() {
if (el.zncHost) el.zncHost.value = '';
if (el.zncPort) el.zncPort.value = '8250';
if (el.zncUser) el.zncUser.value = '';
if (el.zncPass) el.zncPass.value = '';
if (el.zncNick) el.zncNick.value = '';
if (el.zncChannel) el.zncChannel.value = '#erde';
if (el.zncSsl) el.zncSsl.checked = false;
if (el.zncAutoConnect) el.zncAutoConnect.checked = false;
}
async function saveSettings() {
const isZncActive = currentSettings.activeConnection === 'znc';
const chosenNick = isZncActive
? ((el.zncNick && el.zncNick.value.trim()) || (el.zncUser && el.zncUser.value.trim().split('/')[0]) || '')
: ((el.directNick && el.directNick.value.trim()) || (el.cfgNormalNick && el.cfgNormalNick.value.trim()) || '');
currentSettings = {
...currentSettings,
fontFamily: el.optFontFamily ? el.optFontFamily.value : 'system',
customFont: el.optFontCustom ? el.optFontCustom.value.trim() : '',
fontSize: parseInt(el.optFontSize.value, 10) || 14,
lineHeight: el.optLineHeight.value,
timestampFormat: el.optTimestamp.value,
uiScale: el.optUiScale ? parseInt(el.optUiScale.value, 10) || 100 : 100,
autoUpdateCheck: el.optAutoUpdate ? el.optAutoUpdate.checked : true,
ircColors: el.optIrcColors.checked,
emotesEnabled: el.optEmotes.checked,
emoteSize: parseInt(el.optEmoteSize.value, 10) || 26,
highlightNick: el.optHighlight.checked,
autoScroll: el.optAutoScroll.checked,
showMediaArea: el.optShowMediaArea ? el.optShowMediaArea.checked : true,
// Direct
directHost: RADIO_IRC.host,
directPort: RADIO_IRC.port,
directNick: el.directNick ? el.directNick.value.trim() : currentSettings.directNick,
directChannel: RADIO_IRC.channel,
directPass: el.directPass ? el.directPass.value.trim() : '',
directSsl: RADIO_IRC.ssl,
directAutoConnect: el.directAutoConnect ? el.directAutoConnect.checked : false,
// ZNC
zncHost: el.zncHost ? el.zncHost.value.trim() : currentSettings.zncHost,
zncPort: el.zncPort ? parseInt(el.zncPort.value, 10) || 8250 : 8250,
zncUser: el.zncUser ? el.zncUser.value.trim() : currentSettings.zncUser,
zncPass: el.zncPass ? el.zncPass.value.trim() : currentSettings.zncPass,
zncNick: el.zncNick ? el.zncNick.value.trim() : currentSettings.zncNick,
zncChannel: el.zncChannel ? el.zncChannel.value.trim() || '#erde' : '#erde',
zncSsl: el.zncSsl ? el.zncSsl.checked : false,
zncAutoConnect: el.zncAutoConnect ? el.zncAutoConnect.checked : false,
// Lua & Modi
luaEnabled: el.chkEnableLua ? (el.chkEnableLua.checked && !!currentSettings.luaFileName) : false,
luaFileName: currentSettings.luaFileName || '',
normalNick: chosenNick,
modiNick: el.cfgModiNick ? el.cfgModiNick.value.trim() : '',
internPass: el.cfgInternPass ? el.cfgInternPass.value.trim() : '',
nickservPasswords: currentSettings.nickservPasswords || {}
};
state.currentNick = chosenNick;
if (el.displayNick) el.displayNick.textContent = chosenNick || 'Nicht verbunden';
if (el.userAvatar) el.userAvatar.textContent = chosenNick ? chosenNick[0].toUpperCase() : '?';
await persistConfigToFileAndStorage();
applySettings(currentSettings);
renderCurrentChannelMessages();
el.settingsModal.classList.add('hidden');
}
function updatePreview() {
if (!el.settingsPreview) return;
let fFamily = fontMap.system;
if (el.optFontCustom && el.optFontCustom.value.trim().length > 0) {
fFamily = `"${el.optFontCustom.value.trim()}", sans-serif`;
} else if (el.optFontFamily && el.optFontFamily.value !== 'system') {
fFamily = fontMap[el.optFontFamily.value] || `"${el.optFontFamily.value}", sans-serif`;
}
const fSize = `${el.optFontSize.value}px`;
const density = densityMap[el.optLineHeight.value] || densityMap.normal;
const eSize = `${el.optEmoteSize.value}px`;
el.settingsPreview.style.fontFamily = fFamily;
el.settingsPreview.style.fontSize = fSize;
el.settingsPreview.style.lineHeight = density.lineHeight;
el.settingsPreview.style.gap = density.gap;
const emotes = el.settingsPreview.querySelectorAll('.chat-emote');
emotes.forEach(img => {
img.style.height = eSize;
});
}
// --- Modi Nickwechsel (Radio Sendebetrieb) ---
function toggleModiState() {
const tauri = getTauri();
if (!state.connected && tauri) {
addMessage('*server*', {
type: 'system',
text: '❌ Bitte zuerst mit dem Server verbinden.'
});
return;
}
if (!state.isModiActive) {
// Switch to Modi
const normalName = state.currentNick || currentSettings.normalNick || 'Hoerer';
currentSettings.normalNick = normalName;
if (el.cfgNormalNick) el.cfgNormalNick.value = normalName;
let modiName = currentSettings.modiNick;
if (!modiName) {
modiName = prompt('Bitte deinen Moderatoren-Nick eingeben (z. B. DJ_Name):', `DJ_${normalName}`);
if (!modiName) return;
currentSettings.modiNick = sanitizeIrcNick(modiName);
if (el.cfgModiNick) el.cfgModiNick.value = currentSettings.modiNick;
}
modiName = sanitizeIrcNick(modiName);
const internPass = currentSettings.internPass || '';
state.isModiActive = true;
el.btnModiToggle.classList.add('modi-active');
el.modiToggleLabel.textContent = `🎧 Modi aktiv (${modiName})`;
addMessage('*server*', {
type: 'system',
text: `📻 [Sendebetrieb AN] Wechsle zu Moderatoren-Nick '${modiName}' und betrete #intern...`
});
// NickServ-Identify beim Nickwechsel uebernimmt das Backend.
if (tauri && tauri.core) {
tauri.core.invoke('send_irc_raw', { line: `NICK ${modiName}` });
tauri.core.invoke('send_irc_raw', { line: internPass ? `JOIN #intern ${internPass}` : 'JOIN #intern' });
}
addChannelToState('#intern');
switchChannel('#intern');
} else {
// Switch back to Normal
const normalName = currentSettings.normalNick || state.currentNick.replace(/^DJ_/i, '') || 'Hoerer';
state.isModiActive = false;
el.btnModiToggle.classList.remove('modi-active');
el.modiToggleLabel.textContent = '📻 Zum Modi wechseln';
addMessage('*server*', {
type: 'system',
text: `📻 [Sendebetrieb AUS] Verlasse #intern und wechsle zurück zu '${normalName}'...`
});
if (tauri && tauri.core) {
// Erst !deop mit dem Modi-Nick in allen offenen Kanaelen (ausser #intern) senden, dann nach
// kurzer Pause PART und Nickwechsel, damit der Bot den Befehl noch dem Modi-Nick zuordnet.
const deopChans = Object.keys(state.channels)
.filter(name => name.startsWith('#') && name.toLowerCase() !== '#intern');
for (const chan of deopChans) {
tauri.core.invoke('send_irc_raw', { line: `PRIVMSG ${chan} :!deop` });
}
el.btnModiToggle.disabled = true;
setTimeout(() => {
tauri.core.invoke('send_irc_raw', { line: 'PART #intern :Sendung beendet' });
tauri.core.invoke('send_irc_raw', { line: `NICK ${normalName}` });
el.btnModiToggle.disabled = false;
}, 1000);
}
if (state.channels['#intern']) {
delete state.channels['#intern'];
}
switchChannel('#erde');
updateChannelListUI();
}
}
// --- Initialize Emotes ---
async function loadEmotes() {
try {
const res = await fetch('emotes.json');
const data = await res.json();
state.emotes.baseUrl = data.base_url || 'https://chat.euer.tv/static/emocache/';
state.emotes.map = data.emotes || {};
state.emotes.sortedKeys = Object.keys(state.emotes.map).sort((a, b) => b.length - a.length);
emoteRegex = null;
renderEmoteGrid('all');
} catch (err) {
console.warn('Emotes konnten nicht geladen werden:', err);
}
}
function renderEmoteGrid(filterType, search = '') {
el.emoteGrid.innerHTML = '';
const q = search.toLowerCase();
for (const code of state.emotes.sortedKeys) {
const filename = state.emotes.map[code];
const isGif = filename.toLowerCase().endsWith('.gif');
if (filterType === 'gifs' && !isGif) continue;
if (filterType === 'smilies' && isGif) continue;
if (q && !code.toLowerCase().includes(q) && !filename.toLowerCase().includes(q)) continue;
const item = document.createElement('div');
item.className = 'emote-item';
item.title = code;
const img = document.createElement('img');
img.src = state.emotes.baseUrl + filename;
img.alt = code;
img.loading = 'lazy';
item.appendChild(img);
item.addEventListener('click', () => {
insertEmoteToInput(code);
});
el.emoteGrid.appendChild(item);
}
}
function insertEmoteToInput(code) {
const val = el.chatInput.value;
const space = val.length > 0 && !val.endsWith(' ') ? ' ' : '';
el.chatInput.value = val + space + code + ' ';
el.chatInput.focus();
// Do NOT close emotePicker here so user can click multiple emotes/GIFs consecutively!
}
// --- Comprehensive mIRC Formatting & Emote Parser ---
// Escapes auch Anfuehrungszeichen, damit Text sicher in HTML-Attribute passt.
function escapeHtml(text) {
return String(text)
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function formatIrcText(rawText) {
if (!rawText) return '';
let out = '';
const enableColors = currentSettings.ircColors;
if (!enableColors) {
// Strip control codes completely if disabled
let stripped = rawText.replace(/\x03(?:\d{1,2}(?:,\d{1,2})?)?/g, '')
.replace(/[\x02\x1D\x1F\x1E\x0F\x16]/g, '');
out = escapeHtml(stripped);
} else {
// Parse full mIRC formatting
let inColor = false;
let inBold = false;
let inItalic = false;
let inUnderline = false;
let inStrike = false;
let i = 0;
function closeColor() {
if (inColor) { out += '</span>'; inColor = false; }
}
function closeAll() {
closeColor();
if (inBold) { out += '</b>'; inBold = false; }
if (inItalic) { out += '</i>'; inItalic = false; }
if (inUnderline) { out += '</u>'; inUnderline = false; }
if (inStrike) { out += '</s>'; inStrike = false; }
}
while (i < rawText.length) {
const ch = rawText[i];
const code = rawText.charCodeAt(i);
if (code === 3) { // Color \x03
i++;
const match = rawText.slice(i).match(/^(\d{1,2})(?:,(\d{1,2}))?/);
closeColor();
if (match) {
const fg = parseInt(match[1], 10) % 16;
let bgStyle = '';
if (match[2] !== undefined) {
const bg = parseInt(match[2], 10) % 16;
bgStyle = `;background-color:${mircColors[bg]};padding:0 2px;border-radius:2px`;
}
out += `<span style="color:${mircColors[fg]}${bgStyle}">`;
inColor = true;
i += match[0].length;
}
continue;
} else if (code === 2) { // Bold \x02
inBold = !inBold;
out += inBold ? '<b>' : '</b>';
i++;
continue;
} else if (code === 29) { // Italic \x1D
inItalic = !inItalic;
out += inItalic ? '<i>' : '</i>';
i++;
continue;
} else if (code === 31) { // Underline \x1F
inUnderline = !inUnderline;
out += inUnderline ? '<u>' : '</u>';
i++;
continue;
} else if (code === 30) { // Strikethrough \x1E
inStrike = !inStrike;
out += inStrike ? '<s>' : '</s>';
i++;
continue;
} else if (code === 15) { // Reset \x0F
closeAll();
i++;
continue;
} else if (code === 22) { // Reverse video \x16
i++;
continue;
} else {
// Escape HTML
if (ch === '<') out += '&lt;';
else if (ch === '>') out += '&gt;';
else if (ch === '&') out += '&amp;';
else if (ch === '"') out += '&quot;';
else if (ch === "'") out += '&#39;';
else out += ch;
i++;
}
}
closeAll();
}
// Linkify URLs (Text ist bereits escaped, Anfuehrungszeichen kommen als &quot; an)
out = out.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer" style="color:var(--accent-cyan);text-decoration:underline;">$1</a>');
// Replace emotes if enabled (ein einziger vorkompilierter Regex statt eines Regex pro Emote)
if (currentSettings.emotesEnabled) {
const emoteRegex = getEmoteRegex();
if (emoteRegex) {
out = out.replace(emoteRegex, (match, lead, code) => {
const filename = emoteByEscapedCode.get(code);
if (!filename) return match;
return `${lead}<img class="chat-emote" src="${escapeHtml(state.emotes.baseUrl + filename)}" alt="${code}" title="${code}">`;
});
}
}
return out;
}
let emoteRegex = null;
const emoteByEscapedCode = new Map();
function getEmoteRegex() {
if (emoteRegex !== null) return emoteRegex || null;
emoteByEscapedCode.clear();
if (state.emotes.sortedKeys.length === 0) return null;
const alternatives = [];
for (const code of state.emotes.sortedKeys) {
const escaped = escapeHtml(code);
emoteByEscapedCode.set(escaped, state.emotes.map[code]);
alternatives.push(escapeRegex(escaped));
}
emoteRegex = new RegExp(`(^|\\s)(${alternatives.join('|')})(?=\\s|$)`, 'g');
return emoteRegex;
}
function escapeRegex(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function getNickColor(nick) {
const colors = [
'#ff6b6b', '#48dbfb', '#1dd1a1', '#feca57', '#ff9ff3',
'#54a0ff', '#5f27cd', '#00d2d3', '#ff9f43', '#10ac84'
];
let hash = 0;
for (let i = 0; i < nick.length; i++) {
hash = nick.charCodeAt(i) + ((hash << 5) - hash);
}
return colors[Math.abs(hash) % colors.length];
}
function formatTimestamp(timeStr) {
if (currentSettings.timestampFormat === 'none') return '';
const now = new Date();
if (currentSettings.timestampFormat === 'hms') {
return now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
return timeStr || now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
function addMessage(targetChan, msg) {
if (!targetChan) targetChan = '*server*';
// Prevent creating tabs named after own nick or CTCP noise
if (state.currentNick && targetChan.toLowerCase() === state.currentNick.toLowerCase()) {
targetChan = '*server*';
}
if (targetChan.includes('\x01') || targetChan.toUpperCase().includes('VERSION')) {
targetChan = '*server*';
}
if (!state.channels[targetChan]) {
addChannelToState(targetChan);
}
const chanData = state.channels[targetChan];
chanData.messages.push(msg);
const overflow = chanData.messages.length - MAX_MESSAGES_PER_CHANNEL;
if (overflow > 0) {
chanData.messages.splice(0, overflow);
}
if (state.currentChannel === targetChan) {
renderSingleMessage(msg);
while (el.messagesContainer.childElementCount > MAX_MESSAGES_PER_CHANNEL) {
el.messagesContainer.firstElementChild.remove();
}
if (currentSettings.autoScroll) {
scrollToBottom();
}
} else {
chanData.unread++;
updateChannelListUI();
}
}
function renderSingleMessage(msg) {
const item = document.createElement('div');
item.className = 'chat-msg';
// Nick highlight check
if (currentSettings.highlightNick && state.currentNick && msg.text) {
if (msg.text.toLowerCase().includes(state.currentNick.toLowerCase())) {
item.classList.add('highlight-msg');
}
}
if (msg.type === 'system') {
item.classList.add('system-msg');
} else if (msg.type === 'notice') {
item.classList.add('notice-msg');
} else if (msg.type === 'highlight') {
item.classList.add('highlight-msg');
} else if (msg.type === 'action') {
item.classList.add('action-msg');
}
// Timestamp
if (currentSettings.timestampFormat !== 'none') {
const timeSpan = document.createElement('span');
timeSpan.className = 'msg-time';
timeSpan.textContent = formatTimestamp(msg.time);
item.appendChild(timeSpan);
}
// Render CTCP Action cleanly (e.g. "* Kellnerin Jetzt läuft: ...")
if (msg.type === 'action') {
const textSpan = document.createElement('span');
textSpan.className = 'msg-text';
const nickColor = getNickColor(msg.nick || '');
textSpan.innerHTML = `<span class="msg-action-star">*</span> <span class="msg-nick" style="color:${nickColor};margin-right:4px;">${escapeHtml(msg.nick || '')}</span>${formatIrcText(msg.text)}`;
item.appendChild(textSpan);
el.messagesContainer.appendChild(item);
return;
}
// Normal message: Nick column
if (msg.nick) {
const nickSpan = document.createElement('span');
nickSpan.className = 'msg-nick';
nickSpan.textContent = msg.nick;
nickSpan.style.color = getNickColor(msg.nick);
item.appendChild(nickSpan);
}
// Message Text
const textSpan = document.createElement('span');
textSpan.className = 'msg-text';
textSpan.innerHTML = formatIrcText(msg.text);
item.appendChild(textSpan);
el.messagesContainer.appendChild(item);
}
function renderCurrentChannelMessages() {
el.messagesContainer.innerHTML = '';
const chan = state.channels[state.currentChannel];
if (!chan) return;
for (const msg of chan.messages) {
renderSingleMessage(msg);
}
if (currentSettings.autoScroll) {
scrollToBottom();
}
}
function scrollToBottom() {
el.messagesContainer.scrollTop = el.messagesContainer.scrollHeight;
}
// --- Channel Management (Horizontal Bottom Bar) ---
function addChannelToState(chanName) {
if (state.channels[chanName]) return;
state.channels[chanName] = {
topic: `Kanal ${chanName}`,
messages: [],
users: [],
unread: 0
};
updateChannelListUI();
}
function closeChannel(name) {
if (name === '*server*') return;
const tauri = getTauri();
if (name.startsWith('#') && state.connected && tauri && tauri.core) {
tauri.core.invoke('send_irc_raw', { line: `PART ${name} :Fenster geschlossen` });
}
delete state.channels[name];
if (state.currentChannel === name) {
const remaining = Object.keys(state.channels);
const nextChan = state.channels['#erde'] ? '#erde' : (remaining[0] || '*server*');
switchChannel(nextChan);
} else {
updateChannelListUI();
}
}
function switchChannel(chanName) {
if (!state.channels[chanName]) {
addChannelToState(chanName);
}
state.currentChannel = chanName;
state.channels[chanName].unread = 0;
if (el.headerChanName) el.headerChanName.textContent = chanName;
if (el.headerTopic) el.headerTopic.textContent = state.channels[chanName].topic || '';
if (el.chatInput) el.chatInput.placeholder = `Nachricht an ${chanName}... (Enter zum Senden)`;
updateChannelListUI();
renderCurrentChannelMessages();
renderUserList();
}
// Reihenfolge der Tabs: manuell gespeicherte zuerst, neue Kanaele hinten in Beitrittsreihenfolge.
function orderedChannelNames() {
const order = currentSettings.channelOrder || [];
const rank = (name) => {
const i = order.indexOf(name);
return i === -1 ? order.length : i;
};
return Object.keys(state.channels)
.map((name, index) => ({ name, index }))
.sort((a, b) => rank(a.name) - rank(b.name) || a.index - b.index)
.map(entry => entry.name);
}
let tabDrag = null;
let suppressTabClick = false;
function onTabMouseDown(e, item) {
if (e.button !== 0 || e.target.closest('.btn-tab-close')) return;
tabDrag = { item, startX: e.clientX, active: false };
window.addEventListener('mousemove', onTabDragMove);
window.addEventListener('mouseup', onTabDragEnd);
}
function onTabDragMove(e) {
if (!tabDrag) return;
if (!tabDrag.item.isConnected) {
onTabDragEnd();
return;
}
if (!tabDrag.active) {
if (Math.abs(e.clientX - tabDrag.startX) < 6) return;
tabDrag.active = true;
tabDrag.item.classList.add('dragging');
document.body.classList.add('tab-dragging');
}
// Bei vielen Tabs am Rand der Leiste automatisch mitscrollen.
const bar = el.channelList.parentElement.getBoundingClientRect();
if (e.clientX < bar.left + 30) el.channelList.parentElement.scrollLeft -= 14;
else if (e.clientX > bar.right - 30) el.channelList.parentElement.scrollLeft += 14;
const others = [...el.channelList.children].filter(c => c !== tabDrag.item);
const before = others.find((c) => {
const r = c.getBoundingClientRect();
return e.clientX < r.left + r.width / 2;
});
if (before) {
if (tabDrag.item.nextElementSibling !== before) {
el.channelList.insertBefore(tabDrag.item, before);
}
} else if (tabDrag.item.nextElementSibling) {
el.channelList.appendChild(tabDrag.item);
}
}
function onTabDragEnd() {
window.removeEventListener('mousemove', onTabDragMove);
window.removeEventListener('mouseup', onTabDragEnd);
const drag = tabDrag;
tabDrag = null;
if (!drag || !drag.active) return;
drag.item.classList.remove('dragging');
document.body.classList.remove('tab-dragging');
// Das auf das Loslassen folgende click-Event soll keinen Kanalwechsel ausloesen.
suppressTabClick = true;
setTimeout(() => { suppressTabClick = false; }, 0);
currentSettings.channelOrder = [...el.channelList.children].map(c => c.dataset.target);
persistConfigToFileAndStorage();
updateChannelListUI();
}
function updateChannelListUI() {
// Waehrend des Ziehens nicht neu aufbauen; passiert nach dem Loslassen.
if (tabDrag && tabDrag.active) return;
el.channelList.innerHTML = '';
for (const name of orderedChannelNames()) {
const item = document.createElement('div');
const isActive = state.currentChannel === name;
const isPm = !name.startsWith('#') && name !== '*server*';
const unread = state.channels[name].unread || 0;
item.className = 'channel-tab' +
(isActive ? ' active' : '') +
(isPm ? ' is-pm' : '') +
(unread > 0 && !isActive ? ' has-unread' : '');
item.dataset.target = name;
const prefix = document.createElement('span');
prefix.className = 'chan-prefix';
prefix.textContent = name.startsWith('#') ? '#' : (name === '*server*' ? 'ℹ' : '💬');
item.appendChild(prefix);
const title = document.createElement('span');
title.className = 'chan-name';
title.textContent = name.startsWith('#') ? name.slice(1) : (name === '*server*' ? 'Server' : name);
item.appendChild(title);
if (unread > 0 && !isActive) {
const badge = document.createElement('span');
badge.className = 'chan-badge unread-badge';
badge.textContent = unread;
item.appendChild(badge);
}
// Close button (allow closing any tab except *server*)
if (name !== '*server*') {
const btnClose = document.createElement('button');
btnClose.className = 'btn-tab-close';
btnClose.innerHTML = '&times;';
btnClose.title = `${name} schließen`;
btnClose.addEventListener('click', (e) => {
e.stopPropagation();
closeChannel(name);
});
item.appendChild(btnClose);
}
item.addEventListener('mousedown', (e) => onTabMouseDown(e, item));
item.addEventListener('click', () => {
if (!suppressTabClick) switchChannel(name);
});
el.channelList.appendChild(item);
}
}
// --- User List Management ---
function setUserList(chanName, users) {
if (!state.channels[chanName]) {
addChannelToState(chanName);
}
state.channels[chanName].users = users;
if (state.currentChannel === chanName) {
renderUserList();
}
}
function renderUserList() {
el.userList.innerHTML = '';
const chan = state.channels[state.currentChannel];
if (!chan) {
el.userCount.textContent = '0';
return;
}
if (!state.currentChannel.startsWith('#') && state.currentChannel !== '*server*' && (!chan.users || chan.users.length === 0)) {
chan.users = [{ nick: state.currentChannel, prefix: '' }];
}
if (!chan.users) {
el.userCount.textContent = '0';
return;
}
el.userCount.textContent = chan.users.length;
// Sort: Ops (@, ~, &) first, Voice (+) second, regular last
const sorted = [...chan.users].sort((a, b) => {
const isOpA = ['@', '~', '&'].includes(a.prefix);
const isOpB = ['@', '~', '&'].includes(b.prefix);
const rankA = isOpA ? 3 : (a.prefix === '+' ? 2 : 1);
const rankB = isOpB ? 3 : (b.prefix === '+' ? 2 : 1);
if (rankA !== rankB) return rankB - rankA;
return a.nick.localeCompare(b.nick);
});
for (const u of sorted) {
const item = document.createElement('div');
item.className = 'user-item';
item.title = `Klick: Optionen für ${u.nick} | Doppelklick: PN öffnen`;
const prefix = document.createElement('span');
const isOp = ['@', '~', '&'].includes(u.prefix);
prefix.className = 'user-prefix ' + (isOp ? 'op' : (u.prefix === '+' ? 'voice' : ''));
prefix.textContent = u.prefix || '';
item.appendChild(prefix);
const name = document.createElement('span');
name.className = 'user-name';
name.textContent = u.nick;
item.appendChild(name);
// Single click: Show options menu
item.addEventListener('click', (e) => {
openUserContextMenu(e, u);
});
// Right click also opens options menu
item.addEventListener('contextmenu', (e) => {
openUserContextMenu(e, u);
});
// Double click: Open private chat
item.addEventListener('dblclick', () => {
closeUserContextMenu();
addChannelToState(u.nick);
switchChannel(u.nick);
});
el.userList.appendChild(item);
}
}
let activeContextUser = null;
function openUserContextMenu(e, user) {
e.preventDefault();
e.stopPropagation();
activeContextUser = user;
if (!el.userContextMenu) return;
if (el.ctxUserPrefix) {
el.ctxUserPrefix.textContent = user.prefix || '';
const isOp = ['@', '~', '&'].includes(user.prefix);
el.ctxUserPrefix.style.color = isOp ? 'var(--accent-red)' : (user.prefix === '+' ? 'var(--accent-green)' : 'var(--accent-cyan)');
}
if (el.ctxUserNick) {
el.ctxUserNick.textContent = user.nick;
}
el.userContextMenu.classList.remove('hidden');
const menuWidth = 205;
const menuHeight = 330;
let x = e.clientX;
let y = e.clientY;
if (x + menuWidth > window.innerWidth - 10) {
x = Math.max(10, e.clientX - menuWidth);
}
if (y + menuHeight > window.innerHeight - 10) {
y = Math.max(10, window.innerHeight - menuHeight - 10);
}
el.userContextMenu.style.left = `${x}px`;
el.userContextMenu.style.top = `${y}px`;
}
function closeUserContextMenu() {
if (el.userContextMenu) {
el.userContextMenu.classList.add('hidden');
}
activeContextUser = null;
}
function setupUserContextMenu() {
if (!el.userContextMenu) return;
el.userContextMenu.addEventListener('click', (e) => {
const btn = e.target.closest('.ctx-item');
if (!btn) return;
const action = btn.dataset.action;
if (!action || !activeContextUser) return;
const nick = activeContextUser.nick;
const chan = state.currentChannel;
const tauri = getTauri();
closeUserContextMenu();
switch (action) {
case 'query':
addChannelToState(nick);
switchChannel(nick);
el.chatInput.focus();
break;
case 'whois':
if (state.connected && tauri && tauri.core) {
tauri.core.invoke('send_irc_raw', { line: `WHOIS ${nick} ${nick}` });
addMessage(chan, {
type: 'system',
text: `ℹ [WHOIS] Frage Benutzerinfo für "${nick}" ab...`
});
}
break;
case 'grussbox':
openGrussboxModal();
if (el.gbTextInput) {
el.gbTextInput.value = `Liebe Grüße an ${nick}! `;
el.gbTextInput.focus();
}
break;
case 'copy':
navigator.clipboard.writeText(nick).then(() => {
addMessage(chan, {
type: 'system',
text: `📋 [Clipboard] Nickname "${nick}" kopiert.`
});
});
break;
case 'op':
if (state.connected && tauri && tauri.core) {
tauri.core.invoke('send_irc_raw', { line: `MODE ${chan} +o ${nick}` });
}
break;
case 'deop':
if (state.connected && tauri && tauri.core) {
tauri.core.invoke('send_irc_raw', { line: `MODE ${chan} -o ${nick}` });
}
break;
case 'voice':
if (state.connected && tauri && tauri.core) {
tauri.core.invoke('send_irc_raw', { line: `MODE ${chan} +v ${nick}` });
}
break;
case 'devoice':
if (state.connected && tauri && tauri.core) {
tauri.core.invoke('send_irc_raw', { line: `MODE ${chan} -v ${nick}` });
}
break;
case 'kick':
const reason = prompt(`Grund für den Kick von ${nick}:`, 'Vom Moderator gekickt');
if (reason !== null && state.connected && tauri && tauri.core) {
tauri.core.invoke('send_irc_raw', { line: `KICK ${chan} ${nick} :${reason}` });
}
break;
case 'ban':
if (confirm(`Möchtest du ${nick} wirklich bannen und kicken?`)) {
if (state.connected && tauri && tauri.core) {
tauri.core.invoke('send_irc_raw', { line: `MODE ${chan} +b ${nick}!*@*` });
tauri.core.invoke('send_irc_raw', { line: `KICK ${chan} ${nick} :Gebannt` });
}
}
break;
}
});
document.addEventListener('click', (e) => {
if (!el.userContextMenu.classList.contains('hidden') && !el.userContextMenu.contains(e.target)) {
closeUserContextMenu();
}
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeUserContextMenu();
}
});
}
// --- Sending Messages ---
async function sendMessage() {
const text = el.chatInput.value.trim();
if (!text) return;
el.chatInput.value = '';
if (el.emotePicker && !el.emotePicker.classList.contains('hidden')) {
el.emotePicker.classList.add('hidden');
}
if (text.startsWith('/')) {
handleSlashCommand(text);
return;
}
const tauri = getTauri();
if (!state.connected && tauri) {
addMessage(state.currentChannel, {
type: 'system',
text: '❌ Nicht verbunden. Bitte in den Optionen -> Server verbinden.'
});
return;
}
if (state.currentChannel === '*server*') {
addMessage('*server*', {
type: 'system',
text: `>>> ${text}`
});
if (tauri && tauri.core) {
try {
await tauri.core.invoke('send_irc_raw', { line: text });
} catch (err) {
addMessage('*server*', {
type: 'system',
text: `Fehler beim Senden: ${err}`
});
}
}
return;
}
addMessage(state.currentChannel, {
nick: state.currentNick || 'Ich',
text: text,
time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
});
if (tauri && tauri.core) {
try {
await tauri.core.invoke('send_irc_message', {
target: state.currentChannel,
text: text
});
} catch (err) {
addMessage(state.currentChannel, {
type: 'system',
text: `Fehler beim Senden: ${err}`
});
}
}
}
function handleSlashCommand(raw) {
const parts = raw.split(' ');
const cmd = parts[0].toLowerCase();
const args = parts.slice(1);
const tauri = getTauri();
if (cmd === '/join' || cmd === '/j') {
if (args[0]) {
const target = args[0].startsWith('#') ? args[0] : '#' + args[0];
addChannelToState(target);
switchChannel(target);
if (tauri && tauri.core) {
tauri.core.invoke('send_irc_raw', { line: `JOIN ${target}` });
}
}
} else if (cmd === '/part' || cmd === '/p') {
const target = args[0] || state.currentChannel;
if (tauri && tauri.core) {
tauri.core.invoke('send_irc_raw', { line: `PART ${target}` });
}
} else if (cmd === '/nick') {
if (args[0] && tauri && tauri.core) {
tauri.core.invoke('send_irc_raw', { line: `NICK ${args[0]}` });
}
} else if (cmd === '/me') {
if (args.length > 0) {
const actionText = args.join(' ');
addMessage(state.currentChannel, {
nick: state.currentNick || 'Ich',
text: actionText,
type: 'action'
});
if (tauri && tauri.core) {
tauri.core.invoke('send_irc_raw', { line: `PRIVMSG ${state.currentChannel} :\x01ACTION ${actionText}\x01` });
}
}
} else if (cmd === '/clones') {
addMessage(state.currentChannel, {
type: 'system',
text: '🔍 Scanne Channel auf Klone (*!*@host)...'
});
if (tauri && tauri.core) {
tauri.core.invoke('scan_clones', { channel: state.currentChannel });
}
} else if (cmd === '/clear') {
if (state.channels[state.currentChannel]) {
state.channels[state.currentChannel].messages = [];
renderCurrentChannelMessages();
}
} else {
if (tauri && tauri.core) {
tauri.core.invoke('send_irc_raw', { line: raw.slice(1) });
}
}
}
// --- Tauri Event Listeners & Bridge ---
function setupTauriBridge() {
const tauri = getTauri();
if (!tauri || !tauri.event) {
console.warn('Tauri event module nicht verfügbar');
return;
}
const { listen } = tauri.event;
listen('irc-status', (event) => {
const { connected, connecting, nick, host } = event.payload;
state.connected = connected;
state.connecting = connecting;
if (nick) {
state.currentNick = nick;
// Waehrend des Sendebetriebs ist der Nick der Modi-Nick, nicht der normale Nick.
if (!state.isModiActive) {
currentSettings.normalNick = nick;
}
}
el.statusDot.className = 'status-dot ' + (connected ? 'connected' : (connecting ? 'connecting' : 'disconnected'));
if (el.statusDot) {
el.statusDot.title = connected ? `Verbunden (${host || 'irc.euer.tv'})` : (connecting ? 'Verbinde...' : 'Getrennt');
}
if (el.statusText) {
el.statusText.textContent = connected ? `Verbunden (${host})` : (connecting ? 'Verbinde...' : 'Getrennt');
}
el.displayNick.textContent = state.currentNick || 'Nicht verbunden';
if (el.displayHost) {
el.displayHost.textContent = host || 'irc.euer.tv';
}
el.userAvatar.textContent = state.currentNick ? state.currentNick[0].toUpperCase() : '?';
if (el.btnHeaderConnect) {
if (connected) {
el.btnHeaderConnect.textContent = 'Trennen';
el.btnHeaderConnect.className = 'btn-connect-header connected';
} else if (connecting) {
el.btnHeaderConnect.textContent = 'Verbinde...';
el.btnHeaderConnect.className = 'btn-connect-header';
} else {
el.btnHeaderConnect.textContent = 'Verbinden';
el.btnHeaderConnect.className = 'btn-connect-header';
if (el.statusPing) el.statusPing.classList.add('hidden');
}
}
});
listen('update-progress', (event) => {
if (el.updProgressBar) el.updProgressBar.style.width = `${event.payload}%`;
});
listen('irc-ping', (event) => {
const { ping_ms } = event.payload;
if (!state.connected || !el.statusPing) return;
el.statusPing.textContent = `${ping_ms} ms`;
el.statusPing.classList.remove('hidden');
el.statusPing.className = 'status-ping' + (ping_ms > 150 ? ' ping-high' : (ping_ms > 60 ? ' ping-medium' : ''));
});
listen('irc-message', (event) => {
const { channel, nick, text, time, type } = event.payload;
const target = channel || state.currentChannel;
addMessage(target, {
nick,
text,
time,
type: type || 'normal'
});
});
listen('irc-names', (event) => {
const { channel, users } = event.payload;
setUserList(channel, users);
});
listen('irc-topic', (event) => {
const { channel, topic } = event.payload;
if (state.channels[channel]) {
state.channels[channel].topic = topic;
if (state.currentChannel === channel && el.headerTopic) {
el.headerTopic.textContent = topic;
}
}
});
listen('irc-channel-joined', (event) => {
const { channel } = event.payload;
addChannelToState(channel);
switchChannel(channel);
});
listen('irc-channel-parted', (event) => {
const { channel } = event.payload;
if (state.channels[channel] && channel !== '#erde') {
delete state.channels[channel];
if (state.currentChannel === channel) {
switchChannel('#erde');
} else {
updateChannelListUI();
}
}
});
}
function sanitizeIrcNick(raw) {
let s = (raw || '').trim()
.replace(/ä/g, 'ae').replace(/Ä/g, 'Ae')
.replace(/ö/g, 'oe').replace(/Ö/g, 'Oe')
.replace(/ü/g, 'ue').replace(/Ü/g, 'Ue')
.replace(/ß/g, 'ss');
s = s.replace(/[^a-zA-Z0-9_\-\[\]\\^{}|]/g, '_');
return s || `Hoerer_${Math.floor(Math.random() * 900 + 100)}`;
}
// --- Connect Actions ---
function showServerSettings(hint) {
el.settingsModal.classList.remove('hidden');
const serverTab = document.querySelector('.modal-tabs .tab-btn[data-tab-target="secServer"]');
if (serverTab) serverTab.click();
setConnectionTab('direct');
if (el.directNick) el.directNick.focus();
if (hint) {
addMessage('*server*', { type: 'system', text: `ℹ ${hint}` });
}
}
async function connectDirect() {
await saveDirectSettings(false);
const { host, port, channel, ssl } = RADIO_IRC;
const nick = sanitizeIrcNick(currentSettings.directNick || '');
const pass = currentSettings.directPass || '';
if (!currentSettings.directNick) {
showServerSettings('Bitte zuerst einen Namen eingeben.');
return;
}
state.currentNick = nick;
currentSettings.directNick = nick;
currentSettings.normalNick = nick;
if (el.directNick) el.directNick.value = nick;
if (el.cfgNormalNick) el.cfgNormalNick.value = nick;
if (el.displayNick) el.displayNick.textContent = nick;
if (el.userAvatar) el.userAvatar.textContent = nick[0].toUpperCase();
el.settingsModal.classList.add('hidden');
el.statusDot.className = 'status-dot connecting';
if (el.statusDot) el.statusDot.title = `Verbinde mit ${host}:${port}...`;
if (el.statusText) el.statusText.textContent = `Verbinde mit ${host}:${port}...`;
if (el.btnHeaderConnect) {
el.btnHeaderConnect.textContent = 'Verbinde...';
el.btnHeaderConnect.className = 'btn-connect-header';
}
addMessage('*server*', {
type: 'system',
text: `Verbindungsaufbau zu ${host}:${port} (SSL: ${ssl ? 'AN' : 'AUS'}), Nick: ${nick}...`
});
const tauri = getTauri();
if (tauri && tauri.core) {
try {
await tauri.core.invoke('connect_irc', {
config: {
host,
port,
nick,
channel,
// Das Passwort wird als NickServ-Identify gesendet (kein Server-PASS).
password: null,
use_ssl: ssl,
accept_invalid_certs: false,
nickserv_passwords: pass
? { ...(currentSettings.nickservPasswords || {}), [nick.toLowerCase()]: pass }
: (currentSettings.nickservPasswords || null)
}
});
} catch (err) {
addMessage('*server*', {
type: 'system',
text: `❌ Verbindungsfehler: ${err}`
});
}
}
}
async function connectZnc() {
const host = (el.zncHost && el.zncHost.value.trim()) || currentSettings.zncHost;
const port = parseInt(el.zncPort ? el.zncPort.value : '8250', 10) || currentSettings.zncPort || 8250;
const user = (el.zncUser && el.zncUser.value.trim()) || currentSettings.zncUser;
const pass = (el.zncPass && el.zncPass.value.trim()) || currentSettings.zncPass;
const nick = (el.zncNick && el.zncNick.value.trim()) || currentSettings.zncNick || (user ? user.split('/')[0] : 'Hoerer');
const channel = (el.zncChannel && el.zncChannel.value.trim()) || currentSettings.zncChannel || '#erde';
const ssl = el.zncSsl ? el.zncSsl.checked : !!currentSettings.zncSsl;
if (!host || !user || !pass) {
alert('Bitte ZNC Host, ZNC-Benutzer und Passwort ausfüllen.');
return;
}
await saveZncSettings(false);
state.currentNick = nick;
currentSettings.normalNick = nick;
if (el.displayNick) el.displayNick.textContent = nick;
if (el.userAvatar) el.userAvatar.textContent = nick[0].toUpperCase();
el.settingsModal.classList.add('hidden');
el.statusDot.className = 'status-dot connecting';
if (el.statusDot) el.statusDot.title = `Verbinde mit ZNC ${host}:${port}...`;
if (el.statusText) el.statusText.textContent = `Verbinde mit ZNC ${host}:${port}...`;
if (el.btnHeaderConnect) {
el.btnHeaderConnect.textContent = 'Verbinde...';
el.btnHeaderConnect.className = 'btn-connect-header';
}
addMessage('*server*', {
type: 'system',
text: `ZNC Bouncer Verbindung zu ${host}:${port} für Benutzer ${user}...`
});
const tauri = getTauri();
if (tauri && tauri.core) {
try {
await tauri.core.invoke('connect_irc', {
config: {
host,
port,
nick,
channel,
password: `${user}:${pass}`,
use_ssl: ssl,
accept_invalid_certs: currentSettings.acceptInvalidCerts === true,
nickserv_passwords: currentSettings.nickservPasswords || null
}
});
} catch (err) {
addMessage('*server*', {
type: 'system',
text: `❌ ZNC Verbindungsfehler: ${err}`
});
}
}
}
async function disconnectIrc() {
const tauri = getTauri();
if (tauri && tauri.core) {
try {
await tauri.core.invoke('disconnect_irc');
} catch (err) {
console.warn('Disconnect error:', err);
}
}
state.connected = false;
state.connecting = false;
el.statusDot.className = 'status-dot disconnected';
if (el.statusDot) el.statusDot.title = 'Getrennt';
if (el.statusText) el.statusText.textContent = 'Getrennt';
if (el.statusPing) el.statusPing.classList.add('hidden');
if (el.btnHeaderConnect) {
el.btnHeaderConnect.textContent = 'Verbinden';
el.btnHeaderConnect.className = 'btn-connect-header';
}
}
// --- Livestream & Radio Player ---
const streamQualityMap = {
'1080p': {
primary: 'https://hls21.euer.tv/hls/1080p.m3u8',
fallback: 'https://hls23.euer.tv/hls/1080p.m3u8'
},
'720p': {
primary: 'https://hls21.euer.tv/hls/720p.m3u8',
fallback: 'https://hls23.euer.tv/hls/720p.m3u8'
},
'360p': {
primary: 'https://hls21.euer.tv/hls/360p.m3u8',
fallback: 'https://hls23.euer.tv/hls/360p.m3u8'
}
};
const DEFAULT_STREAM_VOLUME = 0.10; // 10% Standardlautstärke
let hlsInstance = null;
let metadataTimer = null;
let currentTrackTitle = '';
let currentQuality = localStorage.getItem('euerchat_video_quality') || '1080p';
function initStreamPlayer() {
if (currentSettings.showMediaArea === false) {
if (el.sidebarMediaArea) el.sidebarMediaArea.classList.add('hidden');
} else {
startStreamPlayer();
}
// Start metadata polling
fetchStreamMetadata();
if (metadataTimer) clearInterval(metadataTimer);
metadataTimer = setInterval(fetchStreamMetadata, 10000);
}
function startStreamPlayer() {
const video = el.streamVideo;
if (!video) return;
// IMMER Stumm beim Start mit 10% Standardlautstärke
video.muted = true;
const storedVol = parseFloat(localStorage.getItem('euerchat_volume') || '0.10');
const vol = storedVol > 0 ? storedVol : DEFAULT_STREAM_VOLUME;
video.volume = vol;
if (el.streamVolSlider) el.streamVolSlider.value = vol;
if (el.btnStreamMute) el.btnStreamMute.classList.add('is-muted');
if (el.streamMuteIcon) el.streamMuteIcon.textContent = '🔇';
if (el.videoQualitySelect) {
if (streamQualityMap[currentQuality]) {
el.videoQualitySelect.value = currentQuality;
} else {
currentQuality = '1080p';
el.videoQualitySelect.value = '1080p';
}
}
loadStreamQuality(currentQuality);
}
function stopStreamPlayer() {
if (hlsInstance) {
try {
hlsInstance.stopLoad();
hlsInstance.destroy();
} catch (e) {}
hlsInstance = null;
}
const video = el.streamVideo;
if (video) {
video.pause();
video.removeAttribute('src');
video.load();
}
}
function loadStreamQuality(quality) {
const video = el.streamVideo;
if (!video) return;
if (!streamQualityMap[quality]) quality = '1080p';
currentQuality = quality;
localStorage.setItem('euerchat_video_quality', quality);
const urls = streamQualityMap[quality];
if (window.Hls && window.Hls.isSupported()) {
if (hlsInstance) {
try { hlsInstance.destroy(); } catch (e) {}
}
hlsInstance = new window.Hls({
autoStartLoad: true,
startPosition: -1,
capLevelToPlayerSize: false,
liveSyncDurationCount: 3
});
hlsInstance.loadSource(urls.primary);
hlsInstance.attachMedia(video);
hlsInstance.on(window.Hls.Events.ERROR, function (event, data) {
if (data.fatal) {
switch (data.type) {
case window.Hls.ErrorTypes.NETWORK_ERROR:
console.warn('[HLS] Network error, trying fallback server...');
if (hlsInstance.url === urls.primary) {
hlsInstance.loadSource(urls.fallback);
} else {
hlsInstance.startLoad();
}
break;
case window.Hls.ErrorTypes.MEDIA_ERROR:
console.warn('[HLS] Media error, recovering...');
hlsInstance.recoverMediaError();
break;
default:
console.warn('[HLS] Fatal error, reloading stream...');
try { hlsInstance.destroy(); } catch (e) {}
setTimeout(() => loadStreamQuality(currentQuality), 3000);
break;
}
}
});
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = urls.primary;
}
video.play().catch(err => {
console.log('Video autoplay:', err);
});
}
function toggleStreamMute() {
const video = el.streamVideo;
if (!video) return;
if (video.muted || video.volume === 0) {
// Unmute: Standardwert 10% falls Lautstärke 0 ist
video.muted = false;
const stored = parseFloat(localStorage.getItem('euerchat_volume') || '0.10');
const targetVol = stored > 0 ? stored : DEFAULT_STREAM_VOLUME;
video.volume = targetVol;
if (el.streamVolSlider) el.streamVolSlider.value = targetVol;
if (el.btnStreamMute) el.btnStreamMute.classList.remove('is-muted');
if (el.streamMuteIcon) el.streamMuteIcon.textContent = targetVol > 0.5 ? '🔊' : '🔉';
} else {
// Mute
video.muted = true;
if (el.btnStreamMute) el.btnStreamMute.classList.add('is-muted');
if (el.streamMuteIcon) el.streamMuteIcon.textContent = '🔇';
}
}
function handleVolumeChange(e) {
const video = el.streamVideo;
if (!video) return;
const val = parseFloat(e.target.value);
video.volume = val;
localStorage.setItem('euerchat_volume', val);
if (val === 0) {
video.muted = true;
if (el.btnStreamMute) el.btnStreamMute.classList.add('is-muted');
if (el.streamMuteIcon) el.streamMuteIcon.textContent = '🔇';
} else {
video.muted = false;
if (el.btnStreamMute) el.btnStreamMute.classList.remove('is-muted');
if (el.streamMuteIcon) el.streamMuteIcon.textContent = val > 0.5 ? '🔊' : '🔉';
}
}
function resyncStream() {
if (hlsInstance) {
hlsInstance.stopLoad();
hlsInstance.startLoad(-1);
}
if (el.streamVideo) {
el.streamVideo.load();
el.streamVideo.play().catch(() => {});
}
fetchStreamMetadata();
}
async function fetchStreamMetadata() {
if (document.hidden) return;
try {
const res = await fetch('https://api.euer.tv/v2/getjsondata.php?status');
if (!res.ok) return;
const data = await res.json();
let artist = data.artist || '';
let track = data.track || '';
if (!artist && !track && data.metadata) {
if (data.metadata.includes(' - ')) {
const parts = data.metadata.split(' - ');
artist = parts[0].trim();
track = parts.slice(1).join(' - ').trim();
} else {
artist = 'euer.tv Radio';
track = data.metadata;
}
}
if (!artist) artist = 'euer.tv Radio';
if (!track) track = 'Radio & Video Livestream';
const fullTitle = `${artist} - ${track}`;
if (fullTitle !== currentTrackTitle) {
currentTrackTitle = fullTitle;
if (el.sidebarArtist) el.sidebarArtist.textContent = artist;
if (el.sidebarTrack) el.sidebarTrack.textContent = track;
adjustTrackTextSizes();
}
// Update listener count in sidebar media header
if (data.listeners && data.listeners[0]) {
const cur = data.listeners[0].current || '0';
const vid = data.listeners[0].video || '0';
const aud = data.listeners[0].audio || '0';
const num = parseInt(cur, 10);
const formatted = isNaN(num) ? cur : num.toLocaleString('de-DE');
if (el.statTotalListeners) el.statTotalListeners.textContent = formatted;
if (el.statVideoListeners) el.statVideoListeners.textContent = vid;
if (el.statAudioListeners) el.statAudioListeners.textContent = aud;
}
} catch (err) {
// Ignore network hiccups
}
}
// --- Dynamic Text Auto-Shrink (Automatisches Verkleinern bei langen Titeln) ---
function adjustTrackTextSizes() {
fitText(el.sidebarArtist, 15, 10);
fitText(el.sidebarTrack, 13, 9.5);
}
function fitText(element, maxPx, minPx) {
if (!element) return;
const parent = element.parentElement;
if (!parent) return;
// Reset to maximum size first to measure correctly
element.style.fontSize = `${maxPx}px`;
const availWidth = parent.clientWidth;
if (!availWidth) return;
// Dynamically shrink if content overflows
let curSize = maxPx;
while (element.scrollWidth > availWidth && curSize > minPx) {
curSize -= 0.5;
element.style.fontSize = `${curSize}px`;
}
}
// --- Grußbox Handler ---
function openGrussboxModal() {
if (!el.grussboxModal) return;
el.grussboxModal.classList.remove('hidden');
if (el.gbNameInput) {
el.gbNameInput.value = state.currentNick || '';
}
if (el.gbTextInput) {
el.gbTextInput.value = '';
setTimeout(() => {
if (el.gbTextInput) el.gbTextInput.focus();
}, 80);
}
if (el.gbFeedback) {
el.gbFeedback.className = 'grussbox-feedback hidden';
el.gbFeedback.textContent = '';
}
if (el.btnSubmitGruss) {
el.btnSubmitGruss.disabled = false;
el.btnSubmitGruss.textContent = '🚀 Gruß senden';
}
}
function closeGrussboxModal() {
if (el.grussboxModal) {
el.grussboxModal.classList.add('hidden');
}
}
async function submitGrussbox() {
const name = el.gbNameInput ? el.gbNameInput.value.trim() : '';
const text = el.gbTextInput ? el.gbTextInput.value.trim() : '';
if (!name) {
showGrussboxFeedback('Bitte gib deinen Namen ein.', 'error');
if (el.gbNameInput) el.gbNameInput.focus();
return;
}
if (!text) {
showGrussboxFeedback('Bitte gib einen Gruß- oder Wunschtext ein.', 'error');
if (el.gbTextInput) el.gbTextInput.focus();
return;
}
const tauri = getTauri();
if (!tauri || !tauri.core) {
showGrussboxFeedback('Tauri API nicht verfügbar.', 'error');
return;
}
if (el.btnSubmitGruss) {
el.btnSubmitGruss.disabled = true;
el.btnSubmitGruss.textContent = '⏳ Wird gesendet...';
}
try {
const resp = await tauri.core.invoke('send_grussbox', { name, text });
showGrussboxFeedback('✅ Gruß erfolgreich an das Studio übermittelt!', 'success');
if (el.gbTextInput) el.gbTextInput.value = '';
addMessage(state.currentChannel, {
type: 'system',
text: `✉ [Grußbox] Gruß von "${name}" wurde an das Studio übermittelt.`
});
setTimeout(() => {
closeGrussboxModal();
}, 1600);
} catch (err) {
showGrussboxFeedback(`❌ Fehler beim Senden: ${err}`, 'error');
if (el.btnSubmitGruss) {
el.btnSubmitGruss.disabled = false;
el.btnSubmitGruss.textContent = '🚀 Gruß senden';
}
}
}
function showGrussboxFeedback(msg, type) {
if (!el.gbFeedback) return;
el.gbFeedback.textContent = msg;
el.gbFeedback.className = `grussbox-feedback ${type}`;
}
// --- Draggable Sidebar Resizer ---
function setupSidebarResizer() {
const resizer = el.sidebarResizer;
const sidebar = el.sidebarUsers;
if (!resizer || !sidebar) return;
// Restore saved width from localStorage (generous limit so user can resize stream as wide as desired)
const savedWidth = localStorage.getItem('euerchat_sidebar_width');
const maxAllowed = Math.max(300, window.innerWidth - 260);
if (savedWidth) {
const parsed = parseInt(savedWidth, 10);
if (parsed >= 160 && parsed <= maxAllowed) {
sidebar.style.width = `${parsed}px`;
}
}
let isResizing = false;
resizer.addEventListener('mousedown', (e) => {
e.preventDefault();
isResizing = true;
document.body.classList.add('resizing');
const onMouseMove = (moveEvent) => {
if (!isResizing) return;
const currentMax = Math.max(300, window.innerWidth - 260);
const newWidth = window.innerWidth - moveEvent.clientX;
if (newWidth >= 160 && newWidth <= currentMax) {
sidebar.style.width = `${newWidth}px`;
adjustTrackTextSizes();
}
};
const onMouseUp = () => {
if (isResizing) {
isResizing = false;
document.body.classList.remove('resizing');
const currentWidth = parseInt(sidebar.style.width, 10);
if (currentWidth) {
localStorage.setItem('euerchat_sidebar_width', currentWidth);
}
adjustTrackTextSizes();
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
}
};
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
});
window.addEventListener('resize', adjustTrackTextSizes);
}
// --- System Fonts Loading ---
async function loadSystemFonts() {
const select = el.optFontFamily;
if (!select) return;
try {
const tauri = getTauri();
if (tauri && tauri.core) {
const fonts = await tauri.core.invoke('get_installed_fonts');
if (Array.isArray(fonts) && fonts.length > 0) {
const curVal = currentSettings.fontFamily || 'system';
select.innerHTML = '';
const optSystem = document.createElement('option');
optSystem.value = 'system';
optSystem.textContent = 'Standard Sans (Segoe UI, System)';
select.appendChild(optSystem);
// Popular Fonts group
const optGroupPop = document.createElement('optgroup');
optGroupPop.label = '★ Beliebte Chat-Schriftarten';
const populars = ['Segoe UI', 'Consolas', 'Arial', 'Calibri', 'Comic Sans MS', 'Verdana', 'Trebuchet MS', 'Courier New', 'Georgia', 'Impact'];
for (const f of populars) {
const opt = document.createElement('option');
opt.value = f;
opt.textContent = f;
optGroupPop.appendChild(opt);
}
select.appendChild(optGroupPop);
// All Windows Installed Fonts
const optGroupAll = document.createElement('optgroup');
optGroupAll.label = `Windows Installiert (${fonts.length} Schriftarten)`;
for (const f of fonts) {
const opt = document.createElement('option');
opt.value = f;
opt.textContent = f;
optGroupAll.appendChild(opt);
}
select.appendChild(optGroupAll);
select.value = curVal;
}
}
} catch (e) {
console.warn('System-Schriftarten konnten nicht geladen werden:', e);
}
}
// --- Updates (Gitea-Releases) ---
let updateInstalling = false;
function setUpdateStatus(target, text, kind) {
if (!target) return;
target.textContent = text;
target.className = 'update-status' + (kind ? ` ${kind}` : '');
}
async function loadAppVersion() {
const tauri = getTauri();
if (!tauri || !tauri.core || !el.updCurrentVersion) return;
try {
el.updCurrentVersion.textContent = await tauri.core.invoke('get_app_version');
} catch (e) { /* Anzeige bleibt leer */ }
}
function showUpdateDialog(info) {
if (!el.updateModal) return;
el.updSummary.textContent = `${info.name || 'EuerChat ' + info.latest} ist verfügbar (installiert: ${info.current}). Möchtest du jetzt aktualisieren?`;
// Release-Notizen nur als Text anzeigen, nie als HTML.
el.updNotes.textContent = (info.notes || '').trim() || 'Für dieses Update wurden keine Release-Notizen hinterlegt.';
el.updProgress.classList.add('hidden');
el.updProgressBar.style.width = '0';
setUpdateStatus(el.updModalStatus, '', '');
el.btnUpdateInstall.disabled = false;
el.btnUpdateLater.disabled = false;
el.updateModal.classList.remove('hidden');
}
async function checkForUpdates(manual) {
const tauri = getTauri();
if (!tauri || !tauri.core || updateInstalling) return;
if (manual) {
if (el.btnCheckUpdate) el.btnCheckUpdate.disabled = true;
setUpdateStatus(el.updStatusText, 'Suche nach Updates …', '');
}
try {
const info = await tauri.core.invoke('check_for_update');
if (info.available) {
setUpdateStatus(el.updStatusText, `Version ${info.latest} ist verfügbar.`, 'success');
showUpdateDialog(info);
} else if (manual) {
setUpdateStatus(el.updStatusText, `✓ EuerChat ${info.current} ist aktuell.`, 'success');
}
} catch (err) {
// Der automatische Start-Check soll bei Netzproblemen nicht stören.
if (manual) {
setUpdateStatus(el.updStatusText, `❌ ${err}`, 'error');
} else {
console.warn('Update-Prüfung fehlgeschlagen:', err);
}
} finally {
if (manual && el.btnCheckUpdate) el.btnCheckUpdate.disabled = false;
}
}
async function installUpdate() {
const tauri = getTauri();
if (!tauri || !tauri.core || updateInstalling) return;
updateInstalling = true;
el.btnUpdateInstall.disabled = true;
el.btnUpdateLater.disabled = true;
el.updProgress.classList.remove('hidden');
setUpdateStatus(el.updModalStatus, 'Update wird geladen und geprüft …', '');
try {
await tauri.core.invoke('install_update');
setUpdateStatus(el.updModalStatus, 'Installation läuft. EuerChat startet gleich neu …', 'success');
} catch (err) {
updateInstalling = false;
el.btnUpdateInstall.disabled = false;
el.btnUpdateLater.disabled = false;
setUpdateStatus(el.updModalStatus, `❌ ${err}`, 'error');
}
}
function closeUpdateDialog() {
if (!updateInstalling && el.updateModal) el.updateModal.classList.add('hidden');
}
// --- Event Listeners Setup ---
function setupEventListeners() {
el.btnSend.addEventListener('click', sendMessage);
el.chatInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
sendMessage();
}
});
// Nickname real-time sync
if (el.directNick) {
el.directNick.addEventListener('input', (e) => {
const val = e.target.value.trim();
if (val) {
currentSettings.directNick = val;
currentSettings.normalNick = val;
if (el.cfgNormalNick) el.cfgNormalNick.value = val;
}
});
}
// Header Connect / Disconnect Button
if (el.btnHeaderConnect) {
el.btnHeaderConnect.addEventListener('click', () => {
if (state.connected || state.connecting) {
disconnectIrc();
} else {
if (currentSettings.activeConnection === 'znc') {
connectZnc();
} else {
connectDirect();
}
}
});
}
// Modi Switch Button
if (el.btnModiToggle) {
el.btnModiToggle.addEventListener('click', toggleModiState);
}
// Updates
if (el.btnCheckUpdate) el.btnCheckUpdate.addEventListener('click', () => checkForUpdates(true));
if (el.btnUpdateInstall) el.btnUpdateInstall.addEventListener('click', installUpdate);
if (el.btnUpdateLater) el.btnUpdateLater.addEventListener('click', closeUpdateDialog);
if (el.btnCloseUpdate) el.btnCloseUpdate.addEventListener('click', closeUpdateDialog);
el.btnEmotePicker.addEventListener('click', (e) => {
e.stopPropagation();
el.emotePicker.classList.toggle('hidden');
if (!el.emotePicker.classList.contains('hidden')) {
el.emoteSearch.focus();
}
});
el.btnCloseEmote.addEventListener('click', () => {
el.emotePicker.classList.add('hidden');
});
// Close emote picker when clicking outside
document.addEventListener('click', (e) => {
if (el.emotePicker && !el.emotePicker.classList.contains('hidden')) {
if (!el.emotePicker.contains(e.target) && !el.btnEmotePicker.contains(e.target)) {
el.emotePicker.classList.add('hidden');
}
}
});
// Close emote picker on Escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && el.emotePicker && !el.emotePicker.classList.contains('hidden')) {
el.emotePicker.classList.add('hidden');
}
});
el.emoteSearch.addEventListener('input', (e) => {
const activeTab = document.querySelector('.emote-tab.active')?.dataset.tab || 'all';
renderEmoteGrid(activeTab, e.target.value);
});
el.emoteTabs.forEach(tab => {
tab.addEventListener('click', () => {
el.emoteTabs.forEach(t => t.classList.remove('active'));
tab.classList.add('active');
renderEmoteGrid(tab.dataset.tab, el.emoteSearch.value);
});
});
// Settings Modal
el.btnSettingsModal.addEventListener('click', () => {
el.settingsModal.classList.remove('hidden');
updatePreview();
});
el.btnCloseSettingsModal.addEventListener('click', () => {
el.settingsModal.classList.add('hidden');
});
el.btnSaveSettings.addEventListener('click', saveSettings);
el.btnResetSettings.addEventListener('click', () => {
applySettings(defaultSettings);
});
// Modal Tab Switching
document.querySelectorAll('.modal-tabs .tab-btn').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.modal-tabs .tab-btn').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
const targetId = tab.dataset.tabTarget;
document.querySelectorAll('.settings-section-pane').forEach(pane => {
if (pane.id === targetId) {
pane.classList.remove('hidden');
pane.classList.add('active');
} else {
pane.classList.add('hidden');
pane.classList.remove('active');
}
});
});
});
// Server Form Type Switcher (Direct vs ZNC)
if (el.btnTypeDirect) {
el.btnTypeDirect.addEventListener('click', () => setConnectionTab('direct'));
}
if (el.btnTypeZnc) {
el.btnTypeZnc.addEventListener('click', () => setConnectionTab('znc'));
}
// Direct Form Actions
if (el.btnSaveDirect) {
el.btnSaveDirect.addEventListener('click', () => saveDirectSettings(true));
}
if (el.btnConnectNow) {
el.btnConnectNow.addEventListener('click', connectDirect);
}
if (el.btnClearDirect) {
el.btnClearDirect.addEventListener('click', clearDirectForm);
}
// ZNC Form Actions
if (el.btnSaveZnc) {
el.btnSaveZnc.addEventListener('click', () => saveZncSettings(true));
}
if (el.btnConnectZncNow) {
el.btnConnectZncNow.addEventListener('click', connectZnc);
}
if (el.btnClearZnc) {
el.btnClearZnc.addEventListener('click', clearZncForm);
}
// Live Settings Inputs Preview Listeners
el.optFontSize.addEventListener('input', (e) => {
el.valFontSize.textContent = `${e.target.value}px`;
updatePreview();
});
if (el.optFontCustom) {
el.optFontCustom.addEventListener('input', updatePreview);
}
[el.optFontFamily, el.optLineHeight, el.optEmoteSize].forEach(input => {
if (input) input.addEventListener('change', updatePreview);
});
if (el.optShowMediaArea) {
el.optShowMediaArea.addEventListener('change', (e) => {
const visible = e.target.checked;
if (el.sidebarMediaArea) el.sidebarMediaArea.classList.toggle('hidden', !visible);
if (visible) {
startStreamPlayer();
} else {
stopStreamPlayer();
}
});
}
// User Context Menu Event Listeners
setupUserContextMenu();
// Lua checkbox toggle
if (el.chkEnableLua) {
el.chkEnableLua.addEventListener('change', (e) => {
currentSettings.luaEnabled = e.target.checked;
updateLuaUI();
});
}
// Lua file input
if (el.btnSelectLua) {
el.btnSelectLua.addEventListener('click', () => {
el.luaFileInput.click();
});
}
if (el.luaFileInput) {
el.luaFileInput.addEventListener('change', (e) => {
if (e.target.files && e.target.files[0]) {
const fn = e.target.files[0].name;
currentSettings.luaEnabled = true;
currentSettings.luaFileName = fn;
if (el.luaFileName) el.luaFileName.textContent = fn;
updateLuaUI();
addMessage('*server*', {
type: 'system',
text: `[Lua] Skript "${fn}" wurde manuell geladen und aktiviert.`
});
}
});
}
// Livestream Audio & Video Controls
if (el.btnStreamMute) {
el.btnStreamMute.addEventListener('click', toggleStreamMute);
}
if (el.streamVolSlider) {
el.streamVolSlider.addEventListener('input', handleVolumeChange);
}
if (el.videoQualitySelect) {
el.videoQualitySelect.addEventListener('change', (e) => {
loadStreamQuality(e.target.value);
});
}
if (el.btnResyncStream) {
el.btnResyncStream.addEventListener('click', resyncStream);
}
// Grußbox Event Listeners
if (el.btnOpenGrussbox) {
el.btnOpenGrussbox.addEventListener('click', openGrussboxModal);
}
if (el.btnCloseGrussbox) {
el.btnCloseGrussbox.addEventListener('click', closeGrussboxModal);
}
if (el.btnCancelGruss) {
el.btnCancelGruss.addEventListener('click', closeGrussboxModal);
}
if (el.btnSubmitGruss) {
el.btnSubmitGruss.addEventListener('click', submitGrussbox);
}
if (el.gbTextInput) {
el.gbTextInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
submitGrussbox();
}
});
}
// Add channel prompt (+)
el.btnAddChan.addEventListener('click', () => {
const name = prompt('Kanalname eingeben (z.B. #stream):');
if (name) {
const formatted = name.startsWith('#') ? name : '#' + name;
addChannelToState(formatted);
switchChannel(formatted);
const tauri = getTauri();
if (state.connected && tauri && tauri.core) {
tauri.core.invoke('send_irc_raw', { line: `JOIN ${formatted}` });
}
}
});
// Clean disconnect on window close
window.addEventListener('beforeunload', () => {
const tauri = getTauri();
if (tauri && tauri.core) {
tauri.core.invoke('disconnect_irc');
}
});
}
// --- Startup ---
window.addEventListener('DOMContentLoaded', async () => {
await loadSettings();
loadEmotes();
setupEventListeners();
setupSidebarResizer();
setupTauriBridge();
initStreamPlayer();
loadSystemFonts();
loadAppVersion();
if (currentSettings.autoUpdateCheck !== false) {
setTimeout(() => checkForUpdates(false), 4000);
}
setTimeout(adjustTrackTextSizes, 300);
// Restore saved nickname (from active connection profile or fallback)
const isZnc = currentSettings.activeConnection === 'znc';
const savedNick = (isZnc ? currentSettings.zncNick : currentSettings.directNick)
|| currentSettings.normalNick
|| '';
if (el.directNick && currentSettings.directNick) el.directNick.value = currentSettings.directNick;
if (el.zncNick && currentSettings.zncNick) el.zncNick.value = currentSettings.zncNick;
if (el.cfgNormalNick) el.cfgNormalNick.value = currentSettings.normalNick || savedNick;
state.currentNick = savedNick;
if (el.displayNick) el.displayNick.textContent = savedNick || 'Nicht verbunden';
if (el.userAvatar) el.userAvatar.textContent = savedNick ? savedNick[0].toUpperCase() : '?';
updateLuaUI();
// Erststart: Name abfragen, bevor irgendetwas verbunden wird
if (!savedNick) {
showServerSettings('Willkommen! Gib deinen Namen ein und klicke auf "Jetzt Verbinden".');
return;
}
// Auto-connect ONLY with saved profile if enabled
if (isZnc) {
if (currentSettings.zncAutoConnect) {
connectZnc();
}
} else {
if (currentSettings.directAutoConnect) {
connectDirect();
}
}
});
})();