"use strict"; /* ============================================================ * KONFIGURATION * ============================================================ */ const CONFIG = { /* * Your Jitsi Launcher domain */ jitsiLauncher: "meet.bnvb.eu", /* * Your Jitsi domain. */ jitsiHost: "meet.bnvb-ingenieure.de", /* * Timeout trying to start the jitsi-meet-electron client */ launchTimeout: 2000, /* * set wallpaper for webpage */ backgroundImage: "assets/background.png", /* * Set logo */ logo: "assets/logo.svg", /* * Download URLs for jitsi-meet-electron client */ downloads: { windows: "https://github.com/jitsi/jitsi-meet-electron/releases", macos: "https://github.com/jitsi/jitsi-meet-electron/releases", linux: "https://github.com/jitsi/jitsi-meet-electron/releases" } }; /* ============================================================ * Translations * ============================================================ */ const TRANSLATIONS = { en: { pageTitle: "Jitsi Meeting", impressum: "Legal Notice", source: "Source", launcher: { title: "Jitsi Meeting", meetingPrefix: "The meeting", meetingSuffix: "will be joined.", openButton: "Join meeting" }, starting: { title: "Starting Jitsi Meet …", status: "The Jitsi Meet desktop client is being opened." }, fallback: { title: "Jitsi Meet could not be opened", description: "The Jitsi Meet desktop client may not have been started.", retryButton: "Try again" }, downloads: { windows: { title: "Jitsi Meet for Windows", description: "Install the Jitsi Meet desktop client and then try opening the meeting again.", button: "Download Jitsi for Windows" }, macos: { title: "Jitsi Meet for macOS", description: "Install the Jitsi Meet desktop client and then try opening the meeting again.", button: "Download Jitsi for macOS" }, linux: { title: "Jitsi Meet for Linux", description: "Install the Jitsi Meet desktop client and then try opening the meeting again.", button: "Download Jitsi for Linux" }, other: { title: "Jitsi Meet Desktop", description: "No desktop download is configured for your operating system." } }, browser: { title: "Join in browser", description: "You can also join the meeting directly in your browser.", button: "Join in browser" }, error: { title: "No meeting specified", description: "Please specify a meeting using the", descriptionSuffix: "parameter.", invalidTitle: "Invalid meeting name", invalidDescription: "Only letters, numbers and hyphens are allowed:" }, creator: { title: "Create New Meeting", meetingNameLabel: "Meeting Name", meetingDescriptionLabel: "Description", meetingStartLabel: "Start Date & Time", meetingEndLabel: "End Date & Time", meetingOrganizerLabel: "Organizer Email", createButton: "Create Meeting", cancelButton: "Cancel", successMessage: "Meeting created! Download your calendar file and open it to import the meeting into your personal calendar.", downloadButton: "Download ICS File", createNewButton: "Create New Meeting", adhocButton: "Create Adhoc Link", adhocLinkLabel: "Your adhoc meeting link:", copyLinkButton: "Copy Link", copiedLabel: "Copied!", meetingNameRequired: "Please enter a meeting name", meetingNameInvalid: "Meeting name may only contain letters, numbers and hyphens", startTimeRequired: "Please select a start time", endTimeRequired: "Please select an end time", endAfterStart: "End time must be after start time", organizerRequired: "Please enter an organizer email", organizerInvalid: "Please enter a valid organizer email address" } }, de: { pageTitle: "Jitsi Meeting öffnen", impressum: "Impressum", source: "Quellcode", launcher: { title: "Jitsi Meeting", meetingPrefix: "Das Meeting", meetingSuffix: "wird geöffnet.", openButton: "An Meeting teilnehmen" }, starting: { title: "Jitsi Meet wird gestartet …", status: "Der Jitsi Meet Desktop-Client wird geöffnet." }, fallback: { title: "Jitsi Meet konnte nicht geöffnet werden", description: "Der Jitsi Meet Desktop-Client wurde möglicherweise nicht gestartet.", retryButton: "Erneut versuchen" }, downloads: { windows: { title: "Jitsi Meet für Windows", description: "Installieren Sie den Jitsi Meet Desktop-Client und versuchen Sie anschließend erneut, das Meeting zu öffnen.", button: "Jitsi für Windows herunterladen" }, macos: { title: "Jitsi Meet für macOS", description: "Installieren Sie den Jitsi Meet Desktop-Client und versuchen Sie anschließend erneut, das Meeting zu öffnen.", button: "Jitsi für macOS herunterladen" }, linux: { title: "Jitsi Meet für Linux", description: "Installieren Sie den Jitsi Meet Desktop-Client und versuchen Sie anschließend erneut, das Meeting zu öffnen.", button: "Jitsi für Linux herunterladen" }, other: { title: "Jitsi Meet Desktop", description: "Für Ihr Betriebssystem ist kein Desktop-Download hinterlegt." } }, browser: { title: "Im Browser teilnehmen", description: "Sie können dem Meeting auch direkt über Ihren Browser beitreten.", button: "Im Browser beitreten" }, error: { title: "Kein Meeting angegeben", description: "Bitte geben Sie ein Meeting über den Parameter", descriptionSuffix: "an.", invalidTitle: "Ungültiger Meeting-Name", invalidDescription: "Erlaubt sind nur Buchstaben, Zahlen und Bindestriche:" }, creator: { title: "Neues Meeting erstellen", meetingNameLabel: "Meeting-Name", meetingDescriptionLabel: "Beschreibung", meetingStartLabel: "Start Datum & Uhrzeit", meetingEndLabel: "Ende Datum & Uhrzeit", meetingOrganizerLabel: "Organisator E-Mail", createButton: "Meeting erstellen", cancelButton: "Abbrechen", successMessage: "Meeting erstellt! Laden Sie Ihre Kalenderdatei herunter und öffnen Sie diese, um das Meeting in Ihren persönlichen Kalender zu importieren.", downloadButton: "ICS-Datei herunterladen", createNewButton: "Neues Meeting erstellen", adhocButton: "Adhoc-Link erstellen", adhocLinkLabel: "Ihr Adhoc-Meeting-Link:", copyLinkButton: "Link kopieren", copiedLabel: "Kopiert!", meetingNameRequired: "Bitte geben Sie einen Meeting-Namen ein", meetingNameInvalid: "Der Meeting-Name darf nur Buchstaben, Zahlen und Bindestriche enthalten", startTimeRequired: "Bitte wählen Sie eine Startzeit", endTimeRequired: "Bitte wählen Sie eine Endzeit", endAfterStart: "Die Endzeit muss nach der Startzeit liegen", organizerRequired: "Bitte geben Sie eine Organisator-E-Mail ein", organizerInvalid: "Bitte geben Sie eine gültige Organisator-E-Mail-Adresse ein" } }, fr: { pageTitle: "Réunion Jitsi", impressum: "Mentions légales", source: "Code source", launcher: { title: "Réunion Jitsi", meetingPrefix: "La réunion", meetingSuffix: "va être ouverte.", openButton: "Rejoindre la réunion" }, starting: { title: "Démarrage de Jitsi Meet…", status: "Le client bureau Jitsi Meet est en cours d'ouverture." }, fallback: { title: "Jitsi Meet n'a pas pu être ouvert", description: "Le client bureau Jitsi Meet n'a peut-être pas été démarré.", retryButton: "Réessayer" }, downloads: { windows: { title: "Jitsi Meet pour Windows", description: "Installez le client bureau Jitsi Meet, puis essayez de rouvrir la réunion.", button: "Télécharger Jitsi pour Windows" }, macos: { title: "Jitsi Meet pour macOS", description: "Installez le client bureau Jitsi Meet, puis essayez de rouvrir la réunion.", button: "Télécharger Jitsi pour macOS" }, linux: { title: "Jitsi Meet pour Linux", description: "Installez le client bureau Jitsi Meet, puis essayez de rouvrir la réunion.", button: "Télécharger Jitsi pour Linux" }, other: { title: "Jitsi Meet Desktop", description: "Aucun téléchargement bureau n'est configuré pour votre système d'exploitation." } }, browser: { title: "Rejoindre dans le navigateur", description: "Vous pouvez également rejoindre la réunion directement dans votre navigateur.", button: "Rejoindre dans le navigateur" }, error: { title: "Aucune réunion spécifiée", description: "Veuillez spécifier une réunion à l'aide du paramètre", descriptionSuffix: ".", invalidTitle: "Nom de réunion invalide", invalidDescription: "Seuls les lettres, les chiffres et les tirets sont autorisés :" }, creator: { title: "Créer une nouvelle réunion", meetingNameLabel: "Nom de la réunion", meetingDescriptionLabel: "Description", meetingStartLabel: "Date et heure de début", meetingEndLabel: "Date et heure de fin", meetingOrganizerLabel: "Email de l'organisateur", createButton: "Créer la réunion", cancelButton: "Annuler", successMessage: "Réunion créée ! Téléchargez votre fichier calendrier et ouvrez-le pour importer la réunion dans votre calendrier personnel.", downloadButton: "Télécharger le fichier ICS", createNewButton: "Créer une nouvelle réunion", adhocButton: "Créer un lien adhoc", adhocLinkLabel: "Votre lien de réunion adhoc :", copyLinkButton: "Copier le lien", copiedLabel: "Copié !", meetingNameRequired: "Veuillez entrer un nom de réunion", meetingNameInvalid: "Le nom de la réunion ne peut contenir que des lettres, des chiffres et des tirets", startTimeRequired: "Veuillez sélectionner une heure de début", endTimeRequired: "Veuillez sélectionner une heure de fin", endAfterStart: "L'heure de fin doit être après l'heure de début", organizerRequired: "Veuillez entrer un email d'organisateur", organizerInvalid: "Veuillez entrer une adresse email d'organisateur valide" } }, zh: { pageTitle: "Jitsi 会议", impressum: "法律声明", source: "源代码", launcher: { title: "Jitsi 会议", meetingPrefix: "会议", meetingSuffix: "即将打开。", openButton: "加入会议" }, starting: { title: "正在启动 Jitsi Meet …", status: "正在打开 Jitsi Meet 桌面客户端。" }, fallback: { title: "无法打开 Jitsi Meet", description: "Jitsi Meet 桌面客户端可能尚未启动。", retryButton: "重试" }, downloads: { windows: { title: "适用于 Windows 的 Jitsi Meet", description: "请安装 Jitsi Meet 桌面客户端,然后再次尝试打开会议。", button: "下载 Windows 版 Jitsi" }, macos: { title: "适用于 macOS 的 Jitsi Meet", description: "请安装 Jitsi Meet 桌面客户端,然后再次尝试打开会议。", button: "下载 macOS 版 Jitsi" }, linux: { title: "适用于 Linux 的 Jitsi Meet", description: "请安装 Jitsi Meet 桌面客户端,然后再次尝试打开会议。", button: "下载 Linux 版 Jitsi" }, other: { title: "Jitsi Meet 桌面版", description: "没有为您的操作系统配置桌面版下载。" } }, browser: { title: "在浏览器中加入", description: "您也可以直接通过浏览器加入会议。", button: "在浏览器中加入" }, error: { title: "未指定会议", description: "请使用参数指定会议", descriptionSuffix: "。", invalidTitle: "会议名称无效", invalidDescription: "仅允许使用字母、数字和连字符:" }, creator: { title: "创建新会议", meetingNameLabel: "会议名称", meetingDescriptionLabel: "描述", meetingStartLabel: "开始日期和时间", meetingEndLabel: "结束日期和时间", meetingOrganizerLabel: "组织者邮箱", createButton: "创建会议", cancelButton: "取消", successMessage: "会议已创建!请下载您的日历文件并打开它,将会议导入您的个人日历。", downloadButton: "下载 ICS 文件", createNewButton: "创建新会议", adhocButton: "创建临时链接", adhocLinkLabel: "您的临时会议链接:", copyLinkButton: "复制链接", copiedLabel: "已复制!", meetingNameRequired: "请输入会议名称", meetingNameInvalid: "会议名称只能包含字母、数字和连字符", startTimeRequired: "请选择开始时间", endTimeRequired: "请选择结束时间", endAfterStart: "结束时间必须晚于开始时间", organizerRequired: "请输入组织者邮箱", organizerInvalid: "请输入有效的组织者邮箱地址" } } }; /* ============================================================ * Detect language * ============================================================ */ function detectLanguage() { /* * Gespeicherte Sprachwahl hat Vorrang. */ try { const saved = localStorage.getItem("jitsiLauncherLanguage"); if (saved && TRANSLATIONS[saved]) { return saved; } } catch (error) { /* * localStorage kann z.B. im Privat-Modus blockiert sein. */ } const languages = navigator.languages || [navigator.language]; for (const language of languages) { if (!language) { continue; } const languageCode = language .toLowerCase() .split("-")[0]; if (TRANSLATIONS[languageCode]) { return languageCode; } } /* * Englisch ist der Standard/Fallback. */ return "en"; } let currentLanguage = detectLanguage(); let translation = TRANSLATIONS[currentLanguage]; /* ============================================================ * Apply translations to webpage * ============================================================ */ function applyTranslations() { document.documentElement.lang = currentLanguage; document.title = translation.pageTitle; document .querySelectorAll("[data-i18n]") .forEach((element) => { const key = element.dataset.i18n; const value = getTranslation(key); if (value !== null) { element.textContent = value; } }); } /* * Übersetzung anhand eines Pfades lesen. * * Beispiel: * * "downloads.windows.title" * * ergibt: * * translation.downloads.windows.title */ function getTranslation(key) { const parts = key.split("."); let value = translation; for (const part of parts) { if ( value === null || value === undefined || typeof value !== "object" || !(part in value) ) { return null; } value = value[part]; } return typeof value === "string" ? value : null; } /* ============================================================ * LANGUAGE MENU * ============================================================ */ const LANGUAGE_NAMES = { en: "English", de: "Deutsch", fr: "Français", zh: "中文" }; const LANGUAGE_FLAGS = { en: "assets/uk.svg", de: "assets/de.svg", fr: "assets/fr.svg", zh: "assets/cn.svg" }; function updateLanguageMenu() { langFlag.src = LANGUAGE_FLAGS[currentLanguage]; langLabel.textContent = LANGUAGE_NAMES[currentLanguage]; langDropdown .querySelectorAll("button[data-lang]") .forEach((item) => { item.classList.toggle( "active", item.dataset.lang === currentLanguage ); }); } function setLanguage(language) { if (!TRANSLATIONS[language]) { return; } currentLanguage = language; translation = TRANSLATIONS[language]; try { localStorage.setItem("jitsiLauncherLanguage", language); } catch (error) { /* * localStorage kann z.B. im Privat-Modus blockiert sein. */ } applyTranslations(); updateLanguageMenu(); } function toggleLanguageMenu() { const isOpen = langDropdown.classList.toggle("open"); langButton.setAttribute( "aria-expanded", isOpen ? "true" : "false" ); } function closeLanguageMenu() { langDropdown.classList.remove("open"); langButton.setAttribute("aria-expanded", "false"); } /* ============================================================ * NAV MENU (Hamburger) * ============================================================ */ function toggleNavMenu() { const isOpen = navActions.classList.toggle("open"); navToggle.setAttribute("aria-expanded", isOpen ? "true" : "false"); } function closeNavMenu() { navActions.classList.remove("open"); navToggle.setAttribute("aria-expanded", "false"); } /* ============================================================ * THEME * ============================================================ */ const THEME_KEY = "jitsiLauncherTheme"; function savedTheme() { try { const saved = localStorage.getItem(THEME_KEY); if (saved === "dark" || saved === "light") { return saved; } } catch (error) { /* * localStorage kann z.B. im Privat-Modus blockiert sein. */ } return null; } function systemIsDark() { return window.matchMedia("(prefers-color-scheme: dark)").matches; } function detectTheme() { /* * Gespeicherte Wahl hat Vorrang, sonst System-Einstellung. */ return savedTheme() || (systemIsDark() ? "dark" : "light"); } let currentTheme = detectTheme(); function updateThemeToggle() { themeIconSun.classList.toggle("hidden", currentTheme !== "dark"); themeIconMoon.classList.toggle("hidden", currentTheme === "dark"); } function applyTheme() { document.documentElement.classList.toggle("dark", currentTheme === "dark"); updateThemeToggle(); } function toggleTheme() { currentTheme = currentTheme === "dark" ? "light" : "dark"; try { localStorage.setItem(THEME_KEY, currentTheme); } catch (error) { /* * localStorage kann z.B. im Privat-Modus blockiert sein. */ } applyTheme(); } /* ============================================================ * BACKGROUND IMAGE * ============================================================ */ function applyBackgroundImage() { if (!CONFIG.backgroundImage) { return; } document.body.style.backgroundImage = `url("${CONFIG.backgroundImage}")`; document.body.style.backgroundSize = "cover"; document.body.style.backgroundPosition = "center"; } /* ============================================================ * LOGO * ============================================================ */ function applyLogo() { if (!CONFIG.logo) { return; } logoImage.src = CONFIG.logo; logoBar.classList.remove("hidden"); } /* ============================================================ * DOM elements * ============================================================ */ const launcher = document.getElementById("launcher"); const starting = document.getElementById("starting"); const fallback = document.getElementById("fallback"); const invalidRoom = document.getElementById("invalidRoom"); const meetingCreator = document.getElementById("meetingCreator"); const roomName = document.getElementById("roomName"); const openButton = document.getElementById("openButton"); const retryButton = document.getElementById("retryButton"); const browserLink = document.getElementById("browserLink"); const windowsDownload = document.getElementById("windowsDownload"); const macosDownload = document.getElementById("macosDownload"); const linuxDownload = document.getElementById("linuxDownload"); const otherDownload = document.getElementById("otherDownload"); const windowsLink = document.getElementById("windowsLink"); const macosLink = document.getElementById("macosLink"); const linuxLink = document.getElementById("linuxLink"); const createNewMeetingButton = document.getElementById("createNewMeetingButton"); const createNewFromLauncherButton = document.getElementById("createNewFromLauncherButton"); /* Meeting Creator DOM elements */ const meetingNameInput = document.getElementById("meetingName"); const meetingDescriptionInput = document.getElementById("meetingDescription"); const meetingStartInput = document.getElementById("meetingStart"); const meetingEndInput = document.getElementById("meetingEnd"); const meetingOrganizerInput = document.getElementById("meetingOrganizer"); const createMeetingButton = document.getElementById("createMeetingButton"); const createAdhocButton = document.getElementById("createAdhocButton"); const cancelCreateButton = document.getElementById("cancelCreateButton"); const icsSuccess = document.getElementById("icsSuccess"); const downloadIcsLink = document.getElementById("downloadIcsLink"); const adhocResult = document.getElementById("adhocResult"); const adhocLink = document.getElementById("adhocLink"); const copyAdhocLinkButton = document.getElementById("copyAdhocLinkButton"); /* Navbar DOM elements */ const navbar = document.querySelector(".navbar"); const navToggle = document.getElementById("navToggle"); const navActions = document.getElementById("navActions"); /* Language menu DOM elements */ const langMenu = document.getElementById("langMenu"); const langButton = document.getElementById("langButton"); const langDropdown = document.getElementById("langDropdown"); const langFlag = document.getElementById("langFlag"); const langLabel = document.getElementById("langLabel"); /* Invalid room view DOM elements */ const missingRoomTitle = document.getElementById("missingRoomTitle"); const missingRoomError = document.getElementById("missingRoomError"); const invalidNameTitle = document.getElementById("invalidNameTitle"); const invalidNameError = document.getElementById("invalidNameError"); const invalidRoomName = document.getElementById("invalidRoomName"); /* Logo DOM elements */ const logoBar = document.getElementById("logoBar"); const logoImage = document.getElementById("logoImage"); /* Theme DOM elements */ const themeButton = document.getElementById("themeButton"); const themeIconSun = document.getElementById("themeIconSun"); const themeIconMoon = document.getElementById("themeIconMoon"); /* ============================================================ * GET ROOM FROM QUERY-PARAMETER * ============================================================ * * Beispiel: * * https:///launch.html?room=abc-defg-hij * * ergibt: * * abc-defg-hij * * ============================================================ */ function getRoom() { const params = new URLSearchParams( window.location.search ); /* * Parameter nicht vorhanden. */ if (!params.has("room")) { return null; } /* * Leerzeichen etc. verhindern. * * "?room=" ohne Wert ergibt einen leeren String. */ return params.get("room").trim(); } /* ============================================================ * VALIDATE ROOM * ============================================================ * * Jitsi-Roomnamen können je nach Konfiguration relativ * unterschiedliche Zeichen enthalten. * * Wir verhindern hier lediglich Steuerzeichen und * offensichtliche URL-Manipulation. * * ============================================================ */ const ROOM_PATTERN = /^[A-Za-z0-9-]+$/; function isValidRoom(room) { if (!room) { return false; } /* * Nur Buchstaben, Zahlen und Bindestriche. */ return ROOM_PATTERN.test(room); } /* ============================================================ * JITSI WEB URL * ============================================================ */ function createBrowserUrl(room) { return ( "https://" + CONFIG.jitsiHost + "/" + encodeURIComponent(room) ); } /* ============================================================ * JITSI DESKTOP PROTOCOL URL * ============================================================ */ function createAppUrl(room) { return ( "jitsi-meet://" + CONFIG.jitsiHost + "/" + encodeURIComponent(room) ); } /* ============================================================ * JITSI ANDROID URL * ============================================================ */ /* * Die Android-App registriert "org.jitsi.meet", nicht das * Desktop-Schema. Chrome & Co. verlangen intent://, ohne App * bietet Chrome den Play-Store-Eintrag aus "package=" an. * Firefox ignoriert intent://, kennt aber das Custom-Schema. */ function createAndroidIntentUrl(room) { return ( "intent://" + CONFIG.jitsiHost + "/" + encodeURIComponent(room) + "#Intent;scheme=org.jitsi.meet;package=org.jitsi.meet;end" ); } const ANDROID_APP_SCHEME = "org.jitsi.meet://"; function createAndroidAppUrl(room) { return ( ANDROID_APP_SCHEME + CONFIG.jitsiHost + "/" + encodeURIComponent(room) ); } /* ============================================================ * URL NACH PLATTFORM/BROWSER * ============================================================ */ function selectProtocolUrl(room) { if (detectOperatingSystem() !== "android") { return createAppUrl(room); } return detectBrowser() === "firefox" ? createAndroidAppUrl(room) : createAndroidIntentUrl(room); } /* ============================================================ * DETECT OPERATING SYSTEM * ============================================================ */ /* * Einmal parsen (assets/ua-parser.min.js). * Fällt die Bibliothek aus, bleibt die Seite bedienbar. */ const uaResult = typeof UAParser === "function" ? new UAParser().getResult() : { os: {}, browser: {} }; /* * UAParser-Namen -> interne Kürzel. * iPadOS meldet sich als macOS – Touchpunkte gelten als iOS. */ const OS_NAMES = { Android: "android", iOS: "ios", Windows: "windows", macOS: "macos", Linux: "linux", Ubuntu: "linux", Debian: "linux", Fedora: "linux", Mint: "linux", CentOS: "linux", openSUSE: "linux", Raspbian: "linux" }; const BROWSER_NAMES = { Edge: "edge", "Mobile Edge": "edge", Firefox: "firefox", "Mobile Firefox": "firefox", Chrome: "chrome", "Mobile Chrome": "chrome", "Chrome Headless": "chrome", "Chrome WebView": "chrome", "Samsung Internet": "chrome", Safari: "safari", "Mobile Safari": "safari" }; function detectOperatingSystem() { /* * Chrome im "Desktop-Modus" auf Android sendet einen * Desktop-UA (z.B. X11; Linux). Client Hints verraten * das echte System. Firefox/Safari kennen sie nicht. */ const hints = navigator.userAgentData; if (hints && hints.platform === "Android") { return OS_NAMES.Android; } /* * iPadOS tarnt sich als macOS. */ if ( uaResult.os.name === "macOS" && navigator.maxTouchPoints > 1 ) { return OS_NAMES.iOS; } return OS_NAMES[uaResult.os.name] || "unknown"; } /* ============================================================ * BROWSER ERKENNEN * ============================================================ */ function detectBrowser() { return BROWSER_NAMES[uaResult.browser.name] || "unknown"; } /* ============================================================ * DOWNLOADS * ============================================================ */ function configureDownloads() { const os = detectOperatingSystem(); windowsDownload.classList.add("hidden"); macosDownload.classList.add("hidden"); linuxDownload.classList.add("hidden"); otherDownload.classList.add("hidden"); /* Windows */ if (os === "windows") { windowsLink.href = CONFIG.downloads.windows; windowsDownload.classList.remove("hidden"); return; } /* macOS */ if (os === "macos") { macosLink.href = CONFIG.downloads.macos; macosDownload.classList.remove("hidden"); return; } /* Linux */ if (os === "linux") { linuxLink.href = CONFIG.downloads.linux; linuxDownload.classList.remove("hidden"); return; } /* Alles andere */ otherDownload.classList.remove("hidden"); } /* ============================================================ * FALLBACK ANZEIGEN * ============================================================ */ function showFallback() { starting.classList.add("hidden"); launcher.classList.add("hidden"); fallback.classList.remove("hidden"); configureDownloads(); } /* ============================================================ * JITSI DESKTOP STARTEN * ============================================================ */ function launchJitsi() { const room = getRoom(); if (!isValidRoom(room)) { return; } const protocolUrl = selectProtocolUrl(room); const browserUrl = createBrowserUrl(room); /* * Browser-Fallback vorbereiten. */ browserLink.href = browserUrl; /* * UI auf "Startvorgang" setzen. */ launcher.classList.add("hidden"); fallback.classList.add("hidden"); starting.classList.remove("hidden"); let completed = false; let timeout = null; /* * Unsichtbarer iframe für den Protocol Handler. */ const iframe = document.createElement("iframe"); iframe.style.display = "none"; /* * Aufräumen. */ function cleanup() { if (completed) { return; } completed = true; if (timeout !== null) { clearTimeout(timeout); } iframe.remove(); } /* * -------------------------------------------------------- * WICHTIG: * * Kein "blur" mehr als Erfolgskriterium verwenden. * * Der Browser kann beim Protocol-Dialog ebenfalls * blur auslösen. * * Wenn der Benutzer den Dialog abbricht, bleibt die * Seite sichtbar und unser Timeout läuft weiter. * -------------------------------------------------------- */ document.body.appendChild(iframe); /* * Protocol Handler starten. * * Firefox ignoriert externe Protokolle aus iframes – * das Custom-Schema direkt in der Hauptnavigation starten. */ try { if (protocolUrl.startsWith(ANDROID_APP_SCHEME)) { window.location.href = protocolUrl; } else { iframe.src = protocolUrl; } } catch (error) { console.error( "Error opening the Jitsi Protocol Handler:", error ); cleanup(); showFallback(); return; } /* * -------------------------------------------------------- * FALLBACK * -------------------------------------------------------- * * Wir warten unabhängig davon, ob der Browser kurz * den Fokus verliert. * * Wenn Jitsi erfolgreich startet: * * -> Jitsi kommt in den Vordergrund * -> Benutzer sieht diese Seite nicht mehr * * Wenn der Benutzer den Browser-Dialog abbricht: * * -> diese Seite bleibt sichtbar * -> Timeout läuft ab * -> Fallback wird angezeigt * -------------------------------------------------------- */ timeout = setTimeout(() => { if (completed) { return; } cleanup(); showFallback(); }, CONFIG.launchTimeout); } /* ============================================================ * ROOM NAME * ============================================================ */ /* * Sonderzeichen in das nächstliegende ASCII-Zeichen * umwandeln. * * Beispiel: * * "Grundstück" ergibt "Grundstueck" * "François" ergibt "Francois" */ function transliterate(text) { const extras = { "ß": "ss", "ẞ": "ss", "æ": "ae", "Æ": "ae", "œ": "oe", "Œ": "oe", "ø": "o", "Ø": "o" }; return text .normalize("NFD") .replace(/[\u0300-\u036f]/g, "") .replace(/[ßẞæÆœŒøØ]/g, (char) => extras[char]); } /* * Aus dem Meeting-Namen einen gültigen Room-Namen ableiten. * * Beispiel: * * "Grund Stück Planung" ergibt "grund-stuck-planung" */ function createRoomName(meetingName) { return transliterate(meetingName) .toLowerCase() .replace(/\s+/g, "-") .replace(/[^a-z0-9-]/g, ""); } /* ============================================================ * ICS FILE GENERATION * ============================================================ */ function generateICS(meetingName, description, startTime, endTime, organizer) { /* Generate a unique room name from the meeting name */ const roomName = createRoomName(meetingName); const meetingUrl = createLaunchUrl(roomName); /* Format dates for ICS */ const formatICSDate = (date) => { const pad = (num) => num.toString().padStart(2, '0'); const year = date.getFullYear(); const month = pad(date.getMonth() + 1); const day = pad(date.getDate()); const hours = pad(date.getHours()); const minutes = pad(date.getMinutes()); const seconds = pad(date.getSeconds()); return `${year}${month}${day}T${hours}${minutes}${seconds}`; }; const uid = `jitsi-${Date.now()}@${CONFIG.jitsiHost}`; const dtStamp = formatICSDate(new Date()); const dtStart = formatICSDate(startTime); const dtEnd = formatICSDate(endTime); /* Build ICS content */ const icsContent = `BEGIN:VCALENDAR VERSION:2.0 PRODID:-//BNVB//Jitsi Meeting//EN CALSCALE:GREGORIAN METHOD:PUBLISH BEGIN:VEVENT UID:${uid} DTSTAMP:${dtStamp} DTSTART:${dtStart} DTEND:${dtEnd} SUMMARY:${meetingName.replace(/,/g, '\\,')} DESCRIPTION:${description.replace(/,/g, '\\,').replace(/\n/g, '\\n')} LOCATION:${meetingUrl} ORGANIZER:MAILTO:${organizer} STATUS:CONFIRMED URL:${meetingUrl} SEQUENCE:0 TRANSP:OPAQUE BEGIN:VALARM ACTION:DISPLAY DESCRIPTION:Reminder TRIGGER:-PT15M END:VALARM END:VEVENT END:VCALENDAR`; return { content: icsContent, roomName: roomName, url: meetingUrl }; } function downloadICS(content, filename) { const blob = new Blob([content], { type: 'text/calendar;charset=utf-8' }); const url = URL.createObjectURL(blob); return url; } const MINUTES_STEP = 15; const DEFAULT_DURATION_MINUTES = 30; /* * Minuten auf das 15-Minuten-Raster aufrunden. * * Beispiel: * * 14:07 ergibt 14:15 * 14:50 ergibt 15:00 */ function roundMinutes(date) { const rounded = new Date(date); rounded.setSeconds(0, 0); rounded.setMinutes( Math.ceil(date.getMinutes() / MINUTES_STEP) * MINUTES_STEP ); return rounded; } /* * Lokale Zeit für datetime-local formatieren. * * Beispiel: * * 1. Januar 2026, 14:15 ergibt "2026-01-01T14:15" */ function formatLocal(date) { const pad = (num) => num.toString().padStart(2, "0"); return ( date.getFullYear() + "-" + pad(date.getMonth() + 1) + "-" + pad(date.getDate()) + "T" + pad(date.getHours()) + ":" + pad(date.getMinutes()) ); } function showMeetingCreator() { /* Hide all other views */ launcher.classList.add("hidden"); starting.classList.add("hidden"); fallback.classList.add("hidden"); invalidRoom.classList.add("hidden"); /* Show meeting creator */ meetingCreator.classList.remove("hidden"); /* Reset form */ meetingNameInput.value = ''; meetingDescriptionInput.value = ''; meetingStartInput.value = ''; meetingEndInput.value = ''; meetingOrganizerInput.value = ''; icsSuccess.classList.add("hidden"); adhocResult.classList.add("hidden"); /* Set default start and end times */ const start = roundMinutes(new Date()); const end = new Date( start.getTime() + DEFAULT_DURATION_MINUTES * 60 * 1000 ); meetingStartInput.value = formatLocal(start); meetingEndInput.value = formatLocal(end); } function createMeeting() { const meetingName = meetingNameInput.value.trim(); const description = meetingDescriptionInput.value.trim(); const startTime = new Date(meetingStartInput.value); const endTime = new Date(meetingEndInput.value); const organizer = meetingOrganizerInput.value.trim(); if (!meetingName) { alert(getTranslation('creator.meetingNameRequired') || 'Please enter a meeting name'); return; } if (!ROOM_PATTERN.test(createRoomName(meetingName))) { alert(getTranslation('creator.meetingNameInvalid') || 'Meeting name may only contain letters, numbers and hyphens'); return; } if (!meetingStartInput.value) { alert(getTranslation('creator.startTimeRequired') || 'Please select a start time'); return; } if (!meetingEndInput.value) { alert(getTranslation('creator.endTimeRequired') || 'Please select an end time'); return; } if (startTime >= endTime) { alert(getTranslation('creator.endAfterStart') || 'End time must be after start time'); return; } if (!organizer) { alert(getTranslation('creator.organizerRequired') || 'Please enter an organizer email'); return; } const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailPattern.test(organizer)) { alert(getTranslation('creator.organizerInvalid') || 'Please enter a valid organizer email address'); return; } /* Generate ICS */ const icsData = generateICS(meetingName, description || meetingName, startTime, endTime, organizer); /* Create download link */ const blob = new Blob([icsData.content], { type: 'text/calendar;charset=utf-8' }); const url = URL.createObjectURL(blob); downloadIcsLink.href = url; downloadIcsLink.download = `${icsData.roomName}.ics`; /* Show success message */ icsSuccess.classList.remove("hidden"); adhocResult.classList.add("hidden"); /* Scroll to success message */ icsSuccess.scrollIntoView({ behavior: 'smooth' }); } /* ============================================================ * ADHOC LINK * ============================================================ */ /* * Zeitspanne, nach der der "Kopiert!"-Text zum * normalen Button-Label zurückkehrt. */ const COPY_RESET_MS = 2000; /* * Launch-URL, die das Meeting im Jitsi-Client öffnet. * * Beispiel: * * "projekt-kickoff" ergibt * https://meet.bnvb.eu/launch.html?room=projekt-kickoff */ function createLaunchUrl(roomName) { return `https://${CONFIG.jitsiLauncher}/launch.html?room=${encodeURIComponent(roomName)}`; } function createAdhocLink() { const meetingName = meetingNameInput.value.trim(); if (!meetingName) { alert(getTranslation('creator.meetingNameRequired') || 'Please enter a meeting name'); return; } const roomName = createRoomName(meetingName); if (!ROOM_PATTERN.test(roomName)) { alert(getTranslation('creator.meetingNameInvalid') || 'Meeting name may only contain letters, numbers and hyphens'); return; } const url = createLaunchUrl(roomName); adhocLink.href = url; adhocLink.textContent = url; icsSuccess.classList.add("hidden"); adhocResult.classList.remove("hidden"); adhocResult.scrollIntoView({ behavior: 'smooth' }); } async function copyAdhocLink() { const url = adhocLink.href; /* Clipboard-API, mit Fallback für ältere Browser */ try { await navigator.clipboard.writeText(url); } catch (error) { const input = document.createElement('textarea'); input.value = url; document.body.appendChild(input); input.select(); document.execCommand('copy'); document.body.removeChild(input); } /* Kurzes Feedback, dann zurück zum normalen Label */ copyAdhocLinkButton.textContent = getTranslation('creator.copiedLabel') || 'Copied!'; setTimeout(() => { copyAdhocLinkButton.textContent = getTranslation('creator.copyLinkButton') || 'Copy Link'; }, COPY_RESET_MS); } function cancelCreateMeeting() { meetingCreator.classList.add("hidden"); /* If there was a room parameter, show the launcher */ const room = getRoom(); if (isValidRoom(room)) { roomName.textContent = room; browserLink.href = createBrowserUrl(room); launcher.classList.remove("hidden"); } else { invalidRoom.classList.remove("hidden"); } } /* ============================================================ * INITIALISIERUNG * ============================================================ */ function initialize() { const room = getRoom(); /* * Kein room-Parameter: Meldungen ausblenden, * nur den Create-Button anzeigen. */ if (room === null) { missingRoomTitle.classList.add("hidden"); missingRoomError.classList.add("hidden"); invalidNameTitle.classList.add("hidden"); invalidNameError.classList.add("hidden"); invalidRoom.classList.remove("hidden"); createNewMeetingButton.classList.remove("hidden"); return; } /* * Leerer room-Parameter ("?room="): * "No meeting specified" anzeigen. */ if (room === "") { missingRoomTitle.classList.remove("hidden"); missingRoomError.classList.remove("hidden"); invalidNameTitle.classList.add("hidden"); invalidNameError.classList.add("hidden"); invalidRoom.classList.remove("hidden"); createNewMeetingButton.classList.remove("hidden"); return; } /* * Ungültiger room-Parameter: nur die * Invalid-Name-Meldung anzeigen. */ if (!isValidRoom(room)) { missingRoomTitle.classList.add("hidden"); missingRoomError.classList.add("hidden"); invalidNameTitle.classList.remove("hidden"); invalidNameError.classList.remove("hidden"); invalidRoomName.textContent = room; invalidRoom.classList.remove("hidden"); createNewMeetingButton.classList.remove("hidden"); return; } /* * Room anzeigen. */ roomName.textContent = room; /* * Browser-Fallback vorbereiten. */ browserLink.href = createBrowserUrl(room); /* * Launcher anzeigen. */ launcher.classList.remove("hidden"); /* * Hide the "Create New Meeting" button when a valid room is specified */ createNewFromLauncherButton.classList.add("hidden"); /* * Debug-Informationen. */ console.log( "Jitsi Launcher", { language: currentLanguage, room: room, operatingSystem: detectOperatingSystem(), browser: detectBrowser(), protocol: selectProtocolUrl(room), browserUrl: createBrowserUrl(room) } ); } /* ============================================================ * EVENTS * ============================================================ */ openButton.addEventListener( "click", launchJitsi ); retryButton.addEventListener( "click", launchJitsi ); /* Meeting Creator Events */ createNewMeetingButton.addEventListener( "click", showMeetingCreator ); createNewFromLauncherButton.addEventListener( "click", showMeetingCreator ); createMeetingButton.addEventListener( "click", createMeeting ); createAdhocButton.addEventListener( "click", createAdhocLink ); copyAdhocLinkButton.addEventListener( "click", copyAdhocLink ); cancelCreateButton.addEventListener( "click", cancelCreateMeeting ); /* Language menu events */ langButton.addEventListener( "click", toggleLanguageMenu ); langDropdown.addEventListener( "click", (event) => { const item = event.target.closest("button[data-lang]"); if (!item) { return; } setLanguage(item.dataset.lang); closeLanguageMenu(); } ); document.addEventListener( "click", (event) => { if (!langMenu.contains(event.target)) { closeLanguageMenu(); } if (!navbar.contains(event.target)) { closeNavMenu(); } } ); /* Theme events */ themeButton.addEventListener( "click", toggleTheme ); /* Hamburger events */ navToggle.addEventListener( "click", toggleNavMenu ); /* * Ohne gespeicherte Wahl dem System folgen. */ window .matchMedia("(prefers-color-scheme: dark)") .addEventListener("change", () => { if (savedTheme() === null) { currentTheme = systemIsDark() ? "dark" : "light"; applyTheme(); } }); /* ============================================================ * START * ============================================================ */ applyTranslations(); applyTheme(); applyBackgroundImage(); applyLogo(); updateLanguageMenu(); initialize();