// Version: 1.2.0 | Created: 2026-04-05 | Updated: 2026-07-04 // Web implementation: uses JitsiMeetExternalAPI via dart:js_interop. // This file is web-only — guarded by the Flutter web build. // The external_api.js script is lazy-loaded on first use — not in index.html. // This prevents privacy browsers (Brave, Mullvad) from blocking the app // at startup due to a cross-origin script from conf.m8chat.au. import 'dart:async'; import 'dart:js_interop'; import 'dart:js_interop_unsafe'; import 'dart:ui_web' as ui_web; import 'package:web/web.dart' as web; import '../../../core/config/app_config.dart'; /// Manages a single Jitsi iframe session via the JitsiMeetExternalAPI. class JitsiWebService { JitsiWebService._(); static final instance = JitsiWebService._(); JSObject? _api; bool _viewRegistered = false; bool _scriptLoaded = false; bool _scriptLoading = false; /// Unique view type for HtmlElementView. static const String viewType = 'jitsi-meet-container'; /// Lazy-loads the JitsiMeetExternalAPI script from conf.m8chat.au. /// Returns true if the script loaded successfully, false otherwise. Future _ensureScriptLoaded() async { if (_scriptLoaded) return true; if (_scriptLoading) { // Wait for in-flight load to finish. for (var i = 0; i < 50; i++) { await Future.delayed(const Duration(milliseconds: 100)); if (_scriptLoaded) return true; } return false; } _scriptLoading = true; final completer = Completer(); final script = web.document.createElement('script') as web.HTMLScriptElement; script.src = 'https://${AppConfig.jitsiDomain}/external_api.js'; script.async = true; script.onload = (web.Event event) { _scriptLoaded = true; _scriptLoading = false; completer.complete(true); }.toJS; script.onerror = (web.Event event) { _scriptLoading = false; completer.complete(false); }.toJS; web.document.head?.append(script); return completer.future; } /// Registers the platform view factory (once). void ensureViewRegistered() { if (_viewRegistered) return; ui_web.platformViewRegistry.registerViewFactory( viewType, (int viewId, {Object? params}) { final div = web.document.createElement('div') as web.HTMLDivElement; div.id = 'jitsi-container-$viewId'; div.style ..width = '100%' ..height = '100%'; return div; }, ); _viewRegistered = true; } /// Starts a Jitsi meeting inside the container div created by the platform view. /// Call this AFTER the HtmlElementView has been scheduled for mounting /// (e.g. in a post-frame callback). Lazy-loads the Jitsi script on first /// call and waits for the platform-view container div to appear — the /// post-frame callback can run before the HtmlElementView factory has /// inserted the div, which previously made this method bail out silently /// and leave the meeting screen blank. /// Returns true if the meeting iframe was created. Future joinMeeting({ required String roomName, String? jwt, String? displayName, String? avatarUrl, void Function()? onReadyToClose, }) async { dispose(); // tear down any previous meeting // Lazy-load the external_api.js script if not already present. final loaded = await _ensureScriptLoaded(); if (!loaded) { web.console.warn('[Jitsi] external_api.js failed to load'.toJS); return false; } // Wait for the container div — the platform view may not be in the DOM // yet when the post-frame callback fires. web.Node? parentNode; for (var i = 0; i < 50; i++) { final containers = web.document.querySelectorAll('[id^="jitsi-container-"]'); if (containers.length > 0) { parentNode = containers.item(containers.length - 1); break; } await Future.delayed(const Duration(milliseconds: 100)); } if (parentNode == null) { web.console.warn('[Jitsi] container div never appeared'.toJS); return false; } // Build the options as a real JS object. Passing a DOM node through // Map.jsify() is not supported and threw at runtime in the dart2js build. final configOverwrite = JSObject() ..setProperty('startAudioMuted'.toJS, 0.toJS) ..setProperty('startVideoMuted'.toJS, 0.toJS) ..setProperty('disableDeepLinking'.toJS, true.toJS) ..setProperty('prejoinPageEnabled'.toJS, true.toJS); final interfaceConfigOverwrite = JSObject() ..setProperty('SHOW_CHROME_EXTENSION_BANNER'.toJS, false.toJS); final options = JSObject() ..setProperty('roomName'.toJS, roomName.toJS) ..setProperty('parentNode'.toJS, parentNode as JSAny) ..setProperty('width'.toJS, '100%'.toJS) ..setProperty('height'.toJS, '100%'.toJS) ..setProperty('configOverwrite'.toJS, configOverwrite) ..setProperty('interfaceConfigOverwrite'.toJS, interfaceConfigOverwrite); if (jwt != null && jwt.isNotEmpty) { options.setProperty('jwt'.toJS, jwt.toJS); } if (displayName != null && displayName.isNotEmpty) { final userInfo = JSObject() ..setProperty('displayName'.toJS, displayName.toJS); if (avatarUrl != null) { userInfo.setProperty('avatarUrl'.toJS, avatarUrl.toJS); } options.setProperty('userInfo'.toJS, userInfo); } try { _api = _createJitsiApi(AppConfig.jitsiDomain.toJS, options); } catch (e) { web.console.error('[Jitsi] JitsiMeetExternalAPI threw: $e'.toJS); return false; } if (onReadyToClose != null && _api != null) { _addEventListener(_api!, 'readyToClose'.toJS, () { onReadyToClose(); }.toJS); } return true; } /// Cleans up the Jitsi iframe. void dispose() { if (_api != null) { _disposeApi(_api!); _api = null; } } } /// Binding for the JitsiMeetExternalAPI ES6 class. /// The external constructor compiles to `new JitsiMeetExternalAPI(...)`. /// (A plain external function binding calls it WITHOUT `new`, which throws /// "Class constructor cannot be invoked without 'new'" — the original cause /// of the blank meeting screen.) @JS('JitsiMeetExternalAPI') extension type _JitsiApi._(JSObject _) implements JSObject { external _JitsiApi(JSString domain, JSObject options); external void addEventListener(JSString event, JSFunction callback); external void dispose(); } JSObject _createJitsiApi(JSString domain, JSObject options) => _JitsiApi(domain, options); void _addEventListener(JSObject api, JSString event, JSFunction callback) { (api as _JitsiApi).addEventListener(event, callback); } void _disposeApi(JSObject api) { (api as _JitsiApi).dispose(); }