Compare commits

..

4 Commits

Author SHA1 Message Date
Ovgodd 609ea778f1 ✨(frontend) free the meeting layout while a screen share is popped out
Unpin for grid view; keep screen share out until window closes
2026-09-25 08:59:31 +02:00
Ovgodd a050134a48 🌐(frontend) translate screen share separate window strings
Add the new button and placeholder labels in English, French,
German, Dutch and Spanish.
2026-09-25 08:59:31 +02:00
Ovgodd f7f793bc0c ✨(frontend) add open-in-separate-window control for screen shares
Add a button to put the shared screen in another window, without touching PiP.
2026-09-25 08:59:31 +02:00
Ovgodd f4aecdaf5a ♻️(frontend) add auxiliary window helpers and zoom DOM resync #1695
Open a proper popup window and keep zoom working when the video moves into it.
2026-09-25 08:59:31 +02:00
35 changed files with 679 additions and 440 deletions
+2 -1
View File
@@ -13,6 +13,7 @@ and this project adheres to
- ✨(backend) make the LiveKit default video codec configurable
- 🔧(dev) add support for Bureautix workstations
- ✨(frontend) add screen share zoom controls #1498
- ✨(frontend) open screen share in a separate window #1734
### Changed
@@ -34,7 +35,7 @@ and this project adheres to
### Fixed
- 🐛(helm) probe liveness on __lbheartbeat__ and readiness on __heartbeat__
- 🐛(helm) probe liveness on **lbheartbeat** and readiness on **heartbeat**
- 🐛(helm) render periodSeconds and failureThreshold on probes
- 🐛(backend) report the app release to Sentry instead of "NA"
- 🐛(frontend) play the waiting room notification sound on every arrival
-3
View File
@@ -76,9 +76,6 @@ def get_frontend_configuration(request):
"authenticated_users_can_edit_display_name": (
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME
),
"encryption": {
"is_enabled": settings.ENCRYPTION_ENABLED,
},
}
frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
return Response(frontend_configuration)
+1 -47
View File
@@ -38,7 +38,6 @@ class UserSerializer(serializers.ModelSerializer):
"short_name",
"timezone",
"language",
"default_encryption_mode",
"default_room_access_level",
"default_room_configuration",
]
@@ -54,14 +53,6 @@ class UserSerializer(serializers.ModelSerializer):
raise serializers.ValidationError(e.errors()) from e
return value
def validate_default_encryption_mode(self, value):
"""Reject a non-none default when the server has encryption disabled."""
if value != models.EncryptionMode.NONE and not settings.ENCRYPTION_ENABLED:
raise serializers.ValidationError(
_("End-to-end encryption is disabled on this server.")
)
return value
class UserLightSerializer(serializers.ModelSerializer):
"""Serialize users with limited fields."""
@@ -103,7 +94,6 @@ class ResourceAccessSerializerMixin:
raise PermissionDenied(
"Only owners of a room can assign other users as owners."
)
return data
def validate_resource(self, resource):
@@ -158,15 +148,7 @@ class RoomSerializer(serializers.ModelSerializer):
class Meta:
model = models.Room
fields = [
"id",
"name",
"slug",
"configuration",
"access_level",
"pin_code",
"encryption_mode",
]
fields = ["id", "name", "slug", "configuration", "access_level", "pin_code"]
read_only_fields = ["id", "slug", "pin_code"]
def validate_configuration(self, value):
@@ -179,32 +161,6 @@ class RoomSerializer(serializers.ModelSerializer):
raise serializers.ValidationError(e.errors()) from e
return value
def validate_encryption_mode(self, value):
"""Encryption mode is part of the link's semantics (the passphrase
lives in the URL hash for `basic` rooms) so it cannot be changed once
the room exists."""
instance = self.instance
if instance and instance.encryption_mode != value:
raise serializers.ValidationError(
"Encryption mode cannot be changed after room creation."
)
return value
def validate_access_level(self, value):
"""Encrypted rooms must stay restricted — the lobby is the only way
to enforce per-participant admission, and basic encryption relies on
the host vetting each joiner before they receive the in-URL key."""
instance = self.instance
if (
instance
and instance.encryption_mode != models.EncryptionMode.NONE
and value != models.RoomAccessLevel.RESTRICTED
):
raise serializers.ValidationError(
"Encrypted rooms require restricted access level."
)
return value
def to_representation(self, instance):
"""
Add users only for administrator users.
@@ -241,14 +197,12 @@ class RoomSerializer(serializers.ModelSerializer):
if should_access_room:
room_id = f"{instance.id!s}"
username = request.query_params.get("username", None)
output["livekit"] = utils.generate_livekit_config(
room_id=room_id,
user=request.user,
username=username,
configuration=output["configuration"],
role=role,
encryption_mode=instance.encryption_mode,
)
else:
del output["pin_code"]
+2 -24
View File
@@ -249,18 +249,6 @@ class RoomViewSet(
Apply the user's default room preferences (access level and configuration)
unless the request explicitly provides its own values.
"""
encryption_mode = serializer.validated_data.get(
"encryption_mode", models.EncryptionMode.NONE
)
if (
encryption_mode != models.EncryptionMode.NONE
and not settings.ENCRYPTION_ENABLED
):
raise drf_exceptions.ValidationError(
{"encryption_mode": "Encryption is not enabled on this server."}
)
user = self.request.user
save_kwargs = {}
@@ -325,21 +313,16 @@ class RoomViewSet(
"""Start recording a room."""
serializer = serializers.StartRecordingSerializer(data=request.data)
if not serializer.is_valid():
return drf_response.Response(
{"detail": "Invalid request."},
status=drf_status.HTTP_400_BAD_REQUEST,
{"detail": "Invalid request."}, status=drf_status.HTTP_400_BAD_REQUEST
)
mode = serializer.validated_data["mode"]
options = serializer.validated_data.get("options")
room = self.get_object()
if room.is_encrypted:
raise drf_exceptions.ValidationError(
{"detail": "Recording is unavailable in encrypted rooms."}
)
try:
with transaction.atomic():
recording = models.Recording.objects.create(
@@ -662,11 +645,6 @@ class RoomViewSet(
room = self.get_object()
if room.is_encrypted:
raise drf_exceptions.ValidationError(
{"detail": "Subtitles are unavailable in encrypted rooms."}
)
try:
SubtitleService().start_subtitle(room)
except SubtitleException:
-12
View File
@@ -5,7 +5,6 @@ Core application enums declaration
import re
from django.conf import global_settings, settings
from django.db import models
from django.utils.translation import gettext_lazy as _
UUID_REGEX = (
@@ -33,14 +32,3 @@ ALL_LANGUAGES = getattr(
"ALL_LANGUAGES",
[(language, _(name)) for language, name in global_settings.LANGUAGES],
)
class EncryptionMode(models.TextChoices):
"""Encryption mode for a room.
Kept as an enum (not a boolean) so future modes — e.g. a vault-managed
per-user key flow — can be added without another schema migration.
"""
NONE = "none", _("No encryption")
BASIC = "basic", _("Passphrase-in-URL encryption")
@@ -1,46 +0,0 @@
"""Add Room.encryption_mode and User.default_encryption_mode (enum-based).
We store the mode as an enum (CharField with choices) rather than a boolean
so a future "advanced" mode (per-user vault keys, etc.) can be added without
a schema migration.
"""
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0023_alter_recording_status"),
]
operations = [
migrations.AddField(
model_name="room",
name="encryption_mode",
field=models.CharField(
choices=[
("none", "No encryption"),
("basic", "Passphrase-in-URL encryption"),
],
default="none",
help_text="End-to-end encryption mode for this room.",
max_length=20,
verbose_name="Encryption mode",
),
),
migrations.AddField(
model_name="user",
name="default_encryption_mode",
field=models.CharField(
choices=[
("none", "No encryption"),
("basic", "Passphrase-in-URL encryption"),
],
default="none",
help_text="Encryption mode pre-selected when this user creates a new meeting.",
max_length=20,
verbose_name="Default encryption mode",
),
),
]
+3 -73
View File
@@ -26,7 +26,6 @@ from lasuite.tools.email import get_domain_from_email
from timezone_field import TimeZoneField
from . import fields, utils
from .enums import EncryptionMode
from .recording.enums import FileExtension
from .validators import sub_validator
@@ -217,15 +216,6 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
"Unselect this instead of deleting accounts."
),
)
default_encryption_mode = models.CharField(
_("Default encryption mode"),
max_length=20,
choices=EncryptionMode.choices,
default=EncryptionMode.NONE,
help_text=_(
"Encryption mode pre-selected when this user creates a new meeting."
),
)
objects = auth_models.UserManager()
@@ -421,13 +411,6 @@ class Room(Resource):
choices=RoomAccessLevel.choices,
default=settings.RESOURCE_DEFAULT_ACCESS_LEVEL,
)
encryption_mode = models.CharField(
max_length=20,
choices=EncryptionMode.choices,
default=EncryptionMode.NONE,
verbose_name=_("Encryption mode"),
help_text=_("End-to-end encryption mode for this room."),
)
# Public configuration exposed to any room participant via the API
configuration = models.JSONField(
blank=True,
@@ -454,68 +437,20 @@ class Room(Resource):
return capfirst(self.name)
def save(self, *args, **kwargs):
"""Restrict new encrypted rooms and allocate PINs for unencrypted rooms.
"""Generate a unique n-digit pin code for new rooms."""
Skip PIN allocation for encrypted rooms — the SIP gateway will
always reject calls to them (no way to derive the key), and the
PIN namespace is finite (10**length): no point burning slots that
can never be dialed.
Also run `clean()` so the encryption invariants are enforced on
every save path (ORM, admin, shell), not only via the DRF
serializer.
"""
# Override both explicit access levels and user defaults on creation.
# Updates remain subject to clean() instead of being silently normalized.
if self._state.adding and self.is_encrypted:
self.access_level = RoomAccessLevel.RESTRICTED
self.clean()
# Roomkit devices also join by PIN, so a PIN is needed as soon as
# either integration is enabled.
if (
(settings.ROOM_TELEPHONY_ENABLED or settings.ROOMKIT_ENABLED)
and not self.pk
and not self.pin_code
and self.encryption_mode == EncryptionMode.NONE
):
self.pin_code = self.generate_unique_pin_code(
length=settings.ROOM_TELEPHONY_PIN_LENGTH
)
super().save(*args, **kwargs)
def clean(self):
"""Enforce encryption-mode invariants outside DRF.
Two rules:
- `encryption_mode` is set at creation and never mutated afterwards
(the URL-hash passphrase encodes assumptions about it).
- An encrypted room must be at the RESTRICTED access level so the
host vets joiners before they ever see the in-URL key.
"""
super().clean()
if self.pk is not None:
previous = Room.objects.filter(pk=self.pk).only("encryption_mode").first()
if (
previous is not None
and previous.encryption_mode != self.encryption_mode
):
raise ValidationError(
{
"encryption_mode": _(
"Encryption mode cannot be changed after room creation."
)
}
)
if (
self.encryption_mode != EncryptionMode.NONE
and self.access_level != RoomAccessLevel.RESTRICTED
):
raise ValidationError(
{
"access_level": _(
"Encrypted rooms must use the 'restricted' access level."
)
}
)
def clean_fields(self, exclude=None):
"""
Automatically generate the slug from the name and make sure it does not look like a UUID.
@@ -538,11 +473,6 @@ class Room(Resource):
"""Check if a room is public"""
return self.access_level == RoomAccessLevel.PUBLIC
@property
def is_encrypted(self):
"""Convenience: any non-none encryption mode counts as encrypted."""
return self.encryption_mode != EncryptionMode.NONE
@staticmethod
def generate_unique_pin_code(length):
"""Generate a unique n-digit PIN code"""
+1 -3
View File
@@ -275,9 +275,7 @@ class LiveKitEventsService:
except models.Room.DoesNotExist as err:
raise ActionFailedError(f"Room with ID {room_id} does not exist") from err
if (
settings.ROOM_TELEPHONY_ENABLED or settings.ROOMKIT_ENABLED
) and not room.is_encrypted:
if settings.ROOM_TELEPHONY_ENABLED or settings.ROOMKIT_ENABLED:
try:
self.sip_management.ensure_dispatch_rule(room)
except SIPException as e:
+5 -22
View File
@@ -48,11 +48,8 @@ class LobbyParticipant:
color: str
id: str
entered_at: str
# Whether the user signed in (e.g. via ProConnect). Surfaced to admins so
# they can decide whether to accept self-declared identities.
is_authenticated: bool = False
def to_dict(self) -> Dict[str, object]:
def to_dict(self) -> Dict[str, str]:
"""Serialize the participant object to a dict representation."""
return {
"status": self.status.value,
@@ -60,7 +57,6 @@ class LobbyParticipant:
"id": self.id,
"color": self.color,
"entered_at": self.entered_at,
"is_authenticated": self.is_authenticated,
}
@classmethod
@@ -76,7 +72,6 @@ class LobbyParticipant:
id=data["id"],
color=data["color"],
entered_at=data["entered_at"],
is_authenticated=bool(data.get("is_authenticated", False)),
)
except (KeyError, ValueError) as e:
logger.exception("Error creating Participant from dict:")
@@ -149,7 +144,7 @@ class LobbyService:
key=settings.LOBBY_COOKIE_NAME,
value=participant_id,
httponly=True,
secure=not settings.DEBUG,
secure=True,
samesite="Lax",
)
@@ -213,7 +208,6 @@ class LobbyService:
id=participant_id,
color=utils.generate_color(participant_id),
entered_at=timezone.now().isoformat(),
is_authenticated=request.user.is_authenticated,
)
else:
participant.status = LobbyParticipantStatus.ACCEPTED
@@ -226,24 +220,19 @@ class LobbyService:
configuration=room.configuration,
participant_id=participant_id,
role=user_role,
encryption_mode=room.encryption_mode,
)
return participant, livekit_config
livekit_config = None
if participant is None:
participant = self.enter(
room.id,
participant_id,
username,
is_authenticated=request.user.is_authenticated,
)
participant = self.enter(room.id, participant_id, username)
elif participant.status == LobbyParticipantStatus.WAITING:
self.refresh_waiting_status(room.id, participant_id)
elif participant.status == LobbyParticipantStatus.ACCEPTED:
# wrongly named, contains access token to join a room
livekit_config = utils.generate_livekit_config(
room_id=room_id,
user=request.user,
@@ -252,7 +241,6 @@ class LobbyService:
configuration=room.configuration,
participant_id=participant_id,
role=user_role,
encryption_mode=room.encryption_mode,
)
return participant, livekit_config
@@ -270,11 +258,7 @@ class LobbyService:
self._index_touch(room_id)
def enter(
self,
room_id: UUID,
participant_id: str,
username: str,
is_authenticated: bool = False,
self, room_id: UUID, participant_id: str, username: str
) -> LobbyParticipant:
"""Add participant to waiting lobby."""
@@ -286,7 +270,6 @@ class LobbyService:
id=participant_id,
color=color,
entered_at=timezone.now().isoformat(),
is_authenticated=is_authenticated,
)
try:
@@ -312,34 +312,3 @@ def test_api_rooms_create_authenticated_blank_user_default_access_level():
assert response.status_code == 201
room = Room.objects.get()
assert room.access_level == settings.RESOURCE_DEFAULT_ACCESS_LEVEL
@pytest.mark.parametrize("encryption_mode", ["none", "basic"])
@pytest.mark.parametrize(
"user_default", [None, RoomAccessLevel.PUBLIC, RoomAccessLevel.TRUSTED]
)
@pytest.mark.parametrize(
"requested_access", [None, RoomAccessLevel.PUBLIC, RoomAccessLevel.TRUSTED]
)
def test_api_rooms_create_encryption_access_precedence(
settings, encryption_mode, user_default, requested_access
):
"""Encryption overrides request and user access defaults only for encrypted rooms."""
settings.ENCRYPTION_ENABLED = True
user = UserFactory(default_room_access_level=user_default)
client = APIClient()
client.force_login(user)
data = {"name": "New room", "encryption_mode": encryption_mode}
if requested_access is not None:
data["access_level"] = requested_access
response = client.post("/api/v1.0/rooms/", data)
assert response.status_code == 201
expected_access = (
RoomAccessLevel.RESTRICTED
if encryption_mode == "basic"
else requested_access or user_default or settings.RESOURCE_DEFAULT_ACCESS_LEVEL
)
assert response.json()["access_level"] == expected_access
assert Room.objects.get().access_level == expected_access
@@ -62,7 +62,6 @@ def test_request_entry_anonymous(settings):
"status": "waiting",
"color": "mocked-color",
"entered_at": "2025-01-01T10:00:00+00:00",
"is_authenticated": False,
"livekit": None,
}
@@ -76,12 +75,9 @@ def test_request_entry_anonymous(settings):
@freeze_time("2025-01-01 10:00:00")
@pytest.mark.parametrize("encryption_mode", ["none", "basic"])
def test_request_entry_authenticated_user(settings, encryption_mode):
def test_request_entry_authenticated_user(settings):
"""Authenticated users should be allowed to request entry."""
room = RoomFactory(
access_level=RoomAccessLevel.RESTRICTED, encryption_mode=encryption_mode
)
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
user = UserFactory()
client = APIClient()
client.force_login(user)
@@ -117,7 +113,6 @@ def test_request_entry_authenticated_user(settings, encryption_mode):
"status": "waiting",
"color": "mocked-color",
"entered_at": "2025-01-01T10:00:00+00:00",
"is_authenticated": True,
"livekit": None,
}
@@ -194,7 +189,6 @@ def test_request_entry_with_existing_participants(settings):
"entered_at": "2025-01-01T10:00:00+00:00",
"status": "waiting",
"color": "mocked-color",
"is_authenticated": False,
"livekit": None,
}
@@ -249,7 +243,6 @@ def test_request_entry_public_room(settings):
"entered_at": "2025-01-01T10:00:00+00:00",
"status": "accepted",
"color": "mocked-color",
"is_authenticated": False,
"livekit": {"token": "test-token"},
}
@@ -304,7 +297,6 @@ def test_request_entry_authenticated_user_public_room(settings):
"entered_at": "2025-01-01T10:00:00+00:00",
"status": "accepted",
"color": "mocked-color",
"is_authenticated": True,
"livekit": {"token": "test-token"},
}
@@ -362,7 +354,6 @@ def test_request_entry_waiting_participant_public_room(settings):
"status": "accepted",
"color": "#123456",
"entered_at": "2025-01-01T10:00:00+00:00",
"is_authenticated": False,
"livekit": {"token": "test-token"},
}
@@ -632,7 +623,6 @@ def test_list_waiting_participants_success(settings):
"username": "user2",
"status": "waiting",
"color": "#654321",
"is_authenticated": False,
"entered_at": "2025-01-01T10:05:00+00:00",
},
{
@@ -640,7 +630,6 @@ def test_list_waiting_participants_success(settings):
"username": "user1",
"status": "waiting",
"color": "#123456",
"is_authenticated": False,
"entered_at": "2025-01-01T10:00:00+00:00",
},
]
@@ -33,7 +33,6 @@ def test_api_rooms_retrieve_anonymous_private_pk():
"id": str(room.id),
"name": room.name,
"slug": room.slug,
"encryption_mode": room.encryption_mode,
}
@@ -53,7 +52,6 @@ def test_api_rooms_retrieve_anonymous_trusted_pk():
"id": str(room.id),
"name": room.name,
"slug": room.slug,
"encryption_mode": room.encryption_mode,
}
@@ -72,7 +70,6 @@ def test_api_rooms_retrieve_anonymous_private_pk_no_dashes():
"id": str(room.id),
"name": room.name,
"slug": room.slug,
"encryption_mode": room.encryption_mode,
}
@@ -89,7 +86,6 @@ def test_api_rooms_retrieve_anonymous_private_slug():
"id": str(room.id),
"name": room.name,
"slug": room.slug,
"encryption_mode": room.encryption_mode,
}
@@ -106,7 +102,6 @@ def test_api_rooms_retrieve_anonymous_private_slug_not_normalized():
"id": str(room.id),
"name": room.name,
"slug": room.slug,
"encryption_mode": room.encryption_mode,
}
@@ -222,7 +217,6 @@ def test_api_rooms_retrieve_anonymous_public(mock_token):
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
"encryption_mode": room.encryption_mode,
}
mock_token.assert_called_once()
@@ -269,7 +263,6 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
"encryption_mode": room.encryption_mode,
}
mock_token.assert_called_once_with(
@@ -280,7 +273,6 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
sources=["camera"],
role=None,
participant_id=None,
encryption_mode="none",
)
@@ -322,7 +314,6 @@ def test_api_rooms_retrieve_authenticated_trusted(mock_token):
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
"encryption_mode": room.encryption_mode,
}
mock_token.assert_called_once_with(
@@ -333,7 +324,6 @@ def test_api_rooms_retrieve_authenticated_trusted(mock_token):
sources=None,
role=None,
participant_id=None,
encryption_mode="none",
)
@@ -359,7 +349,6 @@ def test_api_rooms_retrieve_authenticated():
"id": str(room.id),
"name": room.name,
"slug": room.slug,
"encryption_mode": room.encryption_mode,
}
@@ -411,7 +400,6 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
"encryption_mode": room.encryption_mode,
}
mock_token.assert_called_once_with(
@@ -422,7 +410,6 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
sources=["camera"],
role=str(RoleChoices.MEMBER),
participant_id=None,
encryption_mode="none",
)
@@ -474,7 +461,6 @@ def test_api_rooms_retrieve_administrators(
"short_name": other_user_access.user.short_name,
"timezone": "UTC",
"language": other_user_access.user.language,
"default_encryption_mode": "none",
},
"resource": str(room.id),
"role": other_user_access.role,
@@ -490,7 +476,6 @@ def test_api_rooms_retrieve_administrators(
"short_name": user_access.user.short_name,
"timezone": "UTC",
"language": user_access.user.language,
"default_encryption_mode": "none",
},
"resource": str(room.id),
"role": user_access.role,
@@ -511,7 +496,6 @@ def test_api_rooms_retrieve_administrators(
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
"encryption_mode": room.encryption_mode,
}
mock_token.assert_called_once_with(
@@ -522,25 +506,4 @@ def test_api_rooms_retrieve_administrators(
sources=None,
role=str(user_access.role),
participant_id=None,
encryption_mode="none",
)
@pytest.mark.parametrize("encryption_mode", ["none", "basic"])
@mock.patch("core.utils.generate_token", return_value="test-token")
def test_api_rooms_retrieve_custom_username(mock_token, encryption_mode, settings):
"""Encryption does not override the participant's requested display name."""
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = True
user = UserFactory(full_name="Profile Name")
room = RoomFactory(
access_level=RoomAccessLevel.RESTRICTED, encryption_mode=encryption_mode
)
UserResourceAccessFactory(resource=room, user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/rooms/{room.id}/", {"username": "Custom Name"})
assert response.status_code == 200
assert mock_token.call_args.kwargs["username"] == "Custom Name"
assert mock_token.call_args.kwargs["encryption_mode"] == encryption_mode
@@ -410,24 +410,3 @@ def test_api_rooms_update_livekit_sync_failure(mock_update_metadata, exception):
"configuration": {"can_publish_sources": ["camera"]},
},
)
@pytest.mark.parametrize(
"access_level", [RoomAccessLevel.PUBLIC, RoomAccessLevel.TRUSTED]
)
def test_api_rooms_update_encrypted_access_rejected(access_level):
"""API updates cannot change an encrypted room away from restricted access."""
room = RoomFactory(encryption_mode="basic")
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.patch(
f"/api/v1.0/rooms/{room.id}/", {"access_level": access_level}
)
assert response.status_code == 400
assert "access_level" in response.json()
room.refresh_from_db()
assert room.access_level == RoomAccessLevel.RESTRICTED
+1 -11
View File
@@ -303,7 +303,6 @@ def test_request_entry_public_room(
configuration=room.configuration,
participant_id="test-participant-id",
role=None,
encryption_mode="none",
)
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
@@ -343,7 +342,6 @@ def test_request_entry_trusted_room(
configuration=room.configuration,
participant_id="test-participant-id",
role=None,
encryption_mode="none",
)
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
@@ -376,12 +374,7 @@ def test_request_entry_new_participant(
assert participant == participant_data
assert livekit_config is None
mock_enter.assert_called_once_with(
room.id,
participant_id,
username,
is_authenticated=request.user.is_authenticated,
)
mock_enter.assert_called_once_with(room.id, participant_id, username)
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
@@ -449,7 +442,6 @@ def test_request_entry_accepted_participant(
configuration=room.configuration,
participant_id="test-participant-id",
role=None,
encryption_mode="none",
)
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
@@ -491,7 +483,6 @@ def test_request_entry_participant_with_role(
configuration=room.configuration,
participant_id="test-participant-id",
role="administrator",
encryption_mode="none",
)
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
@@ -895,7 +886,6 @@ def test_update_participant_status_success(mock_cache, lobby_service, participan
"id": participant_id,
"color": "#123456",
"entered_at": "2025-01-01T10:00:00+00:00",
"is_authenticated": False,
}
mock_cache.set.assert_called_once_with(
"mocked_cache_key", expected_data, timeout=60
-1
View File
@@ -127,7 +127,6 @@ def test_api_users_retrieve_me_authenticated(settings):
"short_name": user.short_name,
"language": user.language,
"timezone": "UTC",
"default_encryption_mode": "none",
}
@@ -360,35 +360,3 @@ def test_pin_generation_upper_bound(mock_randbelow, settings):
# Assert called with the right exclusive upper bound, 10^5
mock_randbelow.assert_called_with(100000)
@pytest.mark.parametrize("access_level", [None, *RoomAccessLevel.values])
@pytest.mark.parametrize("use_manager", [False, True])
def test_models_encrypted_room_creation_is_restricted(access_level, use_manager):
"""Both save and manager creation normalize encrypted rooms before validation."""
fields = {"name": "Encrypted room", "encryption_mode": "basic"}
if access_level is not None:
fields["access_level"] = access_level
if use_manager:
room = Room.objects.create(**fields)
else:
room = Room(**fields)
# A caller may assign the primary key before the first save.
room.pk = room.id
room.save()
room.refresh_from_db()
assert room.access_level == RoomAccessLevel.RESTRICTED
@pytest.mark.parametrize(
"access_level", [RoomAccessLevel.PUBLIC, RoomAccessLevel.TRUSTED]
)
def test_models_encrypted_room_access_update_rejected(access_level):
"""Existing encrypted rooms reject incompatible access instead of normalizing it."""
room = Room.objects.create(name="Encrypted room", encryption_mode="basic")
room.access_level = access_level
with pytest.raises(ValidationError) as excinfo:
room.save()
assert "access_level" in excinfo.value.message_dict
room.refresh_from_db()
assert room.access_level == RoomAccessLevel.RESTRICTED
+4 -18
View File
@@ -57,35 +57,21 @@ def test_generate_token_authenticated_fallback_user_representation():
assert claims["name"] == str(user)
@pytest.mark.parametrize("encryption_mode", ["none", "basic"])
def test_generate_token_explicit_username_overrides_default(encryption_mode):
def test_generate_token_explicit_username_overrides_default():
"""An explicitly provided username should take precedence over the full name."""
user = UserFactory(full_name="Jane Doe")
token = generate_token(
room="my-room",
user=user,
username="Custom Name",
encryption_mode=encryption_mode,
)
token = generate_token(room="my-room", user=user, username="Custom Name")
claims = decode_token(token)
assert claims["name"] == "Custom Name"
@pytest.mark.parametrize("encryption_mode", ["none", "basic"])
def test_authenticated_username_ignored_when_editing_disabled(
settings, encryption_mode
):
def test_authenticated_username_ignored_when_editing_disabled(settings):
"""With editing disabled, an authenticated user's username is ignored."""
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
user = UserFactory(full_name="Jane Doe")
token = generate_token(
room="my-room",
user=user,
username="Custom Name",
encryption_mode=encryption_mode,
)
token = generate_token(room="my-room", user=user, username="Custom Name")
claims = decode_token(token)
assert claims["name"] == "Jane Doe"
+3 -15
View File
@@ -34,9 +34,6 @@ from livekit.api import ( # pylint: disable=E0611
TwirpError,
VideoGrants,
)
from livekit.protocol.room import RoomConfiguration # pylint: disable=E0611
from core.enums import EncryptionMode
logger = logging.getLogger(__name__)
@@ -72,7 +69,6 @@ def generate_token( # noqa: PLR0917
role: Optional[str] = None,
participant_id: Optional[str] = None,
ttl: Optional[timedelta] = None,
encryption_mode: str = "none",
) -> str:
"""Generate a LiveKit access token for a user in a specific room.
@@ -95,8 +91,10 @@ def generate_token( # noqa: PLR0917
"""
is_admin_or_owner = role in ("owner", "administrator")
if is_admin_or_owner:
sources = settings.LIVEKIT_DEFAULT_SOURCES
if is_admin_or_owner or sources is None:
if sources is None:
sources = settings.LIVEKIT_DEFAULT_SOURCES
video_grants = VideoGrants(
@@ -143,14 +141,6 @@ def generate_token( # noqa: PLR0917
if ttl is not None:
token = token.with_ttl(ttl)
if encryption_mode != EncryptionMode.NONE:
token = token.with_room_config(
RoomConfiguration(
name=room,
metadata=json.dumps({"encryption_mode": encryption_mode}),
)
)
return token.to_jwt()
@@ -162,7 +152,6 @@ def generate_livekit_config( # noqa: PLR0917
color: Optional[str] = None,
configuration: Optional[dict] = None,
participant_id: Optional[str] = None,
encryption_mode: str = "none",
) -> dict:
"""Generate LiveKit configuration for room access.
@@ -195,7 +184,6 @@ def generate_livekit_config( # noqa: PLR0917
sources=sources,
role=role,
participant_id=participant_id,
encryption_mode=encryption_mode,
),
}
-4
View File
@@ -978,10 +978,6 @@ class Base(Configuration):
environ_prefix=None,
)
ENCRYPTION_ENABLED = values.BooleanValue(
False, environ_name="ENCRYPTION_ENABLED", environ_prefix=None
)
# External Applications
APPLICATION_ENABLED = values.BooleanValue(
False, environ_name="APPLICATION_ENABLED", environ_prefix=None
@@ -12,6 +12,10 @@ import {
import { Track } from 'livekit-client'
import { useSnapshot } from 'valtio'
import { clearPinnedTrack, layoutStore, setPinnedTrack } from '@/stores/layout'
import {
closeScreenSharePopout,
screenSharePopoutStore,
} from '@/stores/screenSharePopout'
import { useEffect, useRef } from 'react'
export const StageLayout = () => {
@@ -31,8 +35,26 @@ export const StageLayout = () => {
.filter((track) => track.publication.source === Track.Source.ScreenShare)
const { pinnedTrackRef } = useSnapshot(layoutStore)
const { entry: popoutEntry } = useSnapshot(screenSharePopoutStore)
const detachedSid = popoutEntry?.trackSid
const carouselTracks = tracks.filter(
// The popped-out share stays mounted below, but out of the grid and the
// carousel. It comes back with the other tracks when its window closes.
const visibleTracks = detachedSid
? tracks.filter(
(track) =>
!isTrackReference(track) || track.publication.trackSid !== detachedSid
)
: tracks
const detachedTrack = detachedSid
? tracks.find(
(track) =>
isTrackReference(track) && track.publication.trackSid === detachedSid
)
: undefined
const carouselTracks = visibleTracks.filter(
(track) => !isEqualTrackRef(track, pinnedTrackRef)
)
@@ -85,11 +107,36 @@ export const StageLayout = () => {
])
/* eslint-enable react-hooks/exhaustive-deps */
const screenShareKey = screenShareTracks
.map((track) => track.publication.trackSid)
.join()
// The popped-out tile is kept mounted below, so nothing else notices when
// the share stops. Close the window here instead.
useEffect(() => {
const entry = screenSharePopoutStore.entry
if (!entry) return
const alive = screenShareTracks.some(
(track) => track.publication.trackSid === entry.trackSid
)
if (!alive) closeScreenSharePopout({ restorePin: false })
// screenShareKey is the sid list; the array itself is new every render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [screenShareKey])
return (
<>
{/* Out of the layout, but still mounted: this subtree is what renders
into the separate window. hidden also takes it off the a11y tree
and out of the tab order. */}
{detachedTrack && (
<div hidden>
<ParticipantTile trackRef={detachedTrack} />
</div>
)}
{!pinnedTrackRef ? (
<div className="lk-grid-layout-wrapper" style={{ height: 'auto' }}>
<GridLayout tracks={tracks} style={{ padding: 0 }}>
<GridLayout tracks={visibleTracks} style={{ padding: 0 }}>
<ParticipantTile />
</GridLayout>
</div>
@@ -153,7 +153,12 @@ export const ParticipantTile: (
// there without the dead fullscreen button.
trackMedia =
isRemoteScreenShare && !disableTileControls ? (
<ScreenShareZoomableVideo tileRef={tileRef}>
<ScreenShareZoomableVideo
tileRef={tileRef}
participantName={participantName}
trackSid={trackReference.publication.trackSid}
windowName={`meet-screen-share-${trackReference.publication.trackSid || trackReference.participant.identity}`}
>
{videoTrack}
</ScreenShareZoomableVideo>
) : (
@@ -4,6 +4,8 @@ import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
const getOwnerDocument = (el: Element | null) => el?.ownerDocument ?? document
// Keeps the fullscreen state here rather than on the toolbar, so entering or
// leaving fullscreen does not re-render the zoom controls.
export const ScreenShareFullscreenButton = memo(
@@ -16,15 +18,22 @@ export const ScreenShareFullscreenButton = memo(
const announce = useScreenReaderAnnounce()
const [isFullscreen, setIsFullscreen] = useState(false)
const [isFullscreenAvailable, setIsFullscreenAvailable] = useState(
() => document.fullscreenEnabled
)
// Tracks whether this tile's container triggered fullscreen (vs another share's).
const wasThisTileFullscreen = useRef(false)
// Covers Esc and browser UI exits, not just this button.
// Listens on the element's own document, so it still works in the popup.
// Only this tile's instance announces to avoid duplicates with multiple shares.
useEffect(() => {
const doc = getOwnerDocument(containerRef.current)
setIsFullscreenAvailable(!!doc.fullscreenEnabled)
const onChange = () => {
const isThisTileFullscreen =
document.fullscreenElement === containerRef.current
doc.fullscreenElement === containerRef.current
setIsFullscreen(isThisTileFullscreen)
if (isThisTileFullscreen) {
@@ -35,16 +44,17 @@ export const ScreenShareFullscreenButton = memo(
announce(t('fullScreenExited'), 'assertive')
}
}
document.addEventListener('fullscreenchange', onChange)
return () => document.removeEventListener('fullscreenchange', onChange)
doc.addEventListener('fullscreenchange', onChange)
return () => doc.removeEventListener('fullscreenchange', onChange)
}, [announce, t, containerRef])
const toggleFullScreen = useCallback(async () => {
const doc = getOwnerDocument(containerRef.current)
try {
if (document.fullscreenElement === containerRef.current) {
await document.exitFullscreen()
if (doc.fullscreenElement === containerRef.current) {
await doc.exitFullscreen()
} else {
// Tile container so zoom controls stay visible in fullscreen.
// Tile / pop-out chrome so zoom controls stay visible in fullscreen.
await containerRef.current?.requestFullscreen()
}
} catch (error) {
@@ -52,7 +62,7 @@ export const ScreenShareFullscreenButton = memo(
}
}, [containerRef])
if (!document.fullscreenEnabled) return null
if (!isFullscreenAvailable) return null
return (
<Button
@@ -0,0 +1,30 @@
import { createPortal } from 'react-dom'
import { UNSAFE_PortalProvider } from '@react-aria/overlays'
import { ScreenReaderAnnouncer } from '@/primitives'
import { CrossDocumentOverlaysContext } from '@/primitives/CrossDocumentOverlaysContext'
/**
* Render in the popup. Tooltips have to live there too, or they show up
* in the meeting window instead.
*/
export const ScreenSharePopoutPortal = ({
container,
children,
}: {
container: HTMLElement
children: React.ReactNode
}) => {
if (!container.isConnected) return null
return createPortal(
<UNSAFE_PortalProvider getContainer={() => container}>
<CrossDocumentOverlaysContext.Provider value={true}>
{/* The meeting live region sits in the other document, which a screen
reader stops reading once the focus is here. */}
<ScreenReaderAnnouncer />
{children}
</CrossDocumentOverlaysContext.Provider>
</UNSAFE_PortalProvider>,
container
)
}
@@ -2,6 +2,8 @@ import { css } from '@/styled-system/css'
import { Button } from '@/primitives'
import {
RiFullscreenExitLine,
RiArrowGoBackLine,
RiShareBoxLine,
RiZoomInLine,
RiZoomOutLine,
} from '@remixicon/react'
@@ -10,6 +12,7 @@ import { Toolbar } from 'react-aria-components'
import { useEffect, useRef } from 'react'
import { isMacintosh } from '@/utils/livekit'
import { srOnly } from '@/styles/a11y'
import { useIsMobile } from '@/utils/useIsMobile'
import { ScreenShareFullscreenButton } from './ScreenShareFullscreenButton'
interface ScreenShareZoomControlsProps {
@@ -18,9 +21,12 @@ interface ScreenShareZoomControlsProps {
zoomPercentage: number
canZoomIn: boolean
canZoomOut: boolean
isPoppedOut: boolean
popoutButtonRef: React.Ref<HTMLButtonElement>
onZoomIn: () => void
onZoomOut: () => void
onResetZoom: () => void
onTogglePopout: () => void
}
export const ScreenShareZoomControls = ({
@@ -29,11 +35,15 @@ export const ScreenShareZoomControls = ({
zoomPercentage,
canZoomIn,
canZoomOut,
isPoppedOut,
popoutButtonRef,
onZoomIn,
onZoomOut,
onResetZoom,
onTogglePopout,
}: ScreenShareZoomControlsProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'screenShareZoom' })
const isMobile = useIsMobile()
const zoomInButtonRef = useRef<HTMLButtonElement>(null)
const hadFocusInCollapsibleRef = useRef(false)
@@ -158,6 +168,28 @@ export const ScreenShareZoomControls = ({
>
<RiZoomInLine size={20} />
</Button>
{/* Desktop only — popups on mobile are usually blocked. */}
{!isMobile && (
<Button
ref={popoutButtonRef}
size="sm"
variant="primaryTextDark"
square
tooltip={
isPoppedOut ? t('closeSeparateWindow') : t('openInSeparateWindow')
}
aria-label={
isPoppedOut ? t('closeSeparateWindow') : t('openInSeparateWindow')
}
onPress={onTogglePopout}
>
{isPoppedOut ? (
<RiArrowGoBackLine size={20} />
) : (
<RiShareBoxLine size={20} />
)}
</Button>
)}
<ScreenShareFullscreenButton containerRef={containerRef} />
</Toolbar>
</div>
@@ -1,24 +1,83 @@
import { css } from '@/styled-system/css'
import { useEffect, useRef, type ReactNode } from 'react'
import {
cloneElement,
isValidElement,
useCallback,
useEffect,
useLayoutEffect,
useRef,
type ReactNode,
} from 'react'
import { useTranslation } from 'react-i18next'
import { useScreenShareZoom } from '../hooks/useScreenShareZoom'
import { useScreenSharePopout } from '../hooks/useScreenSharePopout'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
import { ScreenShareZoomControls } from './ScreenShareZoomControls'
import { ScreenSharePopoutPortal } from './ScreenSharePopoutPortal'
import {
saveScreenShareZoom,
takePopoutButtonFocus,
takeScreenShareZoom,
} from '@/stores/screenSharePopout'
interface ScreenShareZoomableVideoProps {
tileRef: React.RefObject<HTMLDivElement | null>
participantName: string
trackSid: string
windowName: string
children: ReactNode
}
// The video comes in as children so that a zoom change, which only re-renders
// this wrapper, leaves the video subtree untouched.
const popoutChromeClassName = css({
width: '100%',
height: '100%',
position: 'relative',
backgroundColor: 'primaryDark.50',
outline: 'none',
_focusVisible: {
outline: '2px solid',
outlineColor: 'primary.500',
outlineOffset: '-2px',
},
'& .lk-participant-media-video': {
width: '100%',
height: '100%',
objectFit: 'contain',
},
})
export const ScreenShareZoomableVideo = ({
tileRef,
participantName,
trackSid,
windowName,
children,
}: ScreenShareZoomableVideoProps) => {
const zoom = useScreenShareZoom()
const { t } = useTranslation('rooms', { keyPrefix: 'screenShareZoom' })
const announce = useScreenReaderAnnounce()
const popoutChromeRef = useRef<HTMLDivElement>(null)
const popoutButtonRef = useRef<HTMLButtonElement>(null)
const wasPoppedOut = useRef(false)
const getVideoElement = useCallback(
() => zoom.transformElRef.current?.querySelector('video') ?? null,
[zoom.transformElRef]
)
const popout = useScreenSharePopout({
trackSid,
windowName,
title: t('separateWindowTitle', { name: participantName }),
getVideoElement,
})
// Moving the video in or out of the window remounts this tile, because the
// stage pin changes. Stash the zoom so the new instance picks it up.
const { capture, resync } = zoom
useLayoutEffect(() => {
return () => saveScreenShareZoom(trackSid, capture())
}, [trackSid, capture])
// SR announcement: announce zoom level on change, with a one-time pan hint
// on the first zoom above 100 % per session.
@@ -39,23 +98,45 @@ export const ScreenShareZoomableVideo = ({
if (!zoom.isZoomed) hasAnnouncedPanHint.current = false
}, [zoom.zoomPercentage, zoom.isZoomed, announce, t])
// Attach keyboard listener on the tile container (has tabIndex=0).
// Keys on the tile, or on the popup once the video is over there.
useEffect(() => {
const el = tileRef.current
const el = popout.isOpen ? popoutChromeRef.current : tileRef.current
if (!el) return
el.addEventListener('keydown', zoom.handleKeyDown)
return () => el.removeEventListener('keydown', zoom.handleKeyDown)
}, [tileRef, zoom.handleKeyDown])
}, [popout.isOpen, tileRef, zoom.handleKeyDown])
// Native wheel listener with { passive: false } so preventDefault works.
// Re-attach when the video moves to the other window.
useEffect(() => {
const el = zoom.surfaceElRef.current
if (!el) return
el.addEventListener('wheel', zoom.handleWheel, { passive: false })
return () => el.removeEventListener('wheel', zoom.handleWheel)
}, [zoom.handleWheel, zoom.surfaceElRef])
}, [zoom.handleWheel, zoom.surfaceElRef, popout.isOpen])
return (
// Open: focus the popup. Close: focus the button again (the toolbar remounts).
useLayoutEffect(() => {
resync(takeScreenShareZoom(trackSid) ?? undefined)
if (popout.isOpen) {
wasPoppedOut.current = true
popoutChromeRef.current?.focus()
return
}
if (!wasPoppedOut.current && !takePopoutButtonFocus(trackSid)) return
wasPoppedOut.current = false
popoutButtonRef.current?.focus()
}, [popout.isOpen, resync, trackSid])
// LiveKit unsubscribes tiles it believes are off-screen. Its observer
// cannot measure an element living in another window and reads it as
// hidden, which would drop the track a few seconds after opening.
const video =
popout.isOpen && isValidElement<{ manageSubscription?: boolean }>(children)
? cloneElement(children, { manageSubscription: false })
: children
const media = (
<>
<div
ref={zoom.surfaceElRef}
@@ -80,19 +161,44 @@ export const ScreenShareZoomableVideo = ({
transformOrigin: 'center center',
}}
>
{children}
{video}
</div>
</div>
<ScreenShareZoomControls
containerRef={tileRef}
containerRef={popout.isOpen ? popoutChromeRef : tileRef}
isZoomed={zoom.isZoomed}
zoomPercentage={zoom.zoomPercentage}
canZoomIn={zoom.canZoomIn}
canZoomOut={zoom.canZoomOut}
isPoppedOut={popout.isOpen}
popoutButtonRef={popoutButtonRef}
onZoomIn={zoom.zoomIn}
onZoomOut={zoom.zoomOut}
onResetZoom={zoom.resetZoom}
onTogglePopout={popout.toggle}
/>
</>
)
// Video in the popup. The meeting layout does not keep a tile for it.
if (popout.isOpen && popout.container) {
return (
<ScreenSharePopoutPortal container={popout.container}>
<div
ref={popoutChromeRef}
role="group"
// Focusable on purpose: it carries the zoom key handler, and the
// popup body is an ancestor, so keys would never bubble to it.
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
tabIndex={0}
aria-label={t('separateWindowLabel', { name: participantName })}
className={popoutChromeClassName}
>
{media}
</div>
</ScreenSharePopoutPortal>
)
}
return media
}
@@ -0,0 +1,112 @@
import { useCallback } from 'react'
import { useSnapshot } from 'valtio'
import { useTranslation } from 'react-i18next'
import { reportError } from '@/features/analytics/telemetry'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
import {
closeScreenSharePopout,
openScreenSharePopout,
screenSharePopoutStore,
} from '@/stores/screenSharePopout'
import {
getAuxiliaryWindowFeatures,
getAuxiliaryWindowSize,
initializeAuxiliaryWindow,
} from '@/utils/auxiliaryWindow'
type UseScreenSharePopoutOptions = {
trackSid: string
windowName: string
title: string
getVideoElement?: () => HTMLVideoElement | null
}
/**
* Opens the screen share in another window. Closing it does not stop the
* share: the video just comes back into the meeting.
*
* The window lives in a store, not in this hook. Opening it drops the stage
* pin, so the tile moves from the focus layout into the grid and this
* component remounts. The store is what keeps the window open across that.
*
* A real popup, not the meeting PiP, that one is already taken, and a
* popup can be as large as another screen.
*/
export const useScreenSharePopout = ({
trackSid,
windowName,
title,
getVideoElement,
}: UseScreenSharePopoutOptions) => {
const { t } = useTranslation('rooms', { keyPrefix: 'screenShareZoom' })
const announce = useScreenReaderAnnounce()
const { entry } = useSnapshot(screenSharePopoutStore)
const isOpen = entry?.trackSid === trackSid
// Brings the video back into the meeting, from the toolbar button as well as
// from the window's own close button. Safe to call after this hook's
// component has unmounted: the listener sits on the popup, not on the tile.
const release = useCallback(() => {
if (screenSharePopoutStore.entry?.trackSid !== trackSid) return
closeScreenSharePopout({ restorePin: true })
announce(t('separateWindowClosed'), 'assertive')
}, [announce, t, trackSid])
const open = useCallback(() => {
if (screenSharePopoutStore.entry) return
const { width, height } = getAuxiliaryWindowSize(getVideoElement?.())
// Open right away: waiting first (fullscreen, etc.) lets the browser
// block the popup.
const next = window.open(
'',
windowName,
getAuxiliaryWindowFeatures(width, height)
)
if (!next) {
announce(t('separateWindowBlocked'), 'assertive')
return
}
try {
const container = initializeAuxiliaryWindow(next, { title })
openScreenSharePopout({
trackSid,
popup: next,
container,
// The window X does not go through close() — still bring the video back.
onPopupClosed: release,
})
next.focus()
announce(t('separateWindowOpened'), 'assertive')
// Drop meeting fullscreen: the stage this share was filling goes away.
if (document.fullscreenElement) {
void document.exitFullscreen()
}
} catch (error) {
reportError('generic_failure', error, {
context: 'screen_share_popout_init',
})
next.close()
}
}, [announce, getVideoElement, release, t, title, trackSid, windowName])
const toggle = useCallback(() => {
if (screenSharePopoutStore.entry?.trackSid === trackSid) release()
else open()
}, [open, release, trackSid])
return {
isOpen,
// The snapshot deep-freezes the element. The portal needs the real node,
// which `ref()` kept out of the proxy.
container: isOpen
? (screenSharePopoutStore.entry?.container ?? null)
: null,
open,
close: release,
toggle,
}
}
@@ -19,6 +19,8 @@ import {
getZoomTransform,
} from '../utils/screenShareZoom'
export type ZoomState = { zoom: number; pan: PanOffset }
/**
* Manages zoom and pan state for a remote screen share.
*
@@ -79,6 +81,28 @@ export const useScreenShareZoom = () => {
)
}, [])
// After the video moves to the other window, write the current zoom back
// on the new nodes (otherwise it looks like 100 % until the next scroll).
// Pass a state to adopt one captured before a remount; the pan is clamped
// against the new container either way.
const resync = useCallback(
(state?: ZoomState) => {
if (state) {
zoomRef.current = state.zoom
panRef.current = state.pan
}
panRef.current = clampPan(
panRef.current,
zoomRef.current,
readPictureRatio()
)
applyTransform()
applyCursor()
syncToolbar()
},
[applyCursor, applyTransform, syncToolbar, readPictureRatio]
)
const setZoom = useCallback(
(next: number) => {
zoomRef.current = next
@@ -253,6 +277,13 @@ export const useScreenShareZoom = () => {
[panBy, zoomIn, zoomOut, resetZoom]
)
// The tile remounts when the layout switches. Callers stash this across
// that remount so the popup keeps the zoom the user already had.
const capture = useCallback(
(): ZoomState => ({ zoom: zoomRef.current, pan: { ...panRef.current } }),
[]
)
return {
zoomPercentage: Math.round(zoomLevel * 100),
isZoomed: zoomLevel > MIN_ZOOM,
@@ -264,6 +295,8 @@ export const useScreenShareZoom = () => {
zoomIn,
zoomOut,
resetZoom,
resync,
capture,
handleWheel,
handleKeyDown,
}
@@ -1,7 +1,7 @@
import { isWeb } from '@livekit/components-core'
import { MediaDeviceFailure, Track } from 'livekit-client'
import { getMediaDeviceFailure } from '../utils/mediaPermissions'
import React, { useState } from 'react'
import React, { useEffect, useState } from 'react'
import {
ConnectionStateToast,
RoomAudioRenderer,
@@ -32,6 +32,7 @@ import { ChatProvider } from '@/features/chat/components/ChatProvider'
import { SyncDevicePreferences } from '@/features/rooms/livekit/components/SyncDevicePreferences'
import { RoomSilentMicDetector } from '@/features/rooms/components/SilentMicDetector'
import { LobbyProvider } from '@/features/rooms/components/LobbyProvider'
import { closeScreenSharePopout } from '@/stores/screenSharePopout'
/**
* @public
@@ -78,6 +79,12 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
const { isOpen: isPictureInPictureOpen } = usePictureInPicture()
// Picture-in-picture replaces the stage, which is what renders a popped-out
// share. Bring it back rather than leave an empty window behind.
useEffect(() => {
if (isPictureInPictureOpen) closeScreenSharePopout({ restorePin: true })
}, [isPictureInPictureOpen])
const [isShareErrorVisible, setIsShareErrorVisible] = useState(false)
const handleDeviceError = ({
+8 -1
View File
@@ -772,7 +772,14 @@
"currentZoomLevel": "Zoom {{level}} %",
"panHint": "Zoom {{level}} %. Mit der Maus ziehen oder den Fokus zurück auf die Bildschirmfreigabe setzen und mit den Pfeiltasten im Bild navigieren.",
"fullScreenEntered": "Vollbild aktiviert",
"fullScreenExited": "Vollbild deaktiviert"
"fullScreenExited": "Vollbild deaktiviert",
"openInSeparateWindow": "In eigenem Fenster öffnen",
"closeSeparateWindow": "Bildschirmfreigabe zurück in die Besprechung holen",
"separateWindowOpened": "Bildschirmfreigabe in einem eigenen Fenster geöffnet",
"separateWindowClosed": "Bildschirmfreigabe zurück in die Besprechung geholt",
"separateWindowBlocked": "Der Browser hat das eigene Fenster blockiert. Erlauben Sie Pop-ups für diese Website und versuchen Sie es erneut.",
"separateWindowTitle": "Bildschirmfreigabe von {{name}}",
"separateWindowLabel": "Bildschirmfreigabe von {{name}} in einem eigenen Fenster"
},
"shortcutsPanel": {
"title": "Tastenkürzel",
+8 -1
View File
@@ -772,7 +772,14 @@
"currentZoomLevel": "Zoom {{level}} %",
"panHint": "Zoom {{level}} %. Drag with mouse, or move focus back to the screen share and use arrow keys to navigate the picture.",
"fullScreenEntered": "Full screen enabled",
"fullScreenExited": "Full screen disabled"
"fullScreenExited": "Full screen disabled",
"openInSeparateWindow": "Open in separate window",
"closeSeparateWindow": "Return screen share to meeting",
"separateWindowOpened": "Screen share opened in a separate window",
"separateWindowClosed": "Screen share returned to the meeting",
"separateWindowBlocked": "The browser blocked the separate window. Allow pop-ups for this site and try again.",
"separateWindowTitle": "{{name}}'s screen share",
"separateWindowLabel": "{{name}}'s screen share in a separate window"
},
"shortcutsPanel": {
"title": "Keyboard shortcuts",
+8 -1
View File
@@ -771,7 +771,14 @@
"currentZoomLevel": "Zoom {{level}} %",
"panHint": "Zoom {{level}} %. Arrastra con el ratón, o vuelve a la pantalla compartida y usa las flechas para moverte por la imagen.",
"fullScreenEntered": "Pantalla completa activada",
"fullScreenExited": "Pantalla completa desactivada"
"fullScreenExited": "Pantalla completa desactivada",
"openInSeparateWindow": "Abrir en una ventana separada",
"closeSeparateWindow": "Devolver la pantalla compartida a la reunión",
"separateWindowOpened": "Pantalla compartida abierta en una ventana separada",
"separateWindowClosed": "Pantalla compartida devuelta a la reunión",
"separateWindowBlocked": "El navegador bloqueó la ventana separada. Permite las ventanas emergentes para este sitio y vuelve a intentarlo.",
"separateWindowTitle": "Pantalla compartida de {{name}}",
"separateWindowLabel": "Pantalla compartida de {{name}} en una ventana separada"
},
"shortcutsPanel": {
"title": "Atajos de teclado",
+8 -1
View File
@@ -772,7 +772,14 @@
"currentZoomLevel": "Zoom {{level}} %",
"panHint": "Zoom {{level}} %. Glissez avec la souris, ou revenez sur le partage d'écran et utilisez les touches fléchées pour naviguer dans l'image.",
"fullScreenEntered": "Plein écran activé",
"fullScreenExited": "Plein écran désactivé"
"fullScreenExited": "Plein écran désactivé",
"openInSeparateWindow": "Ouvrir dans une fenêtre séparée",
"closeSeparateWindow": "Ramener le partage d'écran dans la réunion",
"separateWindowOpened": "Partage d'écran ouvert dans une fenêtre séparée",
"separateWindowClosed": "Partage d'écran ramené dans la réunion",
"separateWindowBlocked": "Le navigateur a bloqué la fenêtre séparée. Autorisez les pop-ups pour ce site, puis réessayez.",
"separateWindowTitle": "Partage d'écran de {{name}}",
"separateWindowLabel": "Partage d'écran de {{name}} dans une fenêtre séparée"
},
"shortcutsPanel": {
"title": "Raccourcis clavier",
+8 -1
View File
@@ -772,7 +772,14 @@
"currentZoomLevel": "Zoom {{level}} %",
"panHint": "Zoom {{level}} %. Sleep met de muis, of zet de focus terug op de schermdeling en gebruik de pijltjestoetsen om door het beeld te navigeren.",
"fullScreenEntered": "Volledig scherm ingeschakeld",
"fullScreenExited": "Volledig scherm uitgeschakeld"
"fullScreenExited": "Volledig scherm uitgeschakeld",
"openInSeparateWindow": "Openen in een apart venster",
"closeSeparateWindow": "Schermdeling terugzetten in de vergadering",
"separateWindowOpened": "Schermdeling geopend in een apart venster",
"separateWindowClosed": "Schermdeling teruggezet in de vergadering",
"separateWindowBlocked": "De browser heeft het aparte venster geblokkeerd. Sta pop-ups toe voor deze site en probeer het opnieuw.",
"separateWindowTitle": "Schermdeling van {{name}}",
"separateWindowLabel": "Schermdeling van {{name}} in een apart venster"
},
"shortcutsPanel": {
"title": "Sneltoetsen",
@@ -0,0 +1,123 @@
import { flushSync } from 'react-dom'
import { proxy, ref } from 'valtio'
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
import type { ZoomState } from '@/features/rooms/livekit/hooks/useScreenShareZoom'
import { clearPinnedTrack, layoutStore, setPinnedTrack } from '@/stores/layout'
type Entry = {
trackSid: string
window: Window
container: HTMLElement
// Pin that was on screen when the window opened. Restored on close so the
// share (or whichever tile was focused) comes back. Absent when the meeting
// was already in grid view.
pinnedTrack?: TrackReferenceOrPlaceholder
detachListeners: () => void
}
export const screenSharePopoutStore = proxy<{ entry: Entry | null }>({
entry: null,
})
// Opening and closing both move the video between documents, which remounts
// the tile. These two slots carry what must survive that remount. They are
// armed by the transitions below, so an unrelated remount keeps its previous
// behaviour: the zoom resets and the focus stays where it was.
let armedSid: string | null = null
let carriedZoom: { trackSid: string; state: ZoomState } | null = null
let pendingButtonFocusSid: string | null = null
export const saveScreenShareZoom = (trackSid: string, state: ZoomState) => {
if (armedSid !== trackSid) return
// One save per transition, so a later unrelated remount starts from scratch.
armedSid = null
carriedZoom = { trackSid, state }
}
export const takeScreenShareZoom = (trackSid: string) => {
if (carriedZoom?.trackSid !== trackSid) return null
const { state } = carriedZoom
carriedZoom = null
return state
}
export const takePopoutButtonFocus = (trackSid: string) => {
if (pendingButtonFocusSid !== trackSid) return false
pendingButtonFocusSid = null
return true
}
export const openScreenSharePopout = ({
trackSid,
popup,
container,
onPopupClosed,
}: {
trackSid: string
popup: Window
container: HTMLElement
onPopupClosed: () => void
}) => {
const popupHidden = () => onPopupClosed()
// The meeting tab is going away: drop the window rather than run the
// bring-back flow, which would touch React while the page tears down.
const openerHidden = () => {
popup.removeEventListener('pagehide', popupHidden)
popup.close()
}
popup.addEventListener('pagehide', popupHidden, { once: true })
window.addEventListener('pagehide', openerHidden)
const pinnedTrack = layoutStore.pinnedTrackRef
armedSid = trackSid
screenSharePopoutStore.entry = {
trackSid,
window: ref(popup),
container: ref(container),
pinnedTrack: pinnedTrack ? ref(pinnedTrack) : undefined,
detachListeners: () => {
popup.removeEventListener('pagehide', popupHidden)
window.removeEventListener('pagehide', openerHidden)
},
}
// The share (or another pin) was filling the stage. Grid view leaves the
// rest of the meeting the whole window while the share is outside.
if (pinnedTrack) clearPinnedTrack()
}
// Clears the popout. The previous pin is restored only when the video is
// coming back: if the share itself ended, that pin points at a dead track.
export const closeScreenSharePopout = ({
restorePin,
}: {
restorePin: boolean
}) => {
const entry = screenSharePopoutStore.entry
if (!entry) return
const { window: popup, pinnedTrack, trackSid, detachListeners } = entry
detachListeners()
if (restorePin) {
// Both must be set before the render below: it remounts the tile, which
// reads them from its layout effect.
armedSid = trackSid
pendingButtonFocusSid = trackSid
// Put the cursor back in the meeting so the tile can focus its button.
window.focus()
} else {
armedSid = null
carriedZoom = null
pendingButtonFocusSid = null
}
// Unmount the portal while the other document is still alive, and put the
// pin back in the same render so the share doesn't flash through the grid.
flushSync(() => {
screenSharePopoutStore.entry = null
if (restorePin && pinnedTrack && !layoutStore.pinnedTrackRef) {
setPinnedTrack(pinnedTrack)
}
})
popup.close()
armedSid = null
}
+89
View File
@@ -0,0 +1,89 @@
// Helpers for a separate window. Not the meeting PiP: that API allows only
// one window, and it is already used. A blank popup has no CSS, so we copy
// styles from the meeting.
const AUXILIARY_ROOT_ID = 'root'
const copyDocumentChrome = (target: Window) => {
const { document: targetDoc } = target
document.head
.querySelectorAll('link[rel="stylesheet"], style')
.forEach((node) => {
targetDoc.head.appendChild(node.cloneNode(true))
})
targetDoc.documentElement.className = document.documentElement.className
targetDoc.documentElement.style.cssText =
document.documentElement.style.cssText
targetDoc.documentElement.setAttribute(
'lang',
document.documentElement.lang || 'en'
)
const theme = document.documentElement.dataset.lkTheme
if (theme) {
targetDoc.documentElement.dataset.lkTheme = theme
}
}
const ensureAuxiliaryRoot = (target: Window) => {
const existing = target.document.getElementById(AUXILIARY_ROOT_ID)
if (existing) return existing
const root = target.document.createElement('div')
root.id = AUXILIARY_ROOT_ID
root.style.width = '100%'
root.style.height = '100%'
target.document.body.appendChild(root)
return root
}
// Fill the window and reuse the meeting colors so it does not flash white.
const applyLayout = (target: Window) => {
const { document: targetDoc } = target
const sourceBody = getComputedStyle(document.body)
targetDoc.documentElement.style.height = '100%'
targetDoc.body.style.margin = '0'
targetDoc.body.style.height = '100%'
targetDoc.body.style.overflow = 'hidden'
targetDoc.body.style.backgroundColor = sourceBody.backgroundColor
targetDoc.body.style.color = sourceBody.color
}
// Returns the element to portal into.
export const initializeAuxiliaryWindow = (
target: Window,
{ title }: { title: string }
) => {
copyDocumentChrome(target)
target.document.title = title
applyLayout(target)
return ensureAuxiliaryRoot(target)
}
// Match the shared screen size, but keep the window on one display.
export const getAuxiliaryWindowSize = (
video?: Pick<HTMLVideoElement, 'videoWidth' | 'videoHeight'> | null
) => {
const maxWidth = Math.max(320, Math.round(window.screen.availWidth * 0.9))
const maxHeight = Math.max(240, Math.round(window.screen.availHeight * 0.9))
const videoWidth = video?.videoWidth ?? 0
const videoHeight = video?.videoHeight ?? 0
if (videoWidth > 0 && videoHeight > 0) {
const scale = Math.min(maxWidth / videoWidth, maxHeight / videoHeight, 1)
return {
width: Math.max(320, Math.round(videoWidth * scale)),
height: Math.max(240, Math.round(videoHeight * scale)),
}
}
return {
width: Math.min(1280, maxWidth),
height: Math.min(720, maxHeight),
}
}
export const getAuxiliaryWindowFeatures = (width: number, height: number) =>
`popup=yes,width=${width},height=${height},resizable=yes,scrollbars=no,status=no,location=no,toolbar=no,menubar=no`