fix: Jitsi meeting join rendered blank page — call JitsiMeetExternalAPI with 'new'

- 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>
This commit is contained in:
2026-07-04 07:05:32 +10:00
parent 3568b17f0f
commit 923c0ad878
5 changed files with 115 additions and 45 deletions

View File

@@ -1,4 +1,4 @@
// Version: 1.1.0 | Created: 2026-04-05 | Updated: 2026-04-10
// 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.
@@ -7,6 +7,7 @@
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;
@@ -77,9 +78,14 @@ class JitsiWebService {
}
/// Starts a Jitsi meeting inside the container div created by the platform view.
/// Call this AFTER the HtmlElementView has been mounted (e.g. in a post-frame callback).
/// Lazy-loads the Jitsi script on first call.
Future<void> joinMeeting({
/// 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,
@@ -90,53 +96,73 @@ class JitsiWebService {
// Lazy-load the external_api.js script if not already present.
final loaded = await _ensureScriptLoaded();
if (!loaded) return;
if (!loaded) {
web.console.warn('[Jitsi] external_api.js failed to load'.toJS);
return false;
}
// Find the container div — there should be exactly one with our prefix.
final containers = web.document.querySelectorAll('[id^="jitsi-container-"]');
if (containers.length == 0) return;
final parentNode = containers.item(containers.length - 1);
if (parentNode == null) return;
// 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;
}
final configOverwrite = <String, Object?>{
'startAudioMuted': 0,
'startVideoMuted': 0,
'disableDeepLinking': true,
'prejoinPageEnabled': true,
}.jsify();
// 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 = <String, Object?>{
'SHOW_CHROME_EXTENSION_BANNER': false,
}.jsify();
final interfaceConfigOverwrite = JSObject()
..setProperty('SHOW_CHROME_EXTENSION_BANNER'.toJS, false.toJS);
final options = <String, Object?>{
'roomName': roomName,
'parentNode': parentNode,
'width': '100%',
'height': '100%',
'configOverwrite': configOverwrite,
'interfaceConfigOverwrite': interfaceConfigOverwrite,
};
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['jwt'] = jwt;
options.setProperty('jwt'.toJS, jwt.toJS);
}
if (displayName != null && displayName.isNotEmpty) {
options['userInfo'] = <String, Object?>{
'displayName': displayName,
if (avatarUrl != null) 'avatarUrl': avatarUrl,
}.jsify();
final userInfo = JSObject()
..setProperty('displayName'.toJS, displayName.toJS);
if (avatarUrl != null) {
userInfo.setProperty('avatarUrl'.toJS, avatarUrl.toJS);
}
options.setProperty('userInfo'.toJS, userInfo);
}
final jsOptions = options.jsify();
_api = _createJitsiApi(AppConfig.jitsiDomain.toJS, jsOptions as JSObject);
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.
@@ -148,17 +174,21 @@ class JitsiWebService {
}
}
/// Calls `new JitsiMeetExternalAPI(domain, options)`.
/// 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')
external JSObject _createJitsiApi(JSString domain, JSObject options);
/// Calls `api.addEventListener(event, callback)`.
@JS()
extension type _JitsiApi(JSObject _) implements JSObject {
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);
}

View File

@@ -1,4 +1,4 @@
// Version: 1.2.0 | Created: 2026-04-05 | Updated: 2026-04-10
// Version: 1.3.0 | Created: 2026-04-05 | Updated: 2026-07-04
// Full-screen Jitsi meeting embedded via HtmlElementView (web platform view).
import 'package:flutter/material.dart';
@@ -21,6 +21,7 @@ class _JitsiScreenState extends State<JitsiScreen> {
late final JitsiLink? _link;
bool _meetingStarted = false;
bool _meetingEnded = false;
bool _meetingFailed = false;
@override
void initState() {
@@ -42,8 +43,9 @@ class _JitsiScreenState extends State<JitsiScreen> {
_meetingStarted = true;
// Post-frame so the HtmlElementView div is mounted in the DOM.
WidgetsBinding.instance.addPostFrameCallback((_) {
JitsiWebService.instance.joinMeeting(
// joinMeeting itself waits for the container div to appear.
WidgetsBinding.instance.addPostFrameCallback((_) async {
final ok = await JitsiWebService.instance.joinMeeting(
roomName: _link.roomName,
jwt: _link.jwt,
onReadyToClose: () {
@@ -52,6 +54,9 @@ class _JitsiScreenState extends State<JitsiScreen> {
}
},
);
if (!ok && mounted) {
setState(() => _meetingFailed = true);
}
});
}
@@ -111,6 +116,34 @@ class _JitsiScreenState extends State<JitsiScreen> {
);
}
if (_meetingFailed) {
return Scaffold(
appBar: AppBar(title: const Text('Conference')),
body: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.videocam_off_outlined, size: 48),
const SizedBox(height: 16),
const Text(
'Could not start the meeting. '
'Check your connection and try again.',
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () => context.go('/rooms'),
child: const Text('Back'),
),
],
),
),
),
);
}
// Render the Jitsi iframe and kick off the meeting once mounted.
_startMeeting();