1479 lines
35 KiB
JavaScript
1479 lines
35 KiB
JavaScript
"use strict";
|
||
|
||
/* ============================================================
|
||
* KONFIGURATION
|
||
* ============================================================
|
||
*/
|
||
|
||
/*
|
||
* Konfiguration liegt in assets/config.json und wird beim
|
||
* Start asynchron geladen (siehe START).
|
||
*/
|
||
let CONFIG = {};
|
||
const CONFIG_URL = "assets/config.json";
|
||
|
||
/* ============================================================
|
||
* Translations
|
||
* ============================================================
|
||
*/
|
||
|
||
/*
|
||
* Sprachen liegen in assets/i18n.json und werden beim
|
||
* Start asynchron geladen (siehe START).
|
||
*/
|
||
let TRANSLATIONS = {};
|
||
const I18N_URL = "assets/i18n.json";
|
||
|
||
/* ============================================================
|
||
* 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 = "en";
|
||
let translation = {};
|
||
|
||
/* ============================================================
|
||
* Apply translations to webpage
|
||
* ============================================================
|
||
*/
|
||
|
||
function applyTranslations() {
|
||
document.documentElement.lang = currentLanguage;
|
||
if (translation.pageTitle) {
|
||
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/images/uk.svg",
|
||
de: "assets/images/de.svg",
|
||
fr: "assets/images/fr.svg",
|
||
zh: "assets/images/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");
|
||
}
|
||
|
||
/* ============================================================
|
||
* NEXTCLOUD BUTTON
|
||
* ============================================================
|
||
*/
|
||
|
||
/*
|
||
* Nur anzeigen, wenn eine Nextcloud-Instanz konfiguriert
|
||
* ist (CONFIG.nextcloudBase in assets/config.json).
|
||
*/
|
||
function applyNextcloudButton() {
|
||
if (!CONFIG.nextcloudBase || !createNextcloudButton) {
|
||
return;
|
||
}
|
||
|
||
createNextcloudButton.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 iosDownload = document.getElementById("iosDownload");
|
||
const androidDownload = document.getElementById("androidDownload");
|
||
const otherDownload = document.getElementById("otherDownload");
|
||
const windowsLink = document.getElementById("windowsLink");
|
||
const macosLink = document.getElementById("macosLink");
|
||
const linuxLink = document.getElementById("linuxLink");
|
||
const iosLink = document.getElementById("iosLink");
|
||
const androidLink = document.getElementById("androidLink");
|
||
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 createNextcloudButton = document.getElementById('createNextcloudButton');
|
||
const createAdhocButton = document.getElementById("createAdhocButton");
|
||
const cancelCreateButton = document.getElementById("cancelCreateButton");
|
||
const icsSuccess = document.getElementById("icsSuccess");
|
||
const nextcloudStatus = document.getElementById('nextcloudStatus');
|
||
const nextcloudError = document.getElementById('nextcloudError');
|
||
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://<YOURJITSIINSTANCEADDRESS>/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"
|
||
);
|
||
}
|
||
|
||
/*
|
||
* Die Mobile-Apps (iOS, Firefox auf Android) registrieren
|
||
* "org.jitsi.meet", nicht das Desktop-Schema.
|
||
*/
|
||
const APP_SCHEME = "org.jitsi.meet://";
|
||
|
||
function createAppSchemeUrl(room) {
|
||
return (
|
||
APP_SCHEME +
|
||
CONFIG.jitsiHost +
|
||
"/" +
|
||
encodeURIComponent(room)
|
||
);
|
||
}
|
||
|
||
/* ============================================================
|
||
* URL NACH PLATTFORM/BROWSER
|
||
* ============================================================ */
|
||
|
||
function selectProtocolUrl(room) {
|
||
const os = detectOperatingSystem();
|
||
|
||
/*
|
||
* iOS-App kennt nur das App-Schema.
|
||
*/
|
||
if (os === "ios") {
|
||
return createAppSchemeUrl(room);
|
||
}
|
||
|
||
if (os !== "android") {
|
||
return createAppUrl(room);
|
||
}
|
||
|
||
return detectBrowser() === "firefox"
|
||
? createAppSchemeUrl(room)
|
||
: createAndroidIntentUrl(room);
|
||
}
|
||
|
||
/* ============================================================
|
||
* DETECT OPERATING SYSTEM
|
||
* ============================================================
|
||
*/
|
||
|
||
/*
|
||
* Einmal parsen (assets/lib/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 – Touchpunkte verraten das iPad.
|
||
* Der Plattform-Check greift auch ohne geladenen ua-parser.
|
||
*/
|
||
if (
|
||
navigator.maxTouchPoints > 1 &&
|
||
(navigator.platform === "MacIntel" || uaResult.os.name === "macOS")
|
||
) {
|
||
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");
|
||
iosDownload.classList.add("hidden");
|
||
androidDownload.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;
|
||
}
|
||
|
||
/* iOS */
|
||
if (os === "ios") {
|
||
iosLink.href = CONFIG.downloads.ios;
|
||
iosDownload.classList.remove("hidden");
|
||
return;
|
||
}
|
||
|
||
/* Android */
|
||
if (os === "android") {
|
||
androidLink.href = CONFIG.downloads.android;
|
||
androidDownload.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(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 openNextcloudCalendarCreator() {
|
||
const meetingName =
|
||
meetingNameInput.value.trim();
|
||
|
||
const description =
|
||
meetingDescriptionInput.value.trim();
|
||
|
||
const startTime =
|
||
meetingStartInput.value;
|
||
|
||
const endTime =
|
||
meetingEndInput.value;
|
||
|
||
const organizer =
|
||
meetingOrganizerInput.value.trim();
|
||
|
||
if (!meetingName) {
|
||
alert(getTranslation('creator.meetingNameRequired') || 'Please enter a meeting name.');
|
||
meetingNameInput.focus();
|
||
return;
|
||
}
|
||
|
||
if (!startTime || !endTime) {
|
||
alert(getTranslation('creator.startTimeRequired') || 'Please select a start time.');
|
||
return;
|
||
}
|
||
|
||
const start = new Date(startTime);
|
||
const end = new Date(endTime);
|
||
|
||
if (Number.isNaN(start.getTime()) ||
|
||
Number.isNaN(end.getTime())) {
|
||
alert('Invalid start or end date.');
|
||
return;
|
||
}
|
||
|
||
if (end <= start) {
|
||
alert(getTranslation('creator.endAfterStart') || 'End time must be after start time.');
|
||
return;
|
||
}
|
||
|
||
/*
|
||
* Nextcloud validiert die E-Mail serverseitig und
|
||
* bricht sonst mit einer Fehlerseite ab.
|
||
*/
|
||
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||
|
||
if (organizer && !emailPattern.test(organizer)) {
|
||
alert(getTranslation('creator.organizerInvalid') || 'Please enter a valid organizer email address.');
|
||
return;
|
||
}
|
||
|
||
/*
|
||
* Use exactly the same room-generation logic as the
|
||
* existing ICS creation.
|
||
*/
|
||
const roomName = createRoomName(meetingName);
|
||
|
||
const meetingUrl =
|
||
createLaunchUrl(roomName);
|
||
|
||
const params = new URLSearchParams();
|
||
|
||
params.set('summary', meetingName);
|
||
|
||
if (description) {
|
||
params.set('description', description);
|
||
}
|
||
|
||
params.set('start', startTime);
|
||
params.set('end', endTime);
|
||
|
||
if (organizer) {
|
||
params.set('organizer', organizer);
|
||
}
|
||
|
||
params.set('location', meetingUrl);
|
||
params.set('url', meetingUrl);
|
||
|
||
const nextcloudUrl =
|
||
CONFIG.nextcloudBase +
|
||
'/index.php/apps/jitsi_calendar/confirm?' +
|
||
params.toString();
|
||
|
||
window.location.href = nextcloudUrl;
|
||
}
|
||
|
||
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(),
|
||
touchPoints: navigator.maxTouchPoints,
|
||
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
|
||
);
|
||
|
||
if (createNextcloudButton) {
|
||
createNextcloudButton.addEventListener(
|
||
'click',
|
||
openNextcloudCalendarCreator
|
||
);
|
||
}
|
||
|
||
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
|
||
* ============================================================
|
||
*/
|
||
|
||
async function loadConfig() {
|
||
try {
|
||
const response = await fetch(CONFIG_URL);
|
||
CONFIG = await response.json();
|
||
} catch (error) {
|
||
/*
|
||
* Ohne Datei bleiben Launcher unbrauchbar – Fehler
|
||
* melden und mit leerer Konfiguration fortfahren.
|
||
*/
|
||
console.error("Error loading config:", error);
|
||
}
|
||
}
|
||
|
||
async function loadTranslations() {
|
||
try {
|
||
const response = await fetch(I18N_URL);
|
||
TRANSLATIONS = await response.json();
|
||
} catch (error) {
|
||
/*
|
||
* Ohne Datei bleibt der englische Text aus dem HTML stehen.
|
||
*/
|
||
console.error("Error loading translations:", error);
|
||
}
|
||
|
||
currentLanguage = detectLanguage();
|
||
translation = TRANSLATIONS[currentLanguage] || {};
|
||
}
|
||
|
||
/*
|
||
* Theme/Logo sofort anwenden, damit nichts aufblinkt –
|
||
* Text erst nach dem Laden der Übersetzungen.
|
||
*/
|
||
async function start() {
|
||
applyTheme();
|
||
|
||
await loadConfig();
|
||
|
||
applyBackgroundImage();
|
||
applyLogo();
|
||
applyNextcloudButton();
|
||
|
||
await loadTranslations();
|
||
|
||
applyTranslations();
|
||
updateLanguageMenu();
|
||
initialize();
|
||
}
|
||
|
||
start();
|