- Bind JitsiMeetExternalAPI as extension type external constructor; the old external function binding invoked the ES6 class without 'new' and threw at runtime (silently, in release builds) - joinMeeting: wait up to 5s for platform-view container div (post-frame race), build options via setProperty (jsify cannot carry DOM nodes), return success bool, log failures to browser console - jitsi_screen: show 'Could not start the meeting' UI instead of blank - v1.6.2+10, deployed to app2.m8chat.au Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
199 lines
6.7 KiB
Dart
199 lines
6.7 KiB
Dart
// 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<bool> _ensureScriptLoaded() async {
|
|
if (_scriptLoaded) return true;
|
|
if (_scriptLoading) {
|
|
// Wait for in-flight load to finish.
|
|
for (var i = 0; i < 50; i++) {
|
|
await Future<void>.delayed(const Duration(milliseconds: 100));
|
|
if (_scriptLoaded) return true;
|
|
}
|
|
return false;
|
|
}
|
|
_scriptLoading = true;
|
|
|
|
final completer = Completer<bool>();
|
|
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<bool> 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<void>.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();
|
|
}
|