2677 lines
93 KiB
JavaScript
2677 lines
93 KiB
JavaScript
// 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, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
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 += '<';
|
||
else if (ch === '>') out += '>';
|
||
else if (ch === '&') out += '&';
|
||
else if (ch === '"') out += '"';
|
||
else if (ch === "'") out += ''';
|
||
else out += ch;
|
||
i++;
|
||
}
|
||
}
|
||
closeAll();
|
||
}
|
||
|
||
// Linkify URLs (Text ist bereits escaped, Anfuehrungszeichen kommen als " 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 = '×';
|
||
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();
|
||
}
|
||
}
|
||
});
|
||
|
||
})();
|