Compare commits

...
19 Commits
Author SHA1 Message Date
stefan.tollkuehn ebbfa990fb Relabel creator button as ICS download
The form button generates the ICS file, so name it after
the result. Move the Nextcloud button ahead of it, matching
the visual weight of the primary actions.
2026-11-05 17:52:27 +01:00
stefan.tollkuehn cfb48aa3c9 Group help texts under a help key
meetingPrivacySettings nests one level deeper under help.
The umbrella makes room for further help topics and allows
contextualizing which help a visible view offers. All
data-i18n references follow; no logic changes.
2026-11-05 17:37:24 +01:00
stefan.tollkuehn 4df51ca508 Simplify browser permissions wording
Name only the website-permissions icon when describing
where to change camera/mic decisions. The combined
lock-and-permissions phrasing was clunky in German and
duplicated what the icon already shows. Adjust all
translations accordingly.
2026-11-05 17:33:17 +01:00
stefan.tollkuehn c06127ef2b Document i18n markup and help overlay
Add the missing legalNotice parameter to the config table.
Describe how translations resolve keys, which markup the
renderer accepts, and how the OS-keyed help dialog works.
2026-11-05 17:29:31 +01:00
stefan.tollkuehn da2f6b5c25 Move Windows camera help into overlay
The privacy instructions no longer bloat the launcher card.
A "Help" button in the navbar opens them in a native dialog
(Esc, backdrop and button close it). The button only shows
for Windows users, reusing the existing OS detection.

Translations may contain <kbd>, <b> and <i> markup now. The
renderer builds nodes from a tag whitelist instead of using
innerHTML, so smuggled tags from i18n.json cannot execute.

All help texts live under meetingPrivacySettings.windows;
sibling keys for other operating systems can follow.
2026-11-05 17:24:15 +01:00
stefan.tollkuehn 7961732323 Show Windows privacy hints in launcher
The launcher sentence around the room name was replaced
by a standalone instruction ("meetingText"); the room name
now sits on its own line below it.

Windows users additionally get camera/microphone
permission instructions in the launcher view. The block
reuses the existing i18n/OS detection and only appears
when translations loaded, so a failed i18n.json fetch
cannot render an empty box.
2026-11-05 16:36:43 +01:00
stefan.tollkuehn f746508d90 fix title of AGENTS.md 2026-11-05 15:59:53 +01:00
stefan.tollkuehn 217a076120 Make legal notice link configurable
The legal notice link in the navbar was hardcoded to the
bnvb website. Deployments without that legal notice showed
a dead link to a foreign site.

Move the URL into assets/config.json (legalNotice), like
the Nextcloud and adhoc buttons. The link stays hidden
unless the entry is configured.
2026-11-03 11:09:22 +01:00
stefan.tollkuehn 49a3d90dc5 Scope localStorage keys per app directory
localStorage is scoped per origin, not per path. Multiple
jitsi-launch instances on one origin (e.g. meet.bnvb.eu/i
and /p) therefore shared one language and one theme
setting, and changing it in one instance changed it in
all others.

Prefix the language and theme keys with the app directory
derived from the page path. Each instance keeps its own
state; a root deployment gets the "/" prefix and behaves
as before. Previously saved choices reset once because
the key names change.
2026-11-03 11:06:50 +01:00
stefan.tollkuehn 0e4ba86c38 Make favicon paths configurable
The favicons were hardcoded in launch.html, so
rebranding the page required editing the template.

The paths now come from the favicons block in
assets/config.json. Missing keys keep the defaults
from launch.html.
2026-11-02 15:48:19 +01:00
stefan.tollkuehn 9184f60461 cleanup and htaccess fix 2026-11-02 15:09:00 +01:00
stefan.tollkuehn 64c49d210a update README 2026-11-02 15:02:09 +01:00
stefan.tollkuehn 1e3c7d676c rename config.json to config.json.sample and ignore config.json in git repository 2026-11-02 15:00:48 +01:00
stefan.tollkuehn c9accc2111 fix url for nextcloud 2026-10-22 17:23:56 +02:00
stefan.tollkuehn ce80e52923 - fix escaping in ICS generation
- add CRLF line endings in ICS
- add line folding in ICS
- fix downloadICS()
2026-10-22 17:09:16 +02:00
stefan.tollkuehn 86caf9a3a4 use relative urls, remove shortlink 2026-10-22 16:54:26 +02:00
stefan.tollkuehn 2c5e95ce7f translated comments in css 2026-10-22 16:45:24 +02:00
stefan.tollkuehn 0891a60beb .container should not be partially hidden behind .navbar 2026-10-22 16:44:01 +02:00
stefan.tollkuehn 3267b6a6a3 translation of comments and some other german terms 2026-10-22 16:34:01 +02:00
9 changed files with 657 additions and 219 deletions

No files matched your search

+1
View File
@@ -0,0 +1 @@
assets/config.json
+1 -5
View File
@@ -2,9 +2,5 @@ DirectoryIndex launch.html
# Hide repository meta files behind 404
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule ^(README\.md|LICENSE|AGENTS\.md)$ - [NC,R=404,L]
# security.txt: redirect to central security contact
RewriteRule ^\.well-known/security\.txt$ https://www.bnvb-ingenieure.de/.well-known/security.txt [NC,R=301,L]
RewriteRule ^(README\.md|LICENSE|AGENTS\.md|.gitignore|.htaccess)$ - [NC,R=404,L]
</IfModule>
+1 -1
View File
@@ -1,4 +1,4 @@
# AGENT.MD
# AGENTS.MD
- When writing something intended for human consumption, (comment, commit message, reply to prompt) use as few words as possible. Pick every word meticulously to reduce the volume to a strict minimum. Be down to the point. Less is more.
+26
View File
@@ -7,17 +7,43 @@ A small html/js project to nudge a user to use the desktop client for Jitsi.
All settings live in `assets/config.json`, loaded at startup. The launcher stays
up but links break if the file is missing or malformed.
Rename `assets/config.json.sample` to `assets/config.json`.
| Parameter | Description |
| --- | --- |
| `jitsiLauncher` | Domain of this Jitsi launcher. Used for the meeting URLs created by the meeting creator (Nextcloud, adhoc link). |
| `jitsiHost` | Jitsi domain. Used for the browser fallback URL, the desktop protocol URL and the UID of generated ICS files. |
| `nextcloudBase` | Nextcloud instance for calendar event creation. The "Create in Nextcloud Calendar" button is only shown if this is set. |
| `legalNotice` | URL of the "Legal Notice" link in the navbar. The link stays hidden if unset. |
| `allowAdhocMeetings` | Whether adhoc meetings are allowed. The "Create Adhoc Link" button is only shown if this is `true`. |
| `launchTimeout` | Timeout in ms before the browser fallback is shown after trying to start the jitsi-meet-electron client. |
| `backgroundImage` | Path to the page background image. Empty: no background. |
| `logo` | Path to the logo shown in the logo bar. Empty: logo bar stays hidden. |
| `favicons` | Paths to the page icons: `ico` (fallback icon), `svg` (icon), `appleTouch` (Apple touch icon). The values from `launch.html` are kept if a key is missing. |
| `downloads` | Download URLs for the jitsi-meet clients, shown in the fallback view per detected OS: `windows`, `macos`, `linux`, `ios`, `android`. |
## Translations
All texts live in `assets/i18n.json`, one object per language
(`en`, `de`, `fr`, `zh`). Elements carry the key in `data-i18n`
(dots descend into nested objects), inputs in `data-i18n-placeholder`.
Translations may contain `<kbd>`, `<b>` and `<i>` markup (no
nesting). The renderer builds only these whitelisted elements;
other tags render literally and never execute.
## Help overlay
The "Help" button in the navbar opens a dialog with
camera/microphone hints. It only shows on the detected OS.
All help texts live under `help` in `assets/i18n.json`,
grouped by topic; the overlay uses
`help.meetingPrivacySettings`, keyed by OS (`windows`;
`macos`, `linux`, `ios` and `android` can follow). Further
topics can extend `help` and be contextualized per visible
view. The dialog width is defined in `dialog.privacy`
(`assets/css/launch.css`).
## Third-party assets
- `assets/lib/ua-parser.min.js` — [ua-parser-js](https://github.com/faisalman/ua-parser-js) v2.0.10, © Faisal Salman, licensed under AGPL-3.0.
@@ -1,11 +1,17 @@
{
"jitsiLauncher": "meet.bnvb.eu",
"jitsiHost": "meet.bnvb-ingenieure.de",
"nextcloudBase": "https://cloud.bnvb-ingenieure.de",
"jitsiLauncher": "server.domain.tld/subdir",
"jitsiHost": "jit.si",
"nextcloudBase": "https://cloud.domain.tld",
"legalNotice": "https://www.domain.tld/legal-notice",
"allowAdhocMeetings": true,
"launchTimeout": 2000,
"backgroundImage": "assets/images/background.png",
"logo": "assets/images/logo.svg",
"backgroundImage": "path/to/backgroundimage",
"logo": "path/to/logo",
"favicons": {
"ico": "path/to/favicon.ico",
"svg": "path/to/favicon.svg",
"appleTouch": "path/to/apple-touch-icon.png"
},
"downloads": {
"windows": "https://github.com/jitsi/jitsi-meet-electron/releases/latest/download/jitsi-meet.exe",
"macos": "https://github.com/jitsi/jitsi-meet-electron/releases/latest/download/jitsi-meet.dmg",
+86 -26
View File
@@ -17,11 +17,16 @@ body {
color: #222;
}
/*
* The fixed navbar overlaps the top of the card – keep
* 80px of clearance (all modes, including portrait).
*/
.container {
display: flex;
flex-direction: column;
align-items: center;
width: min(700px, calc(100% - 32px));
padding-top: 80px;
}
.navbar {
@@ -65,14 +70,14 @@ body {
margin-left: auto;
}
/* Hamburger: nur im Phone-Portrait sichtbar.
* "button.nav-toggle" ist spezifischer als ".button",
* sonst überschreibt display: inline-block diese Regel. */
/* Hamburger: only visible on phone portrait.
* "button.nav-toggle" is more specific than ".button",
* otherwise display: inline-block overrides this rule. */
button.nav-toggle {
display: none;
}
/* Enger als der globale 6px-Margin der Buttons */
/* Tighter than the global 6px button margin */
.navbar .button {
margin: 0;
}
@@ -201,11 +206,46 @@ button, .button {
background: #f5f5f5;
}
/* Windows help overlay – long text needs left
* alignment, the page is centered */
dialog.privacy {
width: min(1000px, calc(100% - 32px));
max-height: calc(100vh - 48px);
border: 0;
border-radius: 14px;
background: #f5f5f5;
padding: 20px;
text-align: left;
overflow-y: auto;
}
dialog.privacy::backdrop {
background: rgba(0, 0, 0, 0.5);
}
.help-actions {
margin-top: 24px;
text-align: center;
}
.room {
display: block;
margin-top: 20px;
font-family: monospace;
word-break: break-all;
}
/* Keyboard keys, marked with <kbd> in i18n.json */
kbd {
padding: 1px 6px;
border: 1px solid #ccc;
border-bottom-width: 3px;
border-radius: 4px;
background: #f0f0f0;
font-family: monospace;
font-size: 0.9em;
}
.error {
padding: 16px;
border-radius: 8px;
@@ -264,6 +304,16 @@ button, .button {
color: #1a7f37;
}
/*
* scrollIntoView() aligns the target with the viewport
* edge, where the fixed navbar sits. scroll-margin-top
* keeps the target clear below the navbar.
*/
.adhoc-result,
.success-message {
scroll-margin-top: 110px;
}
.adhoc-link {
margin-top: 12px;
display: flex;
@@ -280,8 +330,8 @@ button, .button {
}
/*
* Verhindert abgeschnittene Inhalte, wenn die Karte
* höher als der Viewport ist ("center" schneidet oben ab).
* Prevents clipped content when the card is taller
* than the viewport ("center" clips at the top).
*/
@supports (align-items: safe center) {
body {
@@ -289,7 +339,7 @@ button, .button {
}
}
/* Sehr schmale Telefone: noch kompakter */
/* Very narrow phones: even more compact */
@media (max-width: 400px) {
.navbar {
left: 2%;
@@ -313,25 +363,23 @@ button, .button {
}
/*
* Phone: Navbar-Aktionen in beiden Ausrichtungen
* hinter dem Hamburger-Button als Dropdown anzeigen.
* Phone: show the navbar actions in both orientations
* as a dropdown behind the hamburger button.
*
* Landscape-Phone: Viewport breiter als 600px – kurze
* Seite (Höhe) und Touch-Eingabe entscheiden.
* Landscape phone: viewport wider than 600px – short
* side (height) and touch input decide.
*/
@media (max-width: 600px),
(orientation: landscape) and (max-height: 500px) and (pointer: coarse) {
body {
/* Navbar ist hier einzeilig */
padding-top: 80px;
/*
* Padding statt margin-bottom an der Karte:
* Ränder am Scroll-Ende werden vom Scrollbereich
* nicht erfasst, Padding schon.
* Padding instead of margin-bottom on the card:
* margins at the scroll end are not covered by the
* scroll area, padding is.
*/
padding-bottom: 10px;
/* 80px Navbar + 10px unten abziehen */
min-height: calc(100vh - 90px);
/* Subtract the 10px bottom padding */
min-height: calc(100vh - 10px);
}
.navbar {
@@ -354,12 +402,18 @@ button, .button {
padding: 20px;
}
/* Navbar is flatter here than on the desktop */
.adhoc-result,
.success-message {
scroll-margin-top: 80px;
}
button.nav-toggle {
display: inline-flex;
}
/* ".navbar .button" setzt margin: 0 – deshalb
* höher spezifisch ans Ziel: Button nach rechts */
/* ".navbar .button" sets margin: 0 – hence target
* more specifically: push the button to the right */
.navbar .nav-toggle {
margin-left: auto;
}
@@ -383,12 +437,12 @@ button, .button {
display: flex;
}
/* Alle Panel-Buttons gleich breit, Text zentriert */
/* All panel buttons equally wide, text centered */
.nav-actions .button {
text-align: center;
}
/* Sprach-Button liegt im .lang-menu-Wrapper */
/* Language button sits inside the .lang-menu wrapper */
.nav-actions .lang-menu .button {
display: block;
width: 100%;
@@ -407,9 +461,9 @@ button, .button {
}
/*
* Dark-Theme über die Klasse "dark" am html-Element.
* JS setzt sie anhand der gespeicherten Wahl oder der
* System-Einstellung (siehe detectTheme).
* Dark theme via the "dark" class on the html element.
* JS sets it based on the saved choice or the system
* setting (see detectTheme).
*/
:root.dark body {
background: #181818;
@@ -440,13 +494,19 @@ button, .button {
color: #bbb;
}
:root.dark kbd {
border-color: #555;
background: #404040;
}
:root.dark .secondary {
background: #444;
color: #eee;
}
:root.dark .status,
:root.dark .download {
:root.dark .download,
:root.dark .privacy {
background: #303030;
}
+100 -16
View File
@@ -1,12 +1,11 @@
{
"en": {
"pageTitle": "Jitsi Meeting",
"impressum": "Legal Notice",
"legal_notice": "Legal Notice",
"source": "Source",
"launcher": {
"title": "Jitsi Meeting",
"meetingPrefix": "The meeting",
"meetingSuffix": "will be joined.",
"meetingText": "Please click the button, to join the meeting:",
"openButton": "Join meeting"
},
"starting": {
@@ -70,7 +69,7 @@
"meetingStartLabel": "Start Date & Time",
"meetingEndLabel": "End Date & Time",
"meetingOrganizerLabel": "Organizer Email",
"createButton": "Create Meeting",
"createButton": "Download ICS File",
"cancelButton": "Cancel",
"successMessage": "Meeting created! Download your calendar file and open it to import the meeting into your personal calendar.",
"downloadButton": "Download ICS File",
@@ -87,16 +86,37 @@
"organizerRequired": "Please enter an organizer email",
"organizerInvalid": "Please enter a valid organizer email address",
"nextcloudButton": "Create in Nextcloud Calendar"
},
"help": {
"meetingPrivacySettings": {
"windows": {
"title": "Camera and microphone in Windows: How to control your privacy",
"intro": "Your camera and microphone are sensitive devices. Windows therefore ensures that they can only be used with your permission. A few steps give you full control, especially when you use video calls or voice messages directly in your web browser.",
"step1Title": "Step 1: Open the Windows settings",
"step1Description": "Open the <b>Windows Settings</b> (<kbd>⊞ Windows</kbd> + <kbd>I</kbd>). There, select the area <b>Privacy & security</b> and then <b>Camera</b> or <b>Microphone</b>.",
"step2Title": "Step 2: Regulate overall access",
"step2Description": "At the top of the page you will find the central switch for camera and microphone access. If it is disabled, no application and no website can access the device. If it is enabled, you decide on the same page whether applications are allowed access in principle.",
"step3Title": "Step 3: Check website permissions in the browser",
"step3Description": "Even if Windows allows access, your browser asks each website individually whether the camera or microphone may be used. This is the most important protection in everyday use: only when you agree does the access actually become possible.",
"browserPermissions": "You can change or revoke these decisions at any time. To do so, click the website-permissions icon in your browser's address bar and select the option for site settings. There you can allow the camera and microphone for the respective site, ask each time, or block them permanently.",
"goodToKnowTitle": "Good to know",
"goodToKnow1": "You can revoke permissions for individual websites at any time without changing Windows settings.",
"goodToKnow2": "An active microphone or camera is indicated in Windows by a small camera or microphone icon in the taskbar. This way you immediately recognize when a site is accessing a device.",
"goodToKnow3": "If a website does not get access despite working hardware, the cause is often not the device itself, but one of the two permission levels, in Windows or in the browser.",
"adminSupport": "If you need support finding the right settings, please ask <b>your administrator</b>.",
"navButton": "Help",
"closeButton": "Close"
}
}
}
},
"de": {
"pageTitle": "Jitsi Meeting öffnen",
"impressum": "Impressum",
"legal_notice": "Impressum",
"source": "Quellcode",
"launcher": {
"title": "Jitsi Meeting",
"meetingPrefix": "Das Meeting",
"meetingSuffix": "wird geöffnet.",
"meetingText": "Bitte klicken Sie auf die Schaltfläche, um an dem Meeting teilzunehmen:",
"openButton": "An Meeting teilnehmen"
},
"starting": {
@@ -160,7 +180,7 @@
"meetingStartLabel": "Start Datum & Uhrzeit",
"meetingEndLabel": "Ende Datum & Uhrzeit",
"meetingOrganizerLabel": "Organisator E-Mail",
"createButton": "Meeting erstellen",
"createButton": "ICS-Datei herunterladen",
"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",
@@ -177,16 +197,37 @@
"organizerRequired": "Bitte geben Sie eine Organisator-E-Mail ein",
"organizerInvalid": "Bitte geben Sie eine gültige Organisator-E-Mail-Adresse ein",
"nextcloudButton": "Im Nextcloud-Kalender erstellen"
},
"help": {
"meetingPrivacySettings": {
"windows": {
"title": "Kamera und Mikrofon in Windows: So kontrollieren Sie Ihre Privatsphäre",
"intro": "Ihre Kamera und Ihr Mikrofon sind sensible Geräte. Windows sorgt dafür, dass sie nur mit Ihrer Erlaubnis genutzt werden können. Mit wenigen Schritten behalten Sie die volle Kontrolle, insbesondere wenn Sie Videotelefonie oder Sprachnachrichten direkt im Webbrowser nutzen.",
"step1Title": "Schritt 1: Windows-Einstellungen öffnen",
"step1Description": "Öffnen Sie die <b>Windows-Einstellungen</b> (<kbd>⊞ Windows</kbd> + <kbd>I</kbd>). Wählen Sie dort den Bereich <b>Datenschutz und Sicherheit</b> und anschließend <b>Kamera</b> oder <b>Mikrofon</b>.",
"step2Title": "Schritt 2: Gesamtzugriff regeln",
"step2Description": "Oben auf der Seite finden Sie den zentralen Schalter für den Kamera- bzw. Mikrofonzugriff. Ist er deaktiviert, kann keine Anwendung und keine Website auf das Gerät zugreifen. Ist er aktiviert, entscheiden Sie auf der gleichen Seite, ob Anwendungen den Zugriff grundsätzlich erhalten dürfen.",
"step3Title": "Schritt 3: Website-Berechtigungen im Browser prüfen",
"step3Description": "Auch wenn Windows den Zugriff erlaubt, fragt Ihr Browser bei jeder Website einzeln nach, ob Kamera oder Mikrofon genutzt werden dürfen. Das ist der wichtigste Schutz im Alltag: Erst wenn Sie zustimmen, wird der Zugriff tatsächlich möglich.",
"browserPermissions": "Sie können diese Entscheidungen jederzeit ändern oder widerrufen. Klicken Sie dazu in der Adressleiste Ihres Browsers auf das Symbol für die Website-Berechtigungen und wählen Sie die Option für Website-Einstellungen. Dort lassen sich Kamera und Mikrofon für die jeweilige Seite gezielt erlauben, einmalig anfragen oder dauerhaft blockieren.",
"goodToKnowTitle": "Gut zu wissen",
"goodToKnow1": "Sie können Berechtigungen für einzelne Websites jederzeit zurückziehen, ohne Windows-Einstellungen zu ändern.",
"goodToKnow2": "Ein aktives Mikrofon oder eine aktive Kamera wird unter Windows durch ein kleines Kamera- oder Mikrofonsymbol in der Taskleiste angezeigt. So erkennen Sie sofort, wenn eine Seite auf ein Gerät zugreift.",
"goodToKnow3": "Wenn eine Website trotz funktionierender Hardware keinen Zugriff erhält, liegt die Ursache häufig nicht am Gerät selbst, sondern an einer der beiden Berechtigungsebenen, in Windows oder im Browser.",
"adminSupport": "Sollten Sie Unterstützung dabei benötigen, die passenden Einstellungen zu finden, fragen Sie bitte <b>Ihren Administrator</b>.",
"navButton": "Hilfe",
"closeButton": "Schließen"
}
}
}
},
"fr": {
"pageTitle": "Réunion Jitsi",
"impressum": "Mentions légales",
"legal_notice": "Mentions légales",
"source": "Code source",
"launcher": {
"title": "Réunion Jitsi",
"meetingPrefix": "La réunion",
"meetingSuffix": "va être ouverte.",
"meetingText": "Veuillez cliquer sur le bouton pour rejoindre la réunion :",
"openButton": "Rejoindre la réunion"
},
"starting": {
@@ -250,7 +291,7 @@
"meetingStartLabel": "Date et heure de début",
"meetingEndLabel": "Date et heure de fin",
"meetingOrganizerLabel": "Email de l'organisateur",
"createButton": "Créer la réunion",
"createButton": "Télécharger le fichier ICS",
"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",
@@ -267,16 +308,37 @@
"organizerRequired": "Veuillez entrer un email d'organisateur",
"organizerInvalid": "Veuillez entrer une adresse email d'organisateur valide",
"nextcloudButton": "Créer dans le calendrier Nextcloud"
},
"help": {
"meetingPrivacySettings": {
"windows": {
"title": "Caméra et microphone sous Windows : comment contrôler votre vie privée",
"intro": "Votre caméra et votre microphone sont des appareils sensibles. Windows veille donc à ce qu'ils ne puissent être utilisés qu'avec votre autorisation. En quelques étapes, vous gardez le contrôle total, surtout lorsque vous utilisez la visioconférence ou les messages vocaux directement dans votre navigateur.",
"step1Title": "Étape 1 : Ouvrir les paramètres Windows",
"step1Description": "Ouvrez les <b>paramètres Windows</b> (<kbd>⊞ Windows</kbd> + <kbd>I</kbd>). Sélectionnez ensuite la section <b>Confidentialité et sécurité</b>, puis <b>Caméra</b> ou <b>Micro</b>.",
"step2Title": "Étape 2 : Régler l'accès global",
"step2Description": "En haut de la page se trouve l'interrupteur central de l'accès à la caméra ou au microphone. S'il est désactivé, aucune application ni aucun site web ne peut accéder à l'appareil. S'il est activé, vous décidez sur la même page si les applications peuvent y accéder en principe.",
"step3Title": "Étape 3 : Vérifier les autorisations du site dans le navigateur",
"step3Description": "Même si Windows autorise l'accès, votre navigateur demande site par site si la caméra ou le microphone peuvent être utilisés. C'est la protection la plus importante au quotidien : seul votre consentement rend l'accès réellement possible.",
"browserPermissions": "Vous pouvez modifier ou révoquer ces décisions à tout moment. Pour cela, cliquez dans la barre d'adresse de votre navigateur sur l'icône des autorisations du site, puis choisissez l'option des paramètres du site. Vous y pouvez autoriser, demander à chaque fois ou bloquer définitivement la caméra et le microphone pour le site concerné.",
"goodToKnowTitle": "Bon à savoir",
"goodToKnow1": "Vous pouvez retirer à tout moment les autorisations de sites individuels sans modifier les paramètres Windows.",
"goodToKnow2": "Un microphone ou une caméra active est signalé sous Windows par une petite icône de caméra ou de microphone dans la barre des tâches. Vous reconnaissez ainsi immédiatement quand un site accède à un appareil.",
"goodToKnow3": "Si un site n'obtient pas l'accès malgré un matériel fonctionnel, la cause n'est souvent pas l'appareil lui-même, mais l'un des deux niveaux d'autorisation, sous Windows ou dans le navigateur.",
"adminSupport": "Si vous avez besoin d'aide pour trouver les bons réglages, veuillez demander à <b>votre administrateur</b>.",
"navButton": "Aide",
"closeButton": "Fermer"
}
}
}
},
"zh": {
"pageTitle": "Jitsi 会议",
"impressum": "法律声明",
"legal_notice": "法律声明",
"source": "源代码",
"launcher": {
"title": "Jitsi 会议",
"meetingPrefix": "会议",
"meetingSuffix": "即将打开。",
"meetingText": "请点击按钮加入会议:",
"openButton": "加入会议"
},
"starting": {
@@ -340,7 +402,7 @@
"meetingStartLabel": "开始日期和时间",
"meetingEndLabel": "结束日期和时间",
"meetingOrganizerLabel": "组织者邮箱",
"createButton": "创建会议",
"createButton": "下载 ICS 文件",
"cancelButton": "取消",
"successMessage": "会议已创建!请下载您的日历文件并打开它,将会议导入您的个人日历。",
"downloadButton": "下载 ICS 文件",
@@ -357,6 +419,28 @@
"organizerRequired": "请输入组织者邮箱",
"organizerInvalid": "请输入有效的组织者邮箱地址",
"nextcloudButton": "在 Nextcloud 日历中创建"
},
"help": {
"meetingPrivacySettings": {
"windows": {
"title": "Windows 中的摄像头和麦克风:如何掌控您的隐私",
"intro": "摄像头和麦克风是敏感设备。因此,Windows 确保只有获得您的许可才能使用它们。只需几个步骤,您就能完全掌控,尤其是在浏览器中直接使用视频通话或语音消息时。",
"step1Title": "步骤 1:打开 Windows 设置",
"step1Description": "打开 <b>Windows 设置</b>(<kbd>⊞ Windows</kbd> + <kbd>I</kbd>)。选择<b>隐私和安全性</b>,然后选择<b>摄像头</b>或<b>麦克风</b>。",
"step2Title": "步骤 2:管理总体访问权限",
"step2Description": "页面顶部有控制摄像头或麦克风访问的总开关。如果关闭,任何应用和网站都无法访问设备。如果开启,您可以在同一页面决定是否允许应用访问。",
"step3Title": "步骤 3:检查浏览器中的网站权限",
"step3Description": "即使 Windows 允许访问,浏览器也会逐个询问每个网站是否可以使用摄像头或麦克风。这是日常使用中最重要的保护:只有您同意后,访问才会真正生效。",
"browserPermissions": "您可以随时更改或撤销这些决定。为此,请点击浏览器地址栏中的网站权限图标,并选择网站设置选项。在那里可以为相应网站专门允许、每次询问或永久阻止摄像头和麦克风。",
"goodToKnowTitle": "实用提示",
"goodToKnow1": "您可以随时撤销单个网站的权限,而无需更改 Windows 设置。",
"goodToKnow2": "在 Windows 中,使用中的麦克风或摄像头会通过任务栏中的小摄像头或麦克风图标显示。这样您可以立即发现某个网站正在访问设备。",
"goodToKnow3": "如果网站在硬件正常的情况下仍无法获得访问权限,原因往往不在设备本身,而在两个权限层级之一:Windows 或浏览器。",
"adminSupport": "如果您需要帮助查找相应的设置,请咨询<b>您的管理员</b>。",
"navButton": "帮助",
"closeButton": "关闭"
}
}
}
}
}
+384 -151
View File
@@ -1,25 +1,39 @@
"use strict";
/* ============================================================
* KONFIGURATION
* CONFIGURATION
* ============================================================
*/
/*
* Konfiguration liegt in assets/config.json und wird beim
* Start asynchron geladen (siehe START).
* Configuration lives in assets/config.json and is loaded
* asynchronously at startup (see START).
*/
let CONFIG = {};
const CONFIG_URL = "assets/config.json";
/*
* localStorage is scoped per origin, not per path.
* Prefix keys with the app directory so parallel
* instances (e.g. meet.bnvb.eu/i and /p) stay
* independent.
*
* "/p/launch.html" becomes "/p/"
*/
const STORAGE_PREFIX =
window.location.pathname.replace(/[^/]*$/, "");
const LANGUAGE_KEY = STORAGE_PREFIX + "jitsiLauncherLanguage";
const THEME_KEY = STORAGE_PREFIX + "jitsiLauncherTheme";
/* ============================================================
* Translations
* ============================================================
*/
/*
* Sprachen liegen in assets/i18n.json und werden beim
* Start asynchron geladen (siehe START).
* Translations live in assets/i18n.json and are loaded
* asynchronously at startup (see START).
*/
let TRANSLATIONS = {};
const I18N_URL = "assets/i18n.json";
@@ -31,17 +45,17 @@ const I18N_URL = "assets/i18n.json";
function detectLanguage() {
/*
* Gespeicherte Sprachwahl hat Vorrang.
* Saved language choice takes precedence.
*/
try {
const saved = localStorage.getItem("jitsiLauncherLanguage");
const saved = localStorage.getItem(LANGUAGE_KEY);
if (saved && TRANSLATIONS[saved]) {
return saved;
}
} catch (error) {
/*
* localStorage kann z.B. im Privat-Modus blockiert sein.
* localStorage can be blocked, e.g. in private mode.
*/
}
@@ -62,7 +76,7 @@ function detectLanguage() {
}
/*
* Englisch ist der Standard/Fallback.
* English is the default/fallback.
*/
return "en";
}
@@ -75,6 +89,46 @@ let translation = {};
* ============================================================
*/
/*
* "<b>", "<i>" and "<kbd>" are the only markup allowed
* in translations (no nesting). Hand-built nodes instead
* of innerHTML: a smuggled tag from i18n.json can never
* execute.
*
* "Settings (<kbd>I</kbd>)" becomes
*
* Settings ("I")[text](kbd)
*/
const MARKUP_PATTERN = /<(b|i|kbd)>(.*?)<\/\1>/g;
function renderTranslation(element, text) {
element.replaceChildren();
let lastIndex = 0;
for (const match of text.matchAll(MARKUP_PATTERN)) {
/*
* The pattern restricts the tag to the whitelist,
* so createElement() cannot create foreign elements.
*/
element.append(
document.createTextNode(
text.slice(lastIndex, match.index)
),
Object.assign(
document.createElement(match[1]),
{ textContent: match[2] }
)
);
lastIndex = match.index + match[0].length;
}
element.append(
document.createTextNode(text.slice(lastIndex))
);
}
function applyTranslations() {
document.documentElement.lang = currentLanguage;
if (translation.pageTitle) {
@@ -88,12 +142,12 @@ function applyTranslations() {
const value = getTranslation(key);
if (value !== null) {
element.textContent = value;
renderTranslation(element, value);
}
});
/*
* Platzhalter von Eingabefeldern übersetzen.
* Translate placeholders of input fields.
*/
document
.querySelectorAll("[data-i18n-placeholder]")
@@ -107,13 +161,13 @@ function applyTranslations() {
}
/*
* Übersetzung anhand eines Pfades lesen.
* Read a translation by path.
*
* Beispiel:
* Example:
*
* "downloads.windows.title"
*
* ergibt:
* resolves to:
*
* translation.downloads.windows.title
*/
@@ -179,10 +233,10 @@ function setLanguage(language) {
translation = TRANSLATIONS[language];
try {
localStorage.setItem("jitsiLauncherLanguage", language);
localStorage.setItem(LANGUAGE_KEY, language);
} catch (error) {
/*
* localStorage kann z.B. im Privat-Modus blockiert sein.
* localStorage can be blocked, e.g. in private mode.
*/
}
@@ -225,8 +279,6 @@ function closeNavMenu() {
* ============================================================
*/
const THEME_KEY = "jitsiLauncherTheme";
function savedTheme() {
try {
const saved = localStorage.getItem(THEME_KEY);
@@ -236,7 +288,7 @@ function savedTheme() {
}
} catch (error) {
/*
* localStorage kann z.B. im Privat-Modus blockiert sein.
* localStorage can be blocked, e.g. in private mode.
*/
}
@@ -249,7 +301,7 @@ function systemIsDark() {
function detectTheme() {
/*
* Gespeicherte Wahl hat Vorrang, sonst System-Einstellung.
* Saved choice takes precedence, otherwise system setting.
*/
return savedTheme() || (systemIsDark() ? "dark" : "light");
}
@@ -273,7 +325,7 @@ function toggleTheme() {
localStorage.setItem(THEME_KEY, currentTheme);
} catch (error) {
/*
* localStorage kann z.B. im Privat-Modus blockiert sein.
* localStorage can be blocked, e.g. in private mode.
*/
}
@@ -309,14 +361,41 @@ function applyLogo() {
logoBar.classList.remove("hidden");
}
/* ============================================================
* FAVICONS
* ============================================================
*/
/*
* Overwrite the default icons from launch.html
* (CONFIG.favicons in assets/config.json).
*/
function applyFavicons() {
if (!CONFIG.favicons) {
return;
}
if (CONFIG.favicons.ico) {
faviconIco.href = CONFIG.favicons.ico;
}
if (CONFIG.favicons.svg) {
faviconSvg.href = CONFIG.favicons.svg;
}
if (CONFIG.favicons.appleTouch) {
appleTouchIcon.href = CONFIG.favicons.appleTouch;
}
}
/* ============================================================
* NEXTCLOUD BUTTON
* ============================================================
*/
/*
* Nur anzeigen, wenn eine Nextcloud-Instanz konfiguriert
* ist (CONFIG.nextcloudBase in assets/config.json).
* Only show if a Nextcloud instance is configured
* (CONFIG.nextcloudBase in assets/config.json).
*/
function applyNextcloudButton() {
if (!CONFIG.nextcloudBase || !createNextcloudButton) {
@@ -332,7 +411,7 @@ function applyNextcloudButton() {
*/
/*
* Nur anzeigen, wenn explizit aktiviert
* Only show if explicitly enabled
* (CONFIG.allowAdhocMeetings in assets/config.json).
*/
function applyAdhocButton() {
@@ -343,6 +422,39 @@ function applyAdhocButton() {
createAdhocButton.classList.remove("hidden");
}
/* ============================================================
* LEGAL NOTICE LINK
* ============================================================ */
/*
* Only show if a legal notice URL is configured
* (CONFIG.legalNotice in assets/config.json).
*/
function applyLegalNoticeLink() {
if (!CONFIG.legalNotice || !legalNoticeLink) {
return;
}
legalNoticeLink.href = CONFIG.legalNotice;
legalNoticeLink.classList.remove("hidden");
}
/* ============================================================
* WINDOWS HELP BUTTON
* ============================================================ */
/*
* The camera/microphone help (opens windowsHelpOverlay)
* only concerns Windows users.
*/
function applyWindowsHelpButton() {
if (detectOperatingSystem() !== OS_NAMES.Windows) {
return;
}
windowsHelpButton.classList.remove("hidden");
}
/* ============================================================
* DOM elements
* ============================================================
@@ -393,6 +505,8 @@ const copyAdhocLinkButton = document.getElementById("copyAdhocLinkButton");
const navbar = document.querySelector(".navbar");
const navToggle = document.getElementById("navToggle");
const navActions = document.getElementById("navActions");
const legalNoticeLink = document.getElementById("legalNoticeLink");
const windowsHelpButton = document.getElementById("windowsHelpButton");
/* Language menu DOM elements */
const langMenu = document.getElementById("langMenu");
@@ -412,20 +526,29 @@ const invalidRoomName = document.getElementById("invalidRoomName");
const logoBar = document.getElementById("logoBar");
const logoImage = document.getElementById("logoImage");
/* Favicon DOM elements */
const faviconIco = document.getElementById("faviconIco");
const faviconSvg = document.getElementById("faviconSvg");
const appleTouchIcon = document.getElementById("appleTouchIcon");
/* Theme DOM elements */
const themeButton = document.getElementById("themeButton");
const themeIconSun = document.getElementById("themeIconSun");
const themeIconMoon = document.getElementById("themeIconMoon");
/* Windows help overlay DOM elements */
const windowsHelpOverlay = document.getElementById("windowsHelpOverlay");
const windowsHelpClose = document.getElementById("windowsHelpClose");
/* ============================================================
* GET ROOM FROM QUERY-PARAMETER
* ============================================================
*
* Beispiel:
* Example:
*
* https://<YOURJITSIINSTANCEADDRESS>/launch.html?room=abc-defg-hij
*
* ergibt:
* results in:
*
* abc-defg-hij
*
@@ -438,16 +561,16 @@ function getRoom() {
);
/*
* Parameter nicht vorhanden.
* Parameter missing.
*/
if (!params.has("room")) {
return null;
}
/*
* Leerzeichen etc. verhindern.
* Prevent whitespace etc.
*
* "?room=" ohne Wert ergibt einen leeren String.
* "?room=" without a value yields an empty string.
*/
return params.get("room").trim();
}
@@ -456,11 +579,11 @@ function getRoom() {
* VALIDATE ROOM
* ============================================================
*
* Jitsi-Roomnamen können je nach Konfiguration relativ
* unterschiedliche Zeichen enthalten.
* Jitsi room names can contain rather different
* characters depending on the configuration.
*
* Wir verhindern hier lediglich Steuerzeichen und
* offensichtliche URL-Manipulation.
* We only prevent control characters and obvious
* URL manipulation here.
*
* ============================================================
*/
@@ -473,7 +596,7 @@ function isValidRoom(room) {
}
/*
* Nur Buchstaben, Zahlen und Bindestriche.
* Only letters, numbers and hyphens.
*/
return ROOM_PATTERN.test(room);
}
@@ -511,10 +634,10 @@ function createAppUrl(room) {
* ============================================================ */
/*
* 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.
* The Android app registers "org.jitsi.meet", not the
* desktop scheme. Chrome & co. require intent://; without
* the app Chrome offers the Play Store entry from "package=".
* Firefox ignores intent:// but knows the custom scheme.
*/
function createAndroidIntentUrl(room) {
@@ -528,8 +651,8 @@ function createAndroidIntentUrl(room) {
}
/*
* Die Mobile-Apps (iOS, Firefox auf Android) registrieren
* "org.jitsi.meet", nicht das Desktop-Schema.
* The mobile apps (iOS, Firefox on Android) register
* "org.jitsi.meet", not the desktop scheme.
*/
const APP_SCHEME = "org.jitsi.meet://";
@@ -543,14 +666,14 @@ function createAppSchemeUrl(room) {
}
/* ============================================================
* URL NACH PLATTFORM/BROWSER
* URL BY PLATFORM/BROWSER
* ============================================================ */
function selectProtocolUrl(room) {
const os = detectOperatingSystem();
/*
* iOS-App kennt nur das App-Schema.
* The iOS app only knows the app scheme.
*/
if (os === "ios") {
return createAppSchemeUrl(room);
@@ -571,8 +694,8 @@ function selectProtocolUrl(room) {
*/
/*
* Einmal parsen (assets/lib/ua-parser.min.js).
* Fällt die Bibliothek aus, bleibt die Seite bedienbar.
* Parse once (assets/lib/ua-parser.min.js).
* If the library is missing, the page stays usable.
*/
const uaResult =
typeof UAParser === "function"
@@ -580,8 +703,8 @@ const uaResult =
: { os: {}, browser: {} };
/*
* UAParser-Namen -> interne Kürzel.
* iPadOS meldet sich als macOS – Touchpunkte gelten als iOS.
* UAParser names -> internal shortcuts.
* iPadOS reports as macOS – touch points count as iOS.
*/
const OS_NAMES = {
Android: "android",
@@ -614,9 +737,9 @@ const BROWSER_NAMES = {
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.
* Chrome in "desktop mode" on Android sends a desktop
* UA (e.g. X11; Linux). Client hints reveal the real
* system. Firefox/Safari do not support them.
*/
const hints = navigator.userAgentData;
if (hints && hints.platform === "Android") {
@@ -624,8 +747,9 @@ function detectOperatingSystem() {
}
/*
* iPadOS tarnt sich als macOS – Touchpunkte verraten das iPad.
* Der Plattform-Check greift auch ohne geladenen ua-parser.
* iPadOS masquerades as macOS – touch points give the
* iPad away. The platform check also works without the
* ua-parser loaded.
*/
if (
navigator.maxTouchPoints > 1 &&
@@ -638,7 +762,7 @@ function detectOperatingSystem() {
}
/* ============================================================
* BROWSER ERKENNEN
* DETECT BROWSER
* ============================================================
*/
@@ -696,12 +820,12 @@ function configureDownloads() {
return;
}
/* Alles andere */
/* Everything else */
otherDownload.classList.remove("hidden");
}
/* ============================================================
* FALLBACK ANZEIGEN
* SHOW FALLBACK
* ============================================================
*/
@@ -714,7 +838,7 @@ function showFallback() {
}
/* ============================================================
* JITSI DESKTOP STARTEN
* START JITSI DESKTOP
* ============================================================
*/
@@ -729,12 +853,12 @@ function launchJitsi() {
const browserUrl = createBrowserUrl(room);
/*
* Browser-Fallback vorbereiten.
* Prepare the browser fallback.
*/
browserLink.href = browserUrl;
/*
* UI auf "Startvorgang" setzen.
* Set the UI to the "starting" state.
*/
launcher.classList.add("hidden");
fallback.classList.add("hidden");
@@ -744,13 +868,13 @@ function launchJitsi() {
let timeout = null;
/*
* Unsichtbarer iframe für den Protocol Handler.
* Invisible iframe for the protocol handler.
*/
const iframe = document.createElement("iframe");
iframe.style.display = "none";
/*
* Aufräumen.
* Clean up.
*/
function cleanup() {
if (completed) {
@@ -768,25 +892,25 @@ function launchJitsi() {
/*
* --------------------------------------------------------
* WICHTIG:
* IMPORTANT:
*
* Kein "blur" mehr als Erfolgskriterium verwenden.
* Do not use "blur" as a success criterion anymore.
*
* Der Browser kann beim Protocol-Dialog ebenfalls
* blur auslösen.
* The browser can also trigger blur when showing
* the protocol dialog.
*
* Wenn der Benutzer den Dialog abbricht, bleibt die
* Seite sichtbar und unser Timeout läuft weiter.
* If the user cancels the dialog, the page stays
* visible and our timeout keeps running.
* --------------------------------------------------------
*/
document.body.appendChild(iframe);
/*
* Protocol Handler starten.
* Start the protocol handler.
*
* Firefox ignoriert externe Protokolle aus iframes –
* das Custom-Schema direkt in der Hauptnavigation starten.
* Firefox ignores external protocols from iframes –
* start the custom scheme directly in the main navigation.
*/
try {
if (protocolUrl.startsWith(APP_SCHEME)) {
@@ -811,19 +935,19 @@ function launchJitsi() {
* FALLBACK
* --------------------------------------------------------
*
* Wir warten unabhängig davon, ob der Browser kurz
* den Fokus verliert.
* We wait regardless of whether the browser briefly
* loses focus.
*
* Wenn Jitsi erfolgreich startet:
* If Jitsi starts successfully:
*
* -> Jitsi kommt in den Vordergrund
* -> Benutzer sieht diese Seite nicht mehr
* -> Jitsi comes to the foreground
* -> the user no longer sees this page
*
* Wenn der Benutzer den Browser-Dialog abbricht:
* If the user cancels the browser dialog:
*
* -> diese Seite bleibt sichtbar
* -> Timeout läuft ab
* -> Fallback wird angezeigt
* -> this page stays visible
* -> the timeout expires
* -> the fallback is shown
* --------------------------------------------------------
*/
@@ -843,13 +967,13 @@ function launchJitsi() {
*/
/*
* Sonderzeichen in das nächstliegende ASCII-Zeichen
* umwandeln.
* Convert special characters to the closest ASCII
* character.
*
* Beispiel:
* Example:
*
* "Grundstück" ergibt "Grundstueck"
* "François" ergibt "Francois"
* "Grundstück" becomes "Grundstueck"
* "François" becomes "Francois"
*/
function transliterate(text) {
const extras = {
@@ -870,11 +994,11 @@ function transliterate(text) {
}
/*
* Aus dem Meeting-Namen einen gültigen Room-Namen ableiten.
* Derive a valid room name from the meeting name.
*
* Beispiel:
* Example:
*
* "Grund Stück Planung" ergibt "grund-stuck-planung"
* "Grund Stück Planung" becomes "grund-stuck-planung"
*/
function createRoomName(meetingName) {
return transliterate(meetingName)
@@ -888,6 +1012,66 @@ function createRoomName(meetingName) {
* ============================================================
*/
const ICS_LINE_LIMIT = 75;
/*
* UTF-8 byte length of a string.
*/
function byteLength(text) {
let length = 0;
for (const char of text) {
const code = char.codePointAt(0);
length += code <= 0x7f ? 1 : code <= 0x7ff ? 2 : code <= 0xffff ? 3 : 4;
}
return length;
}
/*
* Escape a text value per RFC 5545: backslash first,
* then semicolon and comma, newlines as literal "\n".
*/
function escapeICS(text) {
return text
.replace(/\\/g, "\\\\")
.replace(/;/g, "\\;")
.replace(/,/g, "\\,")
.replace(/\r?\n/g, "\\n");
}
/*
* Fold lines longer than 75 octets into CRLF + space
* continuations (RFC 5545).
*/
function foldICS(content) {
return content
.split("\r\n")
.map((line) => {
if (byteLength(line) <= ICS_LINE_LIMIT) {
return line;
}
const parts = [];
let current = "";
for (const char of line) {
if (byteLength(current + char) > ICS_LINE_LIMIT) {
parts.push(current);
current = " " + char;
} else {
current += char;
}
}
parts.push(current);
return parts.join("\r\n");
})
.join("\r\n");
}
function generateICS(meetingName, description, startTime, endTime, organizer) {
/* Generate a unique room name from the meeting name */
const roomName = createRoomName(meetingName);
@@ -910,32 +1094,39 @@ function generateICS(meetingName, description, startTime, endTime, organizer) {
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`;
/*
* One property per line, CRLF endings, folded at
* 75 octets (RFC 5545).
*/
const lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
`PRODID:-//Jitsi Meeting Launcher//NONSGML (${CONFIG.jitsiLauncher})//EN`,
"CALSCALE:GREGORIAN",
"METHOD:PUBLISH",
"BEGIN:VEVENT",
`UID:${uid}`,
`DTSTAMP:${dtStamp}`,
`DTSTART:${dtStart}`,
`DTEND:${dtEnd}`,
`SUMMARY:${escapeICS(meetingName)}`,
`DESCRIPTION:${escapeICS(description)}`,
`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"
];
const icsContent = foldICS(lines.join("\r\n")) + "\r\n";
return {
content: icsContent,
@@ -944,22 +1135,34 @@ END:VCALENDAR`;
};
}
function downloadICS(content, filename) {
let icsObjectUrl = null;
/*
* Download URL for the ICS content. Revokes the URL of
* the previous download.
*/
function downloadICS(content) {
const blob = new Blob([content], { type: 'text/calendar;charset=utf-8' });
const url = URL.createObjectURL(blob);
return url;
if (icsObjectUrl !== null) {
URL.revokeObjectURL(icsObjectUrl);
}
icsObjectUrl = URL.createObjectURL(blob);
return icsObjectUrl;
}
const MINUTES_STEP = 15;
const DEFAULT_DURATION_MINUTES = 30;
/*
* Minuten auf das 15-Minuten-Raster aufrunden.
* Round minutes up to the 15-minute grid.
*
* Beispiel:
* Example:
*
* 14:07 ergibt 14:15
* 14:50 ergibt 15:00
* 14:07 becomes 14:15
* 14:50 becomes 15:00
*/
function roundMinutes(date) {
const rounded = new Date(date);
@@ -973,11 +1176,11 @@ function roundMinutes(date) {
}
/*
* Lokale Zeit für datetime-local formatieren.
* Format local time for datetime-local.
*
* Beispiel:
* Example:
*
* 1. Januar 2026, 14:15 ergibt "2026-01-01T14:15"
* January 1, 2026, 14:15 becomes "2026-01-01T14:15"
*/
function formatLocal(date) {
const pad = (num) => num.toString().padStart(2, "0");
@@ -1066,8 +1269,8 @@ function openNextcloudCalendarCreator() {
}
/*
* Nextcloud validiert die E-Mail serverseitig und
* bricht sonst mit einer Fehlerseite ab.
* Nextcloud validates the email server-side and
* aborts with an error page otherwise.
*/
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
@@ -1105,7 +1308,7 @@ function openNextcloudCalendarCreator() {
const nextcloudUrl =
CONFIG.nextcloudBase +
'/index.php/apps/jitsi_calendar/confirm?' +
'/index.php/apps/atocal/confirm?' +
params.toString();
window.location.href = nextcloudUrl;
@@ -1158,9 +1361,7 @@ function createMeeting() {
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.href = downloadICS(icsData.content);
downloadIcsLink.download = `${icsData.roomName}.ics`;
/* Show success message */
@@ -1177,17 +1378,17 @@ function createMeeting() {
*/
/*
* Zeitspanne, nach der der "Kopiert!"-Text zum
* normalen Button-Label zurückkehrt.
* Time span after which the "Copied!" text returns
* to the normal button label.
*/
const COPY_RESET_MS = 2000;
/*
* Launch-URL, die das Meeting im Jitsi-Client öffnet.
* Launch URL that opens the meeting in the Jitsi client.
*
* Beispiel:
* Example:
*
* "projekt-kickoff" ergibt
* "projekt-kickoff" becomes
* https://meet.bnvb.eu/launch.html?room=projekt-kickoff
*/
function createLaunchUrl(roomName) {
@@ -1221,7 +1422,7 @@ function createAdhocLink() {
async function copyAdhocLink() {
const url = adhocLink.href;
/* Clipboard-API, mit Fallback für ältere Browser */
/* Clipboard API, with a fallback for older browsers */
try {
await navigator.clipboard.writeText(url);
} catch (error) {
@@ -1233,7 +1434,7 @@ async function copyAdhocLink() {
document.body.removeChild(input);
}
/* Kurzes Feedback, dann zurück zum normalen Label */
/* Brief feedback, then back to the normal label */
copyAdhocLinkButton.textContent = getTranslation('creator.copiedLabel') || 'Copied!';
setTimeout(() => {
copyAdhocLinkButton.textContent = getTranslation('creator.copyLinkButton') || 'Copy Link';
@@ -1255,16 +1456,20 @@ function cancelCreateMeeting() {
}
/* ============================================================
* INITIALISIERUNG
* INITIALIZATION
* ============================================================
*/
/* ============================================================
* INITIALIZATION
* ============================================================ */
function initialize() {
const room = getRoom();
/*
* Kein room-Parameter: Meldungen ausblenden,
* nur den Create-Button anzeigen.
* No room parameter: hide the messages,
* only show the Create button.
*/
if (room === null) {
missingRoomTitle.classList.add("hidden");
@@ -1277,8 +1482,8 @@ function initialize() {
}
/*
* Leerer room-Parameter ("?room="):
* "No meeting specified" anzeigen.
* Empty room parameter ("?room="):
* show "No meeting specified".
*/
if (room === "") {
missingRoomTitle.classList.remove("hidden");
@@ -1291,8 +1496,8 @@ function initialize() {
}
/*
* Ungültiger room-Parameter: nur die
* Invalid-Name-Meldung anzeigen.
* Invalid room parameter: only show
* the invalid-name message.
*/
if (!isValidRoom(room)) {
missingRoomTitle.classList.add("hidden");
@@ -1306,17 +1511,17 @@ function initialize() {
}
/*
* Room anzeigen.
* Show the room.
*/
roomName.textContent = room;
/*
* Browser-Fallback vorbereiten.
* Prepare the browser fallback.
*/
browserLink.href = createBrowserUrl(room);
/*
* Launcher anzeigen.
* Show the launcher.
*/
launcher.classList.remove("hidden");
@@ -1326,7 +1531,7 @@ function initialize() {
createNewFromLauncherButton.classList.add("hidden");
/*
* Debug-Informationen.
* Debug information.
*/
console.log(
"Jitsi Launcher",
@@ -1440,8 +1645,33 @@ navToggle.addEventListener(
toggleNavMenu
);
/* Windows help events */
windowsHelpButton.addEventListener(
"click",
() => {
windowsHelpOverlay.showModal();
}
);
windowsHelpClose.addEventListener(
"click",
() => {
windowsHelpOverlay.close();
}
);
/* Clicks on the backdrop close the overlay */
windowsHelpOverlay.addEventListener(
"click",
(event) => {
if (event.target === windowsHelpOverlay) {
windowsHelpOverlay.close();
}
}
);
/*
* Ohne gespeicherte Wahl dem System folgen.
* Follow the system if no choice is saved.
*/
window
.matchMedia("(prefers-color-scheme: dark)")
@@ -1463,8 +1693,8 @@ async function loadConfig() {
CONFIG = await response.json();
} catch (error) {
/*
* Ohne Datei bleiben Launcher unbrauchbar – Fehler
* melden und mit leerer Konfiguration fortfahren.
* Without the file the launcher stays unusable –
* report the error and continue with an empty config.
*/
console.error("Error loading config:", error);
}
@@ -1476,7 +1706,7 @@ async function loadTranslations() {
TRANSLATIONS = await response.json();
} catch (error) {
/*
* Ohne Datei bleibt der englische Text aus dem HTML stehen.
* Without the file the English text from the HTML stays.
*/
console.error("Error loading translations:", error);
}
@@ -1486,8 +1716,8 @@ async function loadTranslations() {
}
/*
* Theme/Logo sofort anwenden, damit nichts aufblinkt –
* Text erst nach dem Laden der Übersetzungen.
* Apply theme/logo immediately so nothing flickers –
* text only after the translations have loaded.
*/
async function start() {
applyTheme();
@@ -1496,8 +1726,11 @@ async function start() {
applyBackgroundImage();
applyLogo();
applyFavicons();
applyNextcloudButton();
applyAdhocButton();
applyLegalNoticeLink();
applyWindowsHelpButton();
await loadTranslations();
+47 -15
View File
@@ -4,10 +4,9 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Jitsi Meeting</title>
<link rel='shortlink' href='https://meet.bnvb.eu/' />
<link href="https://meet.bnvb.eu/assets/images/favicon.ico" rel="alternate icon">
<link href="https://meet.bnvb.eu/assets/images/apple-touch-icon.png" rel="apple-touch-icon" sizes="180x180">
<link href="https://meet.bnvb.eu/assets/images/favicon.svg" rel="icon" type="image/svg+xml">
<link id="faviconIco" href="assets/images/favicon.ico" rel="alternate icon">
<link id="appleTouchIcon" href="assets/images/apple-touch-icon.png" rel="apple-touch-icon" sizes="180x180">
<link id="faviconSvg" href="assets/images/favicon.svg" rel="icon" type="image/svg+xml">
<link rel="stylesheet" href="assets/css/launch.css">
</head>
@@ -31,9 +30,12 @@
</svg>
</button>
<div id="navActions" class="nav-actions">
<a class="button secondary" href="https://www.bnvb-ingenieure.de/impressum" target="_blank" rel="noopener noreferrer" data-i18n="impressum">
Impressum
<a id="legalNoticeLink" class="button secondary hidden" href="#" target="_blank" rel="noopener noreferrer" data-i18n="legal_notice">
Legal Notice
</a>
<button id="windowsHelpButton" class="button secondary hidden" type="button" data-i18n="help.meetingPrivacySettings.windows.navButton">
Help
</button>
<a class="button secondary" href="https://git.bnvb.eu/stefan.tollkuehn/jitsi-launch" target="_blank" rel="noopener noreferrer" data-i18n="source">
Source
</a>
@@ -69,19 +71,16 @@
<h1 data-i18n="launcher.title">Jitsi Meeting</h1>
<p>
<span data-i18n="launcher.meetingPrefix">
The meeting
<span data-i18n="launcher.meetingText">
Please click the button, to join the meeting:
</span>
<span class="room">
<strong id="roomName"></strong>
</span>
<span data-i18n="launcher.meetingSuffix">
will be opened.
</span>
</p>
<button id="openButton" class="button primary" type="button" data-i18n="launcher.openButton">
Open meeting
Join meeting
</button>
<button id="createNewFromLauncherButton" class="button secondary hidden" type="button" data-i18n="creator.createNewButton">
@@ -286,12 +285,12 @@
</div>
<div class="form-actions">
<button id="createMeetingButton" class="button primary" type="button" data-i18n="creator.createButton">
Create Meeting
</button>
<button id="createNextcloudButton" class="button primary hidden" type="button" data-i18n="creator.nextcloudButton">
Create in Nextcloud Calendar
</button>
<button id="createMeetingButton" class="button primary" type="button" data-i18n="creator.createButton">
Download ICS File
</button>
<button id="createAdhocButton" class="button secondary hidden" type="button" data-i18n="creator.adhocButton">
Create Adhoc Link
</button>
@@ -320,6 +319,39 @@
</div>
</div>
<!-- WINDOWS-HILFE OVERLAY -->
<dialog id="windowsHelpOverlay" class="privacy">
<h2 data-i18n="help.meetingPrivacySettings.windows.title"></h2>
<p data-i18n="help.meetingPrivacySettings.windows.intro"></p>
<h3 data-i18n="help.meetingPrivacySettings.windows.step1Title"></h3>
<p data-i18n="help.meetingPrivacySettings.windows.step1Description"></p>
<h3 data-i18n="help.meetingPrivacySettings.windows.step2Title"></h3>
<p data-i18n="help.meetingPrivacySettings.windows.step2Description"></p>
<h3 data-i18n="help.meetingPrivacySettings.windows.step3Title"></h3>
<p data-i18n="help.meetingPrivacySettings.windows.step3Description"></p>
<p data-i18n="help.meetingPrivacySettings.windows.browserPermissions"></p>
<h3 data-i18n="help.meetingPrivacySettings.windows.goodToKnowTitle"></h3>
<ul>
<li data-i18n="help.meetingPrivacySettings.windows.goodToKnow1"></li>
<li data-i18n="help.meetingPrivacySettings.windows.goodToKnow2"></li>
<li data-i18n="help.meetingPrivacySettings.windows.goodToKnow3"></li>
</ul>
<p data-i18n="help.meetingPrivacySettings.windows.adminSupport"></p>
<div class="help-actions">
<button id="windowsHelpClose" class="button secondary" type="button" data-i18n="help.meetingPrivacySettings.windows.closeButton">
Close
</button>
</div>
</dialog>
<script src="assets/lib/ua-parser.min.js"></script>
<script src="assets/launch.js"></script>
</body>