Files
jitsi-launch/assets/launch.js
T

1479 lines
35 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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();