Compare commits

...

68 Commits

Author SHA1 Message Date
Jacques ROUSSEL 3f996aa819 ♻️(tilt) use hem dev-backend chart
Use the helm dev-backend chart to share the backend across all projects
2025-09-02 16:53:34 +02:00
lebaudantoine 51ed277941 🔥(backend) remove demo data generation from tilt migration job
Remove call to generate demo data in tilt stack as it was never useful
to developers and only complicated the migration job unnecessarily.

Migration job should be laser focused on applying database migrations
rather than seeding mock data, improving clarity and reducing
complexity.
2025-08-25 17:23:58 +02:00
lebaudantoine f46a5dc157 🔒️(backend) fix Django security warning with longer dev secret key
Replace mock Django secret key with longer version to resolve security
warnings in development stack.

Still not production-suitable as key remains versioned in repository,
but eliminates security warnings during development workflow.
2025-08-25 17:23:58 +02:00
Jacques ROUSSEL 4f4b4d3231 ♻️(tilt) remove bitnami dependencies from dev stack
Remove dependencies on bitnami Helm charts since recent changes in
bitnami organization led to charts no longer being maintained or
published.

Enhanced the Tilt dependencies to avoid any bootstrap or refresh
errors while developping using the Tilt stack.

Making components dependant from each others increase slightly
the time required to spin up the stack the first time.
2025-08-25 17:23:58 +02:00
lebaudantoine 25a39a1fb6 👷(ci) add pip caching and upgrade setup-python action to v5
Implement pip dependency caching across all CI jobs requiring package
installation and upgrade actions/setup-python from v4 to v5.

The setup-python action is able to cache the dependencies and reuse this
cache while the pyproject file has not changed. It is easy to setup,
just the package manager used has to be declared in the cache settings
2025-08-24 23:39:41 +02:00
lebaudantoine a5003b55e3 💄(frontend) add visual cross to switch component for negative state
Introduce cross icon to switch component when in disabled/negative
state to provide clearer visual feedback to users.

Improves component usability by making the negative state more
explicitly recognizable through visual indicators.
2025-08-23 02:43:38 +02:00
lebaudantoine bcf551a25c 🐛(frontend) fix shortcut handler type to properly handle promises
Correct TypeScript typing for shortcut handler that wasn't properly
handling Promise return types.

Ensures proper async operation handling and prevents potential runtime
issues with promise-based shortcut actions.
2025-08-22 17:05:31 +02:00
lebaudantoine c1c2d0260d ♻️(frontend) refactor settings from context to valtio global store
Replace settings context provider with valtio global store for easier
access outside room components and better long-term maintainability.

Prepares for upcoming prejoin screen settings access by making settings
globally available without React context limitations.
2025-08-22 17:05:31 +02:00
lebaudantoine e80b9c2485 🚚(frontend) rename screenshare preference store for naming consistency
Update screenshare preference store name to follow established naming
convention used across other preference stores.
2025-08-22 17:05:31 +02:00
lebaudantoine 04f7412307 🏷️(frontend) make controlbar props readonly for enhanced type safety
Strengthens type safety by ensuring props immutability and catching
potential side effects during development.
2025-08-22 17:05:31 +02:00
lebaudantoine ba422110f8 📝(frontend) clarify toggleButtonProps override behavior in documentation
Document that toggleButtonProps are intended to override default and
computed values within ToggleComponent, acknowledging this breaks
encapsulation but serves as useful starting point.
2025-08-22 17:05:31 +02:00
lebaudantoine a83afdbb0c 🐛(frontend) prevent state updates when device selection unchanged
Skip state updates when selected device hasn't actually changed to
prevent unnecessary re-renders that caused visible camera track
blinking.

Improves user experience by eliminating visual artifacts during device
selection interactions when no actual change occurs.
2025-08-22 17:05:31 +02:00
lebaudantoine a5d8aae293 ️(frontend) optimize device icon hook to share icons between components
Optimize device icon hook calls to ensure parent select components and
their children share the same icon instances for better performance.
2025-08-22 17:05:31 +02:00
lebaudantoine 8a3a0d5759 🔥(frontend) remove obsolete props from duplicated LiveKit controlbar
Simplify duplicated controlbar component by removing props that became
useless after solving prop drilling issues.
2025-08-22 17:05:31 +02:00
lebaudantoine aa8362c470 🎨(frontend) clarify video toggle naming and improve typing
Enhance toggle naming in video controls to explicitly indicate special
processor handling functionality and improve toggleProps TypeScript
typing.

Makes code more self-documenting by clearly identifying processor-aware
toggle behavior while strengthening type safety
2025-08-22 17:05:31 +02:00
lebaudantoine c2586a392c 🔥(frontend) remove dead code left from previous PR
Clean up unused code that was forgotten during previous pull request
implementation.
2025-08-22 17:05:31 +02:00
lebaudantoine ea3f0cc59e 🐛(frontend) fix audio track initialization bug from merge error
Correct wrong copy-paste error in audio track dynamic initialization
that was missed during previous PR merge process.

Fixes initialization logic that was accidentally duplicated or
incorrectly modified during merge conflict resolution.
2025-08-22 17:05:31 +02:00
lebaudantoine 9aa9342054 ✏️(frontend) fix French typo in "arrière-plan" missing hyphen
Correct French translation typo where "arrière plan" was missing the
required hyphen to properly spell "arrière-plan".
2025-08-22 17:05:31 +02:00
lebaudantoine ca38c4851f 🐛(frontend) fix missing participant name in recording toaster
Restore participant name display in transcription and recording toast
notifications that was accidentally removed in recent changes.

Simple regression fix to ensure proper participant identification in
notification messages.
2025-08-22 17:05:31 +02:00
lebaudantoine a97895c383 🚸(frontend) auto-close device popover when opening dialogs or sidepanels
Close device control popover automatically when user opens sidepanels
or external dialogs to prevent confusing UI state.

Improves focus management by ensuring only one interface element
demands user attention at a time, reducing cognitive load during
interactions.
2025-08-22 17:05:31 +02:00
lebaudantoine e88aeedbf1 🐛(frontend) disable device shortcuts when permissions not granted
Fix bug where device toggling shortcuts remained active despite lacking
permissions, by disabling device-related shortcuts until permissions
are granted.

Prevents confusing user experience where shortcuts appear to work but
have no effect due to missing media permissions.
2025-08-22 17:05:31 +02:00
lebaudantoine 5f32a9e6a3 ♻️(frontend) refactor audio tab to controlled device selection pattern
Convert audio tab device selections to controlled behavior matching
video tab implementation for consistency.

Maintains current component structure without migrating to SelectDevice
component yet, focusing on controlled state pattern alignment first.
2025-08-22 17:05:31 +02:00
lebaudantoine aa09c15ecc (frontend) add effects quick access from in-conference video controls
Provide direct access to background and effects options from video
device controls during conference for additional user convenience.

Creates another pathway to effects configuration, giving users more
flexibility in accessing video enhancement features while in meetings.
2025-08-22 17:05:31 +02:00
lebaudantoine 7ec3e481ff ️(frontend) update device control tooltips to reflect settings dialog
Update tooltip and aria-label text for in-room device controls to
indicate they now open comprehensive settings dialog instead of simple
device selection.
2025-08-22 17:05:31 +02:00
lebaudantoine 42107f4698 (frontend) add settings quick access from device controls
Enable opening settings dialog directly from device controls while
inside a conference for quick access to device configuration.

Improves UX by providing immediate settings access without
enhancing convenience during meetings.

Requested by users.
2025-08-22 17:05:31 +02:00
lebaudantoine a49893696b 🌐(frontend) refactor device i18n keys for reusability across contexts
Update localization keys for device toggling and selection to be more
generic, enabling translation sharing between join and room contexts.

Eliminates duplicate translations and creates consistent messaging for
device interactions regardless of application context.
2025-08-22 17:05:31 +02:00
lebaudantoine 6f3339fbdc ♻️(frontend) unify toggle components into single flexible implementation
Replace separate prejoin and room toggle components with unified
component that's adaptable and easier to evolve without overfitting.

Adds responsibilities to join component but eliminates duplication. Join
component needs future refactoring as complexity is growing
significantly.
2025-08-22 17:05:31 +02:00
lebaudantoine f17e0a3ba0 ♻️(frontend) replace toggle device config with keyboard shortcut hook
Remove ugly toggle device configuration and implement hook to determine
appropriate keyboard shortcuts based on media device kind.

Cleaner approach that encapsulates shortcut logic in reusable hook
instead of scattered configuration objects.
2025-08-22 17:05:31 +02:00
lebaudantoine 2367750395 ♻️(frontend) extract media icon logic into optimized reusable hook
Create simple hook to assign icons to toggle/select components based on
media kind using dictionary lookup for optimization.

Eliminates duplicate icon assignment logic across components with
straightforward, performant implementation that's easy to maintain.
2025-08-22 17:05:31 +02:00
lebaudantoine 5eb70384e3 🚚(frontend) move ToggleDevice controls to Device folder
Reorganize ToggleDevice control components by moving them to the Device
folder for better code organization and logical grouping.
2025-08-22 17:05:31 +02:00
lebaudantoine 22c68da2af ♻️(frontend) extract permission checks into reusable hook by media kind
Create hook to encapsulate permission denied/prompted/loading checks
based on media kind, eliminating props drilling and simplifying code.

Returns appropriate permission state for consuming components based on
media type, cleaning up code structure with small enhancement.
2025-08-22 17:05:31 +02:00
lebaudantoine ebf676529f ♻️(frontend) refactor in-room device selection with audio output control
Refactor device selection within rooms and add audio output selection
to audio controls as requested by users.

Ensures code reuse between join and room components by sharing device
selection logic across both contexts.
2025-08-22 17:05:31 +02:00
lebaudantoine 40cedba8ae ♻️(frontend) decouple audio/video controls for reorganization clarity
Temporary state separating audio and video controls to improve clarity
and prepare for device selection/toggle component reorganization.

Work in progress to better structure device-related components before
implementing final unified control architecture.
2025-08-22 17:05:31 +02:00
lebaudantoine 59e0643dde 💄(frontend) add dark variant prop to Select primitive
Add dark variant to Select component following same approach as Popover
primitive. Same design inconsistency as other variant patterns.

Quick implementation pending UI v2 refactoring for unified variant
system across all components.
2025-08-22 17:05:31 +02:00
lebaudantoine 7b89395f42 (frontend) expose placement prop in Popover primitive
Add placement prop to Popover primitive to leverage React Aria's
explicit placement control functionality.

Provides better positioning control for popovers by exposing underlying
React Aria placement options, enabling more precise UI layouts.
2025-08-22 17:05:31 +02:00
lebaudantoine 3d047b5124 (frontend) add default tab selection mechanism to settings dialog
Implement ability to pass defaultSelectedTab key to settings component
for scenarios requiring specific tab to open automatically.

Enables programmatic control over initial tab selection, improving UX
when directing users to specific settings sections from different
application contexts.
2025-08-22 17:05:31 +02:00
lebaudantoine 89b1190bb4 (frontend) add arrow toggle option to Popover primitive
Allow enabling/disabling arrow graphics in Popover component to create
more modular and flexible primitive.

Provides greater design flexibility by making arrow display optional,
enabling different visual presentations based on specific use cases.
2025-08-22 17:05:31 +02:00
lebaudantoine 8f28a46a5f 💄(frontend) add dark variant prop to Popover primitive
Add dark variant following same approach as Menu component. Not ideal
as light/dark pattern differs from primary/primaryDark variants used
elsewhere.

Quick implementation that will be refactored during UI v2 migration for
better consistency across component variants.
2025-08-22 17:05:31 +02:00
lebaudantoine ac88c046dc ♻️(frontend) replace generic tab IDs with explicit string identifiers
Change tab identification from integer IDs to explicit string
identifiers for better code readability and maintainability.
2025-08-22 17:05:31 +02:00
lebaudantoine b7f55ac35d 🐛(frontend) fix camera/mic acquisition when disabled in user preferences
Replace default usePreviewTrack behavior that acquired media streams
even when users disabled audio/video in preferences, causing OS
recording indicators to show despite explicit user rejection.

Implement custom logic to only initiate preview tracks when actually
needed by user. Code is naive and could be optimized, but fixes the
misleading OS-level recording feedback that created user distrust.
2025-08-20 16:11:01 +02:00
lebaudantoine 329a729bdc ⬆️(frontend) bump LiveKit client JS SDK to 2.15.5
Update LiveKit client JavaScript SDK to version 2.15.5 which includes
a patch for Firefox SVC compatibility with AV1 codec.

Fixes video encoding issues specific to Firefox when using AV1 codec
with Scalable Video Coding, improving browser compatibility and video
quality performance.
2025-08-20 14:03:34 +02:00
lebaudantoine 2215b621f4 ♻️(frontend) refactor audio/video tabs to share common layout components
Extract shared layout components from audio and video tabs to eliminate
code duplication and improve maintainability.

Creates reusable layout components that both tabs can utilize, reducing
redundancy and ensuring consistent styling and behavior across settings
tabs.
2025-08-20 12:26:22 +02:00
lebaudantoine c330ec6ff4 (frontend) add subscription video quality selector to video tab
Add configuration option allowing users to set maximum video quality
for subscribed tracks, requested by users who prefer manual control
over automatic behavior.

Implements custom handling for existing and new video tracks since
LiveKit lacks simple global subscription parameter mechanism. Users can
now override automatic quality decisions with their own preferences.
2025-08-20 12:26:22 +02:00
lebaudantoine 8245270f28 ♻️(frontend) refactor video tab i18n strings with common prefix
Reorganize internationalization strings for video tab to use shared
"video" prefix, improving code maintainability and consistency.
2025-08-20 12:26:22 +02:00
lebaudantoine 9728603f72 (frontend) add persistence for subscribed video resolution preferences
Implement user choice persistence for video resolution settings of
subscribed tracks from other participants.

Maintains user preferences for received video quality across sessions,
allowing consistent bandwidth optimization and viewing experience
without reconfiguring subscription settings each visit.
2025-08-20 12:26:22 +02:00
lebaudantoine 803c94a80c (frontend) add video resolution selector for publishing control
Introduce select option allowing users to set maximum publishing
resolution that instantly changes video track resolution for other
participants.

Essential for low bandwidth networks and follows common patterns across
major videoconferencing solutions. Users can optimize their video
quality based on network conditions without leaving the call.
2025-08-20 12:26:22 +02:00
lebaudantoine fd90d0b830 (frontend) add persistence for video publishing resolution setting
Implement user choice persistence for video publishing resolution
configuration to maintain user preferences across sessions.

Stores selected video resolution in user settings, ensuring consistent
video quality preferences without requiring reconfiguration on each
visit.
2025-08-20 12:26:22 +02:00
lebaudantoine f380d0342d (frontend) add video tab to settings modal for camera configuration
Introduce new video tab in settings modal requested by users who found
it misleading to lack camera configuration options in settings.

Currently implements basic camera device selection. Future commits will
expand functionality to include resolution management, subscription
settings, and other video-related configurations.
2025-08-20 12:26:22 +02:00
lebaudantoine 4cdf257b6a ️(frontend) fix incorrect aria label on tab component
Correct wrong aria label that was accidentally copied from another
component during development.
2025-08-20 12:26:22 +02:00
lebaudantoine 338f8d8a69 ♻️(frontend) refactor useResolveDefaultDeviceId to run initially
Limit useResolveDefaultDeviceId hook execution to initial rendering
only, considering removal due to ux concerns.

Hook's purpose is to populate select fields when 'default' device
option isn't available in enumerated device list. Current
implementation may be too confusing for code execution.
2025-08-13 10:26:37 +02:00
lebaudantoine ab4f415d23 🩹(frontend) fix audiotrack typing issue from refactoring
Correct TypeScript typing issue for audiotrack that was introduced
during the recent app refactoring changes.
2025-08-13 10:26:37 +02:00
lebaudantoine e926b407b1 ♻️(frontend) refactor preview track to avoid unnecessary resets
Replace usePreviewTrack approach with manual track management inspired
by deprecated LiveKit implementation that only toggles what's needed
instead of resetting entire preview track on config changes.

Previous approach created new videoTrack when updating audio track,
causing video preview to blink black during audio configuration
changes.

It was also acquiring video streams even when disabled.

New implementation:
* Initializes tracks once with user permissions for both devices
* Manually handles muting/unmuting and device updates
* Preserves muted state when changing device IDs
* Uses exact device constraints on Chrome for proper device selection

Prevents unnecessary track recreation and eliminates preview blinking.
2025-08-13 10:26:37 +02:00
lebaudantoine 4e655a0a64 🚸(frontend) prioritize UI reactivity over error handling in track toggle
Update track muting/unmuting to prioritize immediate UI state changes
over error handling to prevent weird UX delays.

Ensures toggle buttons reflect new state instantly rather than waiting
for operation completion. Users expect immediate visual feedback when
interacting with mute controls, even if errors occur later.
2025-08-12 00:09:08 +02:00
lebaudantoine d2bde299be 🚸(frontend) auto-select single audio output device for smoother UX
When browsers don't return 'default' audio output device ID and only
one device is available, automatically select the single option to
improve user experience.

Prevents unnecessary user interaction when there's only one choice
available, making the device selection flow smoother and more intuitive
for users with single audio output setups.

This is necessary only for audio output because we don't create
a preview track, compare to video or mic.
2025-08-12 00:09:08 +02:00
lebaudantoine 9b44ed0974 🩹(frontend) disable Safari speaker select for LiveKit compatibility
Disable speaker selection on prejoin screen for Safari based on LiveKit
documentation stating audio output selection isn't supported, though
this needs further verification.

Maintains consistency with audio tab behavior until Safari audio output
support can be confirmed. Feature remains available on other browsers
where support is verified.
2025-08-12 00:09:08 +02:00
lebaudantoine aeaa9b7ffd 🐛(frontend) link audio output permissions to mic to prevent prompts
Fix issue where requesting audio output devices triggers microphone
permission prompts in certain browsers by linking audio output select
permissions with microphone permissions.

Ensures no unexpected permission prompts occur before preview tracks
are acquired, maintaining smooth user flow during device selection
without interrupting the permission sequence.
2025-08-12 00:09:08 +02:00
lebaudantoine da73424f72 🌐(frontend) fix Dutch plural forms in join screen translations
Correct plural form usage in Dutch language translations for the join
screen interface elements.
2025-08-12 00:09:08 +02:00
lebaudantoine 2b9b977f57 (frontend) add speaker select component for audio output configuration
Introduce speaker selection component requested by users to allow audio
output device configuration before entering calls.

Enables users to test and configure their preferred audio output device
during prejoin setup, ensuring proper audio routing before call begins.
Improves user experience by preventing audio issues during meetings.
2025-08-12 00:09:08 +02:00
lebaudantoine 355db6ef9a (frontend) add localStorage persistence for audio output device
Implement audio output device persistence in localStorage since LiveKit
doesn't handle this by default.

Ensures user's preferred audio output selection is remembered across
sessions, improving user experience by maintaining device preferences
without requiring re-selection on each visit.
2025-08-12 00:09:08 +02:00
lebaudantoine 890e043d29 🚨(frontend) remove redundant undefined type or optional specifier
Fix TypeScript warning by removing either 'undefined' type annotation
or '?' optional specifier where both were redundantly specified.
2025-08-11 19:04:59 +02:00
lebaudantoine 9eb412758a 🚨(frontend) fix unnecessary destructuring rename in devices assignment
Remove unnecessary renaming in destructuring assignment where variable
was renamed to the same name, violating TypeScript rule S6650.
2025-08-11 19:04:59 +02:00
lebaudantoine fa27afdfdf 💄(frontend) add symmetric shadows for white button contrast enhancement
Add symmetric shadows at top and bottom of white circle buttons to
ensure sufficient visual contrast against varying background colors.

Improves button visibility and accessibility by providing consistent
visual definition regardless of background context.
2025-08-11 19:04:59 +02:00
lebaudantoine cb8b415ef9 ♻️(frontend) refactor device select for controlled behavior
Major refactor of device select component with several key improvements:

* Set permission=true for Firefox compatibility - without this flag,
  device list returns empty on Firefox
* Implement controlled component pattern for active device selection,
  ensuring sync with preview track state
* Remove default device handling as controlled behavior eliminates need
* Render selectors only after permissions granted to prevent double
  permission prompts (separate for mic/camera)

Ensures usePreviewTrack handles initial permission request, then
selectors allow specific device choice once access is granted.
2025-08-11 19:04:59 +02:00
lebaudantoine 7c6182cc4e 🐛(frontend) fix default device ID mismatch with actual preview track
Update default device IDs when preview track starts to match the
actual device being used. LiveKit returns 'default' string which may
not exist in device list, causing ID mismatch.

Prevents misleading situation where default device ID doesn't
correspond to the device actually used by the preview track. Now
synchronizes IDs once preview starts for accurate device tracking.
2025-08-11 19:04:59 +02:00
lebaudantoine 2d47e90a1a 🐛(frontend) reset video ref on track stop for state transitions
Reset video element reference when track stops to ensure "camera
starting" to "camera started" message transitions work correctly on
repeated camera toggles.

Previously only worked on initial video element load. Now properly
handles state transitions for multiple camera enable/disable cycles.
2025-08-11 19:04:59 +02:00
lebaudantoine 56ec2dd8cb ♻️(frontend) refactor select to show arrow up when menu opens upward
Update select toggle device component used in conference to display
upward arrow when dropdown menu opens above the select component.

Improves visual consistency by matching arrow direction with actual
menu opening direction, providing clearer user interface feedback.
2025-08-11 19:04:59 +02:00
lebaudantoine 361de29780 🩹(frontend) refactor track muting for proper audio/video control
Fix useTrackToggle hook that wasn't properly muting/unmuting tracks
outside room context. Previously only toggled boolean state via
setUpManualToggle without actual track control.

This caused misleading visual feedback where prejoin showed "camera
disabled" while hardware remained active. Users could see camera/mic
LEDs still on despite UI indicating otherwise.

Refactor provides genuine track muting for accurate user feedback.
2025-08-11 19:04:59 +02:00
lebaudantoine e4d5ca64b9 ♻️(frontend) refactor prejoin screen for room context flexibility
Decouple prejoin components from conference context to enable different
behaviors when inside vs outside room environments. Components can now
evolve independently with lighter coupling.

Refactor layout structure to prepare for upcoming speaker selector
introduction. This decoupling allows for more flexible component
evolution and cleaner architecture.
2025-08-11 19:04:59 +02:00
66 changed files with 1987 additions and 858 deletions
+2
View File
@@ -82,6 +82,7 @@ jobs:
uses: actions/setup-python@v5
with:
python-version: "3.13"
cache: "pip"
- name: Install development dependencies
run: pip install --user .[dev]
- name: Check code formatting with ruff
@@ -186,6 +187,7 @@ jobs:
uses: actions/setup-python@v5
with:
python-version: "3.13"
cache: "pip"
- name: Install development dependencies
run: pip install --user .[dev]
+8
View File
@@ -85,6 +85,14 @@ clean_old_images('localhost:5001/meet-livekit')
k8s_yaml(local('cd ../src/helm && helmfile -n meet -e ${DEV_ENV:-dev} template .'))
k8s_resource('meet-backend', resource_deps=['dev-backend-redis', 'dev-backend-keycloak', 'dev-backend-postgres', 'livekit-livekit-server', 'dev-backend-minio:statefulset'])
k8s_resource('meet-backend-migrate', resource_deps=['dev-backend-postgres'])
k8s_resource('livekit-livekit-server', resource_deps=['dev-backend-redis'])
k8s_resource('livekit-livekit-server-test-connection', resource_deps=['livekit-livekit-server'])
k8s_resource('meet-backend-createsuperuser', resource_deps=['meet-backend-migrate'])
k8s_resource('dev-backend-keycloak', resource_deps=['dev-backend-keycloak-pg'])
k8s_resource('livekit-egress', resource_deps=['livekit-livekit-server'])
migration = '''
set -eu
# get k8s pod name from tilt resource name
+12 -11
View File
@@ -13,6 +13,7 @@
"@livekit/track-processors": "0.5.7",
"@pandacss/preset-panda": "0.54.0",
"@react-aria/toast": "3.0.5",
"@react-types/overlays": "3.9.0",
"@remixicon/react": "4.6.0",
"@tanstack/react-query": "5.81.5",
"@timephy/rnnoise-wasm": "1.0.0",
@@ -25,7 +26,7 @@
"i18next-parser": "9.3.0",
"i18next-resources-to-backend": "1.2.1",
"libphonenumber-js": "1.12.10",
"livekit-client": "2.15.2",
"livekit-client": "2.15.5",
"posthog-js": "1.256.2",
"react": "18.3.1",
"react-aria-components": "1.10.1",
@@ -3378,12 +3379,12 @@
}
},
"node_modules/@react-types/overlays": {
"version": "3.8.16",
"resolved": "https://registry.npmjs.org/@react-types/overlays/-/overlays-3.8.16.tgz",
"integrity": "sha512-Aj9jIFwALk9LiOV/s3rVie+vr5qWfaJp/6aGOuc2StSNDTHvj1urSAr3T0bT8wDlkrqnlS4JjEGE40ypfOkbAA==",
"version": "3.9.0",
"resolved": "https://registry.npmjs.org/@react-types/overlays/-/overlays-3.9.0.tgz",
"integrity": "sha512-T2DqMcDN5p8vb4vu2igoLrAtuewaNImLS8jsK7th7OjwQZfIWJn5Y45jSxHtXJUddEg1LkUjXYPSXCMerMcULw==",
"license": "Apache-2.0",
"dependencies": {
"@react-types/shared": "^3.30.0"
"@react-types/shared": "^3.31.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
@@ -3439,9 +3440,9 @@
}
},
"node_modules/@react-types/shared": {
"version": "3.30.0",
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.30.0.tgz",
"integrity": "sha512-COIazDAx1ncDg046cTJ8SFYsX8aS3lB/08LDnbkH/SkdYrFPWDlXMrO/sUam8j1WWM+PJ+4d1mj7tODIKNiFog==",
"version": "3.31.0",
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.31.0.tgz",
"integrity": "sha512-ua5U6V66gDcbLZe4P2QeyNgPp4YWD1ymGA6j3n+s8CGExtrCPe64v+g4mvpT8Bnb985R96e4zFT61+m0YCwqMg==",
"license": "Apache-2.0",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
@@ -7459,9 +7460,9 @@
}
},
"node_modules/livekit-client": {
"version": "2.15.2",
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.15.2.tgz",
"integrity": "sha512-hf0A0JFN7M0iVGZxMfTk6a3cW7TNTVdqxkykjKBweORlqhQX1ITVloh6aLvplLZOxpkUE5ZVLz1DeS3+ERglog==",
"version": "2.15.5",
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.15.5.tgz",
"integrity": "sha512-zn36akmDlqZxlrTOUgYXtxtj35HQ44aJ+mgKat9BTSPiZru4RjEHOtp8RJE6jGoN2miJlWiOeEKHB2+ae3YrSw==",
"license": "Apache-2.0",
"dependencies": {
"@livekit/mutex": "1.1.1",
+2 -1
View File
@@ -18,6 +18,7 @@
"@livekit/track-processors": "0.5.7",
"@pandacss/preset-panda": "0.54.0",
"@react-aria/toast": "3.0.5",
"@react-types/overlays": "3.9.0",
"@remixicon/react": "4.6.0",
"@tanstack/react-query": "5.81.5",
"@timephy/rnnoise-wasm": "1.0.0",
@@ -30,7 +31,7 @@
"i18next-parser": "9.3.0",
"i18next-resources-to-backend": "1.2.1",
"libphonenumber-js": "1.12.10",
"livekit-client": "2.15.2",
"livekit-client": "2.15.5",
"posthog-js": "1.256.2",
"react": "18.3.1",
"react-aria-components": "1.10.1",
@@ -98,6 +98,7 @@ export const MainNotificationToast = () => {
case NotificationType.ScreenRecordingLimitReached:
toastQueue.add(
{
participant,
type: notification.type,
},
{ timeout: NotificationDuration.ALERT }
@@ -12,6 +12,7 @@ import {
RoomOptions,
supportsAdaptiveStream,
supportsDynacast,
VideoPresets,
} from 'livekit-client'
import { keys } from '@/api/queryKeys'
import { queryClient } from '@/api/queryClient'
@@ -98,15 +99,23 @@ export const Conference = ({
},
videoCaptureDefaults: {
deviceId: userConfig.videoDeviceId ?? undefined,
resolution: userConfig.videoPublishResolution
? VideoPresets[userConfig.videoPublishResolution].resolution
: undefined,
},
audioCaptureDefaults: {
deviceId: userConfig.audioDeviceId ?? undefined,
},
audioOutput: {
deviceId: userConfig.audioOutputDeviceId ?? undefined,
},
}
// do not rely on the userConfig object directly as its reference may change on every render
}, [
userConfig.videoDeviceId,
userConfig.videoPublishResolution,
userConfig.audioDeviceId,
userConfig.audioOutputDeviceId,
isAdaptiveStreamSupported,
isDynacastSupported,
])
@@ -3,9 +3,14 @@ import { usePreviewTracks } from '@livekit/components-react'
import { css } from '@/styled-system/css'
import { Screen } from '@/layout/Screen'
import { useEffect, useMemo, useRef, useState } from 'react'
import { LocalVideoTrack, Track } from 'livekit-client'
import {
createLocalVideoTrack,
createLocalAudioTrack,
LocalAudioTrack,
LocalVideoTrack,
Track,
} from 'livekit-client'
import { H } from '@/primitives/H'
import { SelectToggleDevice } from '../livekit/components/controls/SelectToggleDevice'
import { Field } from '@/primitives/Field'
import { Button, Dialog, Text, Form } from '@/primitives'
import { VStack } from '@/styled-system/jsx'
@@ -15,6 +20,8 @@ import {
EffectsConfiguration,
EffectsConfigurationProps,
} from '../livekit/components/effects/EffectsConfiguration'
import { SelectDevice } from '../livekit/components/controls/Device/SelectDevice'
import { ToggleDevice } from '../livekit/components/controls/Device/ToggleDevice'
import { usePersistentUserChoices } from '../livekit/hooks/usePersistentUserChoices'
import { BackgroundProcessorFactory } from '../livekit/components/blur'
import { isMobileBrowser } from '@livekit/components-core'
@@ -27,8 +34,11 @@ import { ApiLobbyStatus, ApiRequestEntry } from '../api/requestEntry'
import { Spinner } from '@/primitives/Spinner'
import { ApiAccessLevel } from '../api/ApiRoom'
import { useLoginHint } from '@/hooks/useLoginHint'
import { useSnapshot } from 'valtio'
import { openPermissionsDialog, permissionsStore } from '@/stores/permissions'
import { openPermissionsDialog } from '@/stores/permissions'
import { useResolveInitiallyDefaultDeviceId } from '../livekit/hooks/useResolveInitiallyDefaultDeviceId'
import { isSafari } from '@/utils/livekit'
import type { LocalUserChoices } from '@/stores/userChoices'
import { useCannotUseDevice } from '../livekit/hooks/useCannotUseDevice'
const onError = (e: Error) => console.error('ERROR', e)
@@ -99,11 +109,13 @@ export const Join = ({
audioEnabled,
videoEnabled,
audioDeviceId,
audioOutputDeviceId,
videoDeviceId,
processorSerialized,
username,
},
saveAudioInputEnabled,
saveAudioOutputDeviceId,
saveVideoInputEnabled,
saveAudioInputDeviceId,
saveVideoInputDeviceId,
@@ -111,19 +123,44 @@ export const Join = ({
saveProcessorSerialized,
} = usePersistentUserChoices()
const initialUserChoices = useRef<LocalUserChoices | null>(null)
if (initialUserChoices.current === null) {
initialUserChoices.current = {
audioEnabled,
videoEnabled,
audioDeviceId,
audioOutputDeviceId,
videoDeviceId,
processorSerialized,
username,
}
}
const tracks = usePreviewTracks(
{
audio: { deviceId: audioDeviceId },
video: {
deviceId: videoDeviceId,
processor:
BackgroundProcessorFactory.deserializeProcessor(processorSerialized),
},
audio: !!initialUserChoices.current &&
initialUserChoices.current?.audioEnabled && {
deviceId: initialUserChoices.current.audioDeviceId,
},
video: !!initialUserChoices.current &&
initialUserChoices.current?.videoEnabled && {
deviceId: initialUserChoices.current.videoDeviceId,
processor:
BackgroundProcessorFactory.deserializeProcessor(
processorSerialized
),
},
},
onError
)
const videoTrack = useMemo(
const [dynamicVideoTrack, setDynamicVideoTrack] =
useState<LocalVideoTrack | null>(null)
const [dynamicAudioTrack, setDynamicAudioTrack] =
useState<LocalAudioTrack | null>(null)
const previewVideoTrack = useMemo(
() =>
tracks?.filter(
(track) => track.kind === Track.Kind.Video
@@ -131,14 +168,101 @@ export const Join = ({
[tracks]
)
const audioTrack = useMemo(
const previewAudioTrack = useMemo(
() =>
tracks?.filter(
(track) => track.kind === Track.Kind.Audio
)[0] as LocalVideoTrack,
)[0] as LocalAudioTrack,
[tracks]
)
/*
* Dynamic track creation strategy: Only create a dynamic track if the user initially disabled audio/video
* but now wants to enable it. This is a "just-in-time" acquisition pattern where we create the track
* on-demand. We avoid creating tracks when the user explicitly requested them to be disabled.
*/
useEffect(() => {
const createVideoTrack = async () => {
try {
const track = await createLocalVideoTrack({
deviceId: { exact: videoDeviceId },
processor:
BackgroundProcessorFactory.deserializeProcessor(
processorSerialized
),
})
setDynamicVideoTrack(track)
} catch (error) {
onError(error as Error)
}
}
if (
videoEnabled &&
!initialUserChoices.current?.videoEnabled &&
!previewVideoTrack &&
!dynamicVideoTrack
) {
createVideoTrack()
}
}, [
videoEnabled,
videoDeviceId,
processorSerialized,
previewVideoTrack,
dynamicVideoTrack,
])
useEffect(() => {
const createAudioTrack = async () => {
try {
const track = await createLocalAudioTrack({
deviceId: { exact: audioDeviceId },
})
setDynamicAudioTrack(track)
} catch (error) {
onError(error as Error)
}
}
if (
audioEnabled &&
!initialUserChoices.current?.audioEnabled &&
!previewAudioTrack &&
!dynamicAudioTrack
) {
createAudioTrack()
}
}, [audioEnabled, audioDeviceId, previewAudioTrack, dynamicAudioTrack])
// Cleanup dynamic tracks
useEffect(() => {
return () => {
dynamicVideoTrack?.stop()
}
}, [dynamicVideoTrack])
useEffect(() => {
return () => {
dynamicAudioTrack?.stop()
}
}, [dynamicAudioTrack])
// Final tracks (dynamic takes precedence over preview)
const videoTrack = dynamicVideoTrack || previewVideoTrack
const audioTrack = dynamicAudioTrack || previewAudioTrack
// LiveKit by default populates device choices with "default" value.
// Instead, use the current device id used by the preview track as a default
useResolveInitiallyDefaultDeviceId(
audioDeviceId,
audioTrack,
saveAudioInputDeviceId
)
useResolveInitiallyDefaultDeviceId(
videoDeviceId,
videoTrack,
saveVideoInputDeviceId
)
const videoEl = useRef(null)
const isVideoInitiated = useRef(false)
@@ -153,14 +277,17 @@ export const Join = ({
}
if (videoElement && videoTrack && videoEnabled) {
videoTrack.unmute()
videoTrack.attach(videoElement)
videoElement.addEventListener('loadedmetadata', handleVideoLoaded)
}
return () => {
videoTrack?.detach()
videoElement?.removeEventListener('loadedmetadata', handleVideoLoaded)
if (videoElement) {
videoElement.removeEventListener('loadedmetadata', handleVideoLoaded)
videoElement.style.opacity = '0'
}
isVideoInitiated.current = false
}
}, [videoTrack, videoEnabled])
@@ -222,13 +349,8 @@ export const Join = ({
enterRoom()
}
const permissions = useSnapshot(permissionsStore)
const isCameraDeniedOrPrompted =
permissions.isCameraDenied || permissions.isCameraPrompted
const isMicrophoneDeniedOrPrompted =
permissions.isMicrophoneDenied || permissions.isMicrophonePrompted
const isCameraDeniedOrPrompted = useCannotUseDevice('videoinput')
const isMicrophoneDeniedOrPrompted = useCannotUseDevice('audioinput')
const hintMessage = useMemo(() => {
if (isCameraDeniedOrPrompted) {
@@ -399,6 +521,28 @@ export const Join = ({
alignItems: 'center',
})}
>
<div
className={css({
position: 'absolute',
top: 0,
height: '5rem',
width: '100%',
backgroundImage:
'linear-gradient(to bottom, rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.3) 40%, rgba(0, 0, 0, 0.1) 80%, rgba(0, 0, 0, 0) 100%)',
zIndex: 1,
})}
/>
<div
className={css({
position: 'absolute',
bottom: 0,
height: '5rem',
width: '100%',
backgroundImage:
'linear-gradient(to top, rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.3) 35%, rgba(0, 0, 0, 0.1) 75%, rgba(0, 0, 0, 0) 100%)',
zIndex: 1,
})}
/>
<div
className={css({
position: 'relative',
@@ -502,6 +646,45 @@ export const Join = ({
)}
</div>
</div>
<div
className={css({
position: 'absolute',
bottom: '1rem',
zIndex: '1',
display: 'flex',
gap: '1rem',
justifyContent: 'center',
left: '50%',
transform: 'translateX(-50%)',
})}
>
<ToggleDevice
kind="audioinput"
context="join"
enabled={audioEnabled}
toggle={async () => {
saveAudioInputEnabled(!audioEnabled)
if (audioEnabled) {
await audioTrack?.mute()
} else {
await audioTrack?.unmute()
}
}}
/>
<ToggleDevice
kind="videoinput"
context="join"
enabled={videoEnabled}
toggle={async () => {
saveVideoInputEnabled(!videoEnabled)
if (videoEnabled) {
await videoTrack?.mute()
} else {
await videoTrack?.unmute()
}
}}
/>
</div>
<div
className={css({
position: 'absolute',
@@ -528,32 +711,57 @@ export const Join = ({
marginX: 'auto',
})}
>
<div>
<SelectToggleDevice
source={Track.Source.Microphone}
initialState={audioEnabled}
track={audioTrack}
initialDeviceId={audioDeviceId}
onChange={(enabled) => saveAudioInputEnabled(enabled)}
onDeviceError={(error) => console.error(error)}
onActiveDeviceChange={(deviceId) =>
saveAudioInputDeviceId(deviceId ?? '')
}
variant="tertiary"
<div
className={css({
width: '30%',
})}
>
<SelectDevice
kind="audioinput"
id={audioDeviceId}
onSubmit={async (id) => {
try {
saveAudioInputDeviceId(id)
if (audioTrack) {
await audioTrack.setDeviceId({ exact: id })
}
} catch (err) {
console.error('Failed to switch microphone device', err)
}
}}
/>
</div>
<div>
<SelectToggleDevice
source={Track.Source.Camera}
initialState={videoEnabled}
track={videoTrack}
initialDeviceId={videoDeviceId}
onChange={(enabled) => saveVideoInputEnabled(enabled)}
onDeviceError={(error) => console.error(error)}
onActiveDeviceChange={(deviceId) =>
saveVideoInputDeviceId(deviceId ?? '')
}
variant="tertiary"
{!isSafari() && (
<div
className={css({
width: '30%',
})}
>
<SelectDevice
kind="audiooutput"
id={audioOutputDeviceId}
onSubmit={saveAudioOutputDeviceId}
/>
</div>
)}
<div
className={css({
width: '30%',
})}
>
<SelectDevice
kind="videoinput"
id={videoDeviceId}
onSubmit={async (id) => {
try {
saveVideoInputDeviceId(id)
if (videoTrack) {
await await videoTrack.setDeviceId({ exact: id })
}
} catch (err) {
console.error('Failed to switch camera device', err)
}
}}
/>
</div>
</div>
@@ -1,7 +1,7 @@
import { css } from '@/styled-system/css'
import { Button, Text } from '@/primitives'
import { useMemo, useRef } from 'react'
import { ScreenSharePreferenceStore } from '@/stores/ScreenSharePreferences'
import { screenSharePreferenceStore } from '@/stores/screenSharePreferences'
import { useSnapshot } from 'valtio'
import { useLocalParticipant } from '@livekit/components-react'
import { useSize } from '../hooks/useResizeObserver'
@@ -18,7 +18,7 @@ export const FullScreenShareWarning = ({
const warningContainerRef = useRef<HTMLDivElement>(null)
const { width: containerWidth } = useSize(warningContainerRef)
const { localParticipant } = useLocalParticipant()
const screenSharePreferences = useSnapshot(ScreenSharePreferenceStore)
const screenSharePreferences = useSnapshot(screenSharePreferenceStore)
const isFullScreenSharing = useMemo(() => {
if (trackReference?.source !== 'screen_share') return false
@@ -62,7 +62,7 @@ export const FullScreenShareWarning = ({
}
const handleDismissWarning = () => {
ScreenSharePreferenceStore.enabled = false
screenSharePreferenceStore.enabled = false
}
if (!shouldShowWarning) return null
@@ -0,0 +1,130 @@
import { useTranslation } from 'react-i18next'
import { useTrackToggle, UseTrackToggleProps } from '@livekit/components-react'
import { Button, Popover } from '@/primitives'
import { RiArrowUpSLine } from '@remixicon/react'
import { Track } from 'livekit-client'
import { ToggleDevice } from './ToggleDevice'
import { css } from '@/styled-system/css'
import { usePersistentUserChoices } from '../../../hooks/usePersistentUserChoices'
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
import Source = Track.Source
import * as React from 'react'
import { SelectDevice } from './SelectDevice'
import { SettingsButton } from './SettingsButton'
import { SettingsDialogExtendedKey } from '@/features/settings/type'
type AudioDevicesControlProps = Omit<
UseTrackToggleProps<Source.Microphone>,
'source' | 'onChange'
> & {
hideMenu?: boolean
}
export const AudioDevicesControl = ({
hideMenu,
...props
}: AudioDevicesControlProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
const {
userChoices: { audioDeviceId, audioOutputDeviceId },
saveAudioInputDeviceId,
saveAudioInputEnabled,
saveAudioOutputDeviceId,
} = usePersistentUserChoices()
const onChange = React.useCallback(
(enabled: boolean, isUserInitiated: boolean) =>
isUserInitiated ? saveAudioInputEnabled(enabled) : null,
[saveAudioInputEnabled]
)
const trackProps = useTrackToggle({
source: Source.Microphone,
onChange,
...props,
})
const kind = 'audioinput'
const cannotUseDevice = useCannotUseDevice(kind)
const selectLabel = t(`settings.${SettingsDialogExtendedKey.AUDIO}`)
return (
<div
className={css({
display: 'flex',
gap: '1px',
})}
>
<ToggleDevice
{...trackProps}
kind={kind}
toggle={trackProps.toggle as () => Promise<void>}
overrideToggleButtonProps={{
...(hideMenu
? {
groupPosition: undefined,
}
: {}),
}}
/>
{!hideMenu && (
<Popover variant="dark" withArrow={false}>
<Button
tooltip={selectLabel}
aria-label={selectLabel}
groupPosition="right"
square
variant={
trackProps.enabled && !cannotUseDevice ? 'primaryDark' : 'error2'
}
>
<RiArrowUpSLine />
</Button>
{({ close }) => (
<div
className={css({
maxWidth: '36rem',
padding: '0.15rem',
display: 'flex',
gap: '0.5rem',
})}
>
<div
style={{
flex: '1 1 0',
minWidth: 0,
}}
>
<SelectDevice
context="room"
kind={kind}
id={audioDeviceId}
onSubmit={saveAudioInputDeviceId}
/>
</div>
<div
style={{
flex: '1 1 0',
minWidth: 0,
}}
>
<SelectDevice
context="room"
kind="audiooutput"
id={audioOutputDeviceId}
onSubmit={saveAudioOutputDeviceId}
/>
</div>
<SettingsButton
settingTab={SettingsDialogExtendedKey.AUDIO}
onPress={close}
/>
</div>
)}
</Popover>
)}
</div>
)
}
@@ -0,0 +1,123 @@
import { useTranslation } from 'react-i18next'
import { useMediaDeviceSelect } from '@livekit/components-react'
import { useEffect, useMemo } from 'react'
import { Select, SelectProps } from '@/primitives/Select'
import { Placement } from '@react-types/overlays'
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
import { useDeviceIcons } from '@/features/rooms/livekit/hooks/useDeviceIcons'
type DeviceItems = Array<{ value: string; label: string }>
type SelectDeviceContext = {
variant?: 'light' | 'dark'
placement?: Placement
}
type SelectDeviceProps = {
id?: string
onSubmit?: (id: string) => void
kind: MediaDeviceKind
context?: 'join' | 'room'
}
type SelectDevicePermissionsProps<T> = SelectDeviceProps &
Pick<SelectProps<T>, 'placement' | 'variant' | 'iconComponent'>
const SelectDevicePermissions = <T extends string | number>({
id,
kind,
onSubmit,
iconComponent,
...props
}: SelectDevicePermissionsProps<T>) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
const { devices, activeDeviceId, setActiveMediaDevice } =
useMediaDeviceSelect({ kind: kind, requestPermissions: true })
const items: DeviceItems = devices
.filter((d) => !!d.deviceId)
.map((d) => ({
value: d.deviceId,
label: d.label,
}))
/**
* FALLBACK AUDIO OUTPUT DEVICE SELECTION
* Auto-selects the only available audio output device when currently on 'default'
*/
useEffect(() => {
if (
kind !== 'audiooutput' ||
items.length !== 1 ||
items[0].value === 'default' ||
activeDeviceId !== 'default'
)
return
onSubmit?.(items[0].value)
setActiveMediaDevice(items[0].value)
}, [items, onSubmit, kind, setActiveMediaDevice, activeDeviceId])
const selectedKey = id || activeDeviceId
return (
<Select
aria-label={t(`${kind}.choose`)}
label=""
isDisabled={items.length === 0}
items={items}
iconComponent={iconComponent}
placeholder={items.length === 0 ? t('loading') : t('select')}
selectedKey={selectedKey}
onSelectionChange={(key) => {
if (key === selectedKey) return
onSubmit?.(key as string)
setActiveMediaDevice(key as string)
}}
{...props}
/>
)
}
export const SelectDevice = ({
id,
onSubmit,
kind,
context = 'join',
}: SelectDeviceProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
const contextProps = useMemo<SelectDeviceContext>(() => {
if (context == 'room') {
return { variant: 'dark', placement: 'top' }
}
return {}
}, [context])
const deviceIcons = useDeviceIcons(kind)
const cannotUseDevice = useCannotUseDevice(kind)
if (cannotUseDevice) {
return (
<Select
aria-label={t(`${kind}.permissionsNeeded`)}
label=""
isDisabled={true}
items={[]}
placeholder={t('permissionsNeeded')}
iconComponent={deviceIcons.select}
{...contextProps}
/>
)
}
return (
<SelectDevicePermissions
id={id}
onSubmit={onSubmit}
kind={kind}
iconComponent={deviceIcons.select}
{...contextProps}
/>
)
}
@@ -0,0 +1,32 @@
import { Button } from '@/primitives'
import { RiSettings3Line } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { SettingsDialogExtendedKey } from '@/features/settings/type'
import { useSettingsDialog } from '@/features/settings/hook/useSettingsDialog'
export const SettingsButton = ({
settingTab,
onPress,
}: {
settingTab: SettingsDialogExtendedKey
onPress?: () => void
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
const { openSettingsDialog } = useSettingsDialog()
return (
<Button
size="sm"
square
tooltip={t(`settings.${settingTab}`)}
aria-label={t(`settings.${settingTab}`)}
variant="primaryDark"
onPress={() => {
openSettingsDialog(settingTab)
onPress?.()
}}
>
<RiSettings3Line size={24} />
</Button>
)
}
@@ -0,0 +1,142 @@
import { ToggleButton } from '@/primitives'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
import { useMemo, useState } from 'react'
import { appendShortcutLabel } from '@/features/shortcuts/utils'
import { useTranslation } from 'react-i18next'
import { PermissionNeededButton } from './PermissionNeededButton'
import useLongPress from '@/features/shortcuts/useLongPress'
import { ActiveSpeaker } from '@/features/rooms/components/ActiveSpeaker'
import {
useIsSpeaking,
useLocalParticipant,
useMaybeRoomContext,
} from '@livekit/components-react'
import { ButtonRecipeProps } from '@/primitives/buttonRecipe'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
import { openPermissionsDialog } from '@/stores/permissions'
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
import { useDeviceIcons } from '../../../hooks/useDeviceIcons'
import { useDeviceShortcut } from '../../../hooks/useDeviceShortcut'
import { ToggleSource, CaptureOptionsBySource } from '@livekit/components-core'
type ToggleDeviceStyleProps = {
variant?: NonNullable<ButtonRecipeProps>['variant']
errorVariant?: NonNullable<ButtonRecipeProps>['variant']
toggleButtonProps?: Partial<ToggleButtonProps>
}
export type ToggleDeviceProps<T extends ToggleSource> = {
enabled: boolean
toggle: (
forceState?: boolean,
captureOptions?: CaptureOptionsBySource<T>
) => Promise<void | boolean | undefined>
context?: 'room' | 'join'
kind: 'audioinput' | 'videoinput'
overrideToggleButtonProps?: Partial<ToggleButtonProps>
}
export const ToggleDevice = <T extends ToggleSource>({
kind,
enabled,
toggle,
context = 'room',
overrideToggleButtonProps,
}: ToggleDeviceProps<T>) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
const {
variant,
errorVariant,
toggleButtonProps: computedToggleButtonProps,
} = useMemo<ToggleDeviceStyleProps>(() => {
if (context === 'join') {
return {
variant: 'whiteCircle',
errorVariant: 'errorCircle',
toggleButtonProps: {
groupPosition: undefined,
},
} as ToggleDeviceStyleProps
}
return {
variant: 'primaryDark',
errorVariant: 'error2',
toggleButtonProps: {
groupPosition: 'left',
},
} as ToggleDeviceStyleProps
}, [context])
const [pushToTalk, setPushToTalk] = useState(false)
const onKeyDown = () => {
if (pushToTalk || enabled) return
toggle()
setPushToTalk(true)
}
const onKeyUp = () => {
if (!pushToTalk) return
toggle()
setPushToTalk(false)
}
const deviceIcons = useDeviceIcons(kind)
const cannotUseDevice = useCannotUseDevice(kind)
const deviceShortcut = useDeviceShortcut(kind)
useRegisterKeyboardShortcut({
shortcut: deviceShortcut,
handler: async () => await toggle(),
isDisabled: cannotUseDevice,
})
useLongPress({
keyCode: kind === 'audioinput' ? 'Space' : undefined,
onKeyDown,
onKeyUp,
isDisabled: cannotUseDevice,
})
const toggleLabel = useMemo(() => {
const label = t(enabled ? 'disable' : 'enable', {
keyPrefix: `selectDevice.${kind}`,
})
return deviceShortcut ? appendShortcutLabel(label, deviceShortcut) : label
}, [enabled, kind, deviceShortcut, t])
const Icon =
enabled && !cannotUseDevice ? deviceIcons.toggleOn : deviceIcons.toggleOff
const roomContext = useMaybeRoomContext()
if (kind === 'audioinput' && pushToTalk && roomContext) {
return <ActiveSpeakerWrapper />
}
return (
<div style={{ position: 'relative' }}>
{cannotUseDevice && <PermissionNeededButton />}
<ToggleButton
isSelected={!enabled}
variant={enabled && !cannotUseDevice ? variant : errorVariant}
shySelected
onPress={() => (cannotUseDevice ? openPermissionsDialog() : toggle())}
aria-label={toggleLabel}
tooltip={
cannotUseDevice
? t('tooltip', { keyPrefix: 'permissionsButton' })
: toggleLabel
}
{...computedToggleButtonProps}
{...overrideToggleButtonProps}
>
<Icon />
</ToggleButton>
</div>
)
}
const ActiveSpeakerWrapper = () => {
const { localParticipant } = useLocalParticipant()
const isSpeaking = useIsSpeaking(localParticipant)
return <ActiveSpeaker isSpeaking={isSpeaking} pushToTalk />
}
@@ -0,0 +1,164 @@
import { useTranslation } from 'react-i18next'
import { useTrackToggle, UseTrackToggleProps } from '@livekit/components-react'
import { Button, Popover } from '@/primitives'
import { RiArrowUpSLine, RiImageCircleAiFill } from '@remixicon/react'
import { Track, VideoCaptureOptions } from 'livekit-client'
import { ToggleDevice } from './ToggleDevice'
import { css } from '@/styled-system/css'
import { usePersistentUserChoices } from '../../../hooks/usePersistentUserChoices'
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
import { useSidePanel } from '../../../hooks/useSidePanel'
import { BackgroundProcessorFactory } from '../../blur'
import Source = Track.Source
import * as React from 'react'
import { SelectDevice } from './SelectDevice'
import { SettingsButton } from './SettingsButton'
import { SettingsDialogExtendedKey } from '@/features/settings/type'
const EffectsButton = ({ onPress }: { onPress: () => void }) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
const { isEffectsOpen, toggleEffects } = useSidePanel()
return (
<Button
size="sm"
square
tooltip={t('effects')}
aria-label={t('effects')}
variant="primaryDark"
onPress={() => {
if (!isEffectsOpen) toggleEffects()
onPress()
}}
>
<RiImageCircleAiFill size={24} />
</Button>
)
}
type VideoDeviceControlProps = Omit<
UseTrackToggleProps<Source.Camera>,
'source' | 'onChange'
> & {
hideMenu?: boolean
}
export const VideoDeviceControl = ({
hideMenu,
...props
}: VideoDeviceControlProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
const { userChoices, saveVideoInputDeviceId, saveVideoInputEnabled } =
usePersistentUserChoices()
const onChange = React.useCallback(
(enabled: boolean, isUserInitiated: boolean) =>
isUserInitiated ? saveVideoInputEnabled(enabled) : null,
[saveVideoInputEnabled]
)
const trackProps = useTrackToggle({
source: Source.Camera,
onChange,
...props,
})
const kind = 'videoinput'
const cannotUseDevice = useCannotUseDevice(kind)
const toggleWithProcessor = async () => {
/**
* We need to make sure that we apply the in-memory processor when re-enabling the camera.
* Before, we had the following bug:
* 1 - Configure a processor on join screen
* 2 - Turn off camera on join screen
* 3 - Join the room
* 4 - Turn on the camera
* 5 - No processor is applied to the camera
* Expected: The processor is applied.
*
* See https://github.com/numerique-gouv/meet/pull/309#issuecomment-2622404121
*/
const processor = BackgroundProcessorFactory.deserializeProcessor(
userChoices.processorSerialized
)
const toggle = trackProps.toggle as (
forceState: boolean,
captureOptions: VideoCaptureOptions
) => Promise<void>
await toggle(!trackProps.enabled, {
processor: processor,
} as VideoCaptureOptions)
}
const selectLabel = t(`settings.${SettingsDialogExtendedKey.VIDEO}`)
return (
<div
className={css({
display: 'flex',
gap: '1px',
})}
>
<ToggleDevice
{...trackProps}
kind={kind}
toggle={toggleWithProcessor}
overrideToggleButtonProps={{
...(hideMenu
? {
groupPosition: undefined,
}
: {}),
}}
/>
{!hideMenu && (
<Popover variant="dark" withArrow={false}>
<Button
tooltip={selectLabel}
aria-label={selectLabel}
groupPosition="right"
square
variant={
trackProps.enabled && !cannotUseDevice ? 'primaryDark' : 'error2'
}
>
<RiArrowUpSLine />
</Button>
{({ close }) => (
<div
className={css({
maxWidth: '36rem',
padding: '0.15rem',
display: 'flex',
gap: '0.5rem',
})}
>
<div
style={{
flex: '1 1 0',
minWidth: 0,
}}
>
<SelectDevice
context="room"
kind={kind}
id={userChoices.videoDeviceId}
onSubmit={saveVideoInputDeviceId}
/>
</div>
<EffectsButton onPress={close} />
<SettingsButton
settingTab={SettingsDialogExtendedKey.VIDEO}
onPress={close}
/>
</div>
)}
</Popover>
)}
</div>
)
}
@@ -2,16 +2,16 @@ import { RiSettings3Line } from '@remixicon/react'
import { MenuItem } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { menuRecipe } from '@/primitives/menuRecipe'
import { useSettingsDialog } from '../SettingsDialogContext'
import { useSettingsDialog } from '@/features/settings/hook/useSettingsDialog'
export const SettingsMenuItem = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
const { setDialogOpen } = useSettingsDialog()
const { openSettingsDialog } = useSettingsDialog()
return (
<MenuItem
className={menuRecipe({ icon: true, variant: 'dark' }).item}
onAction={() => setDialogOpen(true)}
onAction={() => openSettingsDialog()}
>
<RiSettings3Line size={20} />
{t('settings')}
@@ -1,219 +0,0 @@
import { useTranslation } from 'react-i18next'
import {
useMediaDeviceSelect,
useTrackToggle,
UseTrackToggleProps,
} from '@livekit/components-react'
import { Button, Menu, MenuList } from '@/primitives'
import {
RemixiconComponentType,
RiArrowDownSLine,
RiMicLine,
RiMicOffLine,
RiVideoOffLine,
RiVideoOnLine,
} from '@remixicon/react'
import {
LocalAudioTrack,
LocalVideoTrack,
Track,
VideoCaptureOptions,
} from 'livekit-client'
import { Shortcut } from '@/features/shortcuts/types'
import { ToggleDevice } from '@/features/rooms/livekit/components/controls/ToggleDevice.tsx'
import { css } from '@/styled-system/css'
import { ButtonRecipeProps } from '@/primitives/buttonRecipe'
import { useEffect, useMemo } from 'react'
import { usePersistentUserChoices } from '../../hooks/usePersistentUserChoices'
import { BackgroundProcessorFactory } from '../blur'
import { useSnapshot } from 'valtio'
import { permissionsStore } from '@/stores/permissions'
export type ToggleSource = Exclude<
Track.Source,
Track.Source.ScreenShareAudio | Track.Source.Unknown
>
type SelectToggleSource = Exclude<ToggleSource, Track.Source.ScreenShare>
export type SelectToggleDeviceConfig = {
kind: MediaDeviceKind
iconOn: RemixiconComponentType
iconOff: RemixiconComponentType
shortcut?: Shortcut
longPress?: Shortcut
}
type SelectToggleDeviceConfigMap = {
[key in SelectToggleSource]: SelectToggleDeviceConfig
}
const selectToggleDeviceConfig: SelectToggleDeviceConfigMap = {
[Track.Source.Microphone]: {
kind: 'audioinput',
iconOn: RiMicLine,
iconOff: RiMicOffLine,
shortcut: {
key: 'd',
ctrlKey: true,
},
longPress: {
key: 'Space',
},
},
[Track.Source.Camera]: {
kind: 'videoinput',
iconOn: RiVideoOnLine,
iconOff: RiVideoOffLine,
shortcut: {
key: 'e',
ctrlKey: true,
},
},
}
type SelectToggleDeviceProps<T extends ToggleSource> =
UseTrackToggleProps<T> & {
track?: LocalAudioTrack | LocalVideoTrack | undefined
initialDeviceId?: string
onActiveDeviceChange: (deviceId: string) => void
source: SelectToggleSource
variant?: NonNullable<ButtonRecipeProps>['variant']
menuVariant?: 'dark' | 'light'
hideMenu?: boolean
}
export const SelectToggleDevice = <T extends ToggleSource>({
track,
initialDeviceId,
onActiveDeviceChange,
hideMenu,
variant = 'primaryDark',
menuVariant = 'light',
...props
}: SelectToggleDeviceProps<T>) => {
const config = selectToggleDeviceConfig[props.source]
if (!config) {
throw new Error('Invalid source')
}
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
const trackProps = useTrackToggle(props)
const { userChoices } = usePersistentUserChoices()
const permissions = useSnapshot(permissionsStore)
const isPermissionDeniedOrPrompted = useMemo(() => {
switch (config.kind) {
case 'audioinput':
return (
permissions.isMicrophoneDenied || permissions.isMicrophonePrompted
)
case 'videoinput':
return permissions.isCameraDenied || permissions.isCameraPrompted
}
}, [permissions, config.kind])
const toggle = () => {
if (props.source === Track.Source.Camera) {
/**
* We need to make sure that we apply the in-memory processor when re-enabling the camera.
* Before, we had the following bug:
* 1 - Configure a processor on join screen
* 2 - Turn off camera on join screen
* 3 - Join the room
* 4 - Turn on the camera
* 5 - No processor is applied to the camera
* Expected: The processor is applied.
*
* See https://github.com/numerique-gouv/meet/pull/309#issuecomment-2622404121
*/
const processor = BackgroundProcessorFactory.deserializeProcessor(
userChoices.processorSerialized
)
const toggle = trackProps.toggle as (
forceState: boolean,
captureOptions: VideoCaptureOptions
) => Promise<void>
toggle(!trackProps.enabled, {
processor: processor,
} as VideoCaptureOptions)
} else {
trackProps.toggle()
}
}
const { devices, activeDeviceId, setActiveMediaDevice } =
useMediaDeviceSelect({ kind: config.kind, track })
/**
* When providing only track outside of a room context, activeDeviceId is undefined.
* So we need to initialize it with the initialDeviceId.
* nb: I don't understand why useMediaDeviceSelect cannot infer it from track device id.
*/
useEffect(() => {
if (initialDeviceId !== undefined) {
setActiveMediaDevice(initialDeviceId)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [setActiveMediaDevice])
const selectLabel = t('choose', { keyPrefix: `join.${config.kind}` })
return (
<div
className={css({
display: 'flex',
gap: '1px',
})}
>
<ToggleDevice
{...trackProps}
config={config}
variant={variant}
toggle={toggle}
isPermissionDeniedOrPrompted={isPermissionDeniedOrPrompted}
toggleButtonProps={{
...(hideMenu
? {
groupPosition: undefined,
}
: {}),
}}
/>
{!hideMenu && (
<Menu variant={menuVariant}>
<Button
isDisabled={isPermissionDeniedOrPrompted}
tooltip={selectLabel}
aria-label={selectLabel}
groupPosition="right"
square
variant={
trackProps.enabled && !isPermissionDeniedOrPrompted
? variant
: 'error2'
}
>
<RiArrowDownSLine />
</Button>
<MenuList
items={devices.map((d) => ({
value: d.deviceId,
label: d.label,
}))}
selectedItem={activeDeviceId}
onAction={(value) => {
setActiveMediaDevice(value as string)
onActiveDeviceChange(value as string)
}}
variant={menuVariant}
/>
</Menu>
)}
</div>
)
}
@@ -1,36 +0,0 @@
import { SettingsDialogExtended } from '@/features/settings/components/SettingsDialogExtended'
import React, { createContext, useContext, useState } from 'react'
const SettingsDialogContext = createContext<
| {
dialogOpen: boolean
setDialogOpen: React.Dispatch<React.SetStateAction<boolean>>
}
| undefined
>(undefined)
export const SettingsDialogProvider: React.FC<{
children: React.ReactNode
}> = ({ children }) => {
const [dialogOpen, setDialogOpen] = useState(false)
return (
<SettingsDialogContext.Provider value={{ dialogOpen, setDialogOpen }}>
{children}
<SettingsDialogExtended
isOpen={dialogOpen}
onOpenChange={(v) => !v && setDialogOpen(false)}
/>
</SettingsDialogContext.Provider>
)
}
// eslint-disable-next-line react-refresh/only-export-components
export const useSettingsDialog = () => {
const context = useContext(SettingsDialogContext)
if (!context) {
throw new Error(
'useSettingsDialog must be used within a SettingsDialogProvider'
)
}
return context
}
@@ -1,99 +0,0 @@
import { ToggleButton } from '@/primitives'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
import { useMemo, useState } from 'react'
import { appendShortcutLabel } from '@/features/shortcuts/utils'
import { useTranslation } from 'react-i18next'
import { SelectToggleDeviceConfig } from './SelectToggleDevice'
import { PermissionNeededButton } from './PermissionNeededButton'
import useLongPress from '@/features/shortcuts/useLongPress'
import { ActiveSpeaker } from '@/features/rooms/components/ActiveSpeaker'
import {
useIsSpeaking,
useLocalParticipant,
useMaybeRoomContext,
} from '@livekit/components-react'
import { ButtonRecipeProps } from '@/primitives/buttonRecipe'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
import { openPermissionsDialog } from '@/stores/permissions'
export type ToggleDeviceProps = {
enabled: boolean
isPermissionDeniedOrPrompted?: boolean
toggle: () => void
config: SelectToggleDeviceConfig
variant?: NonNullable<ButtonRecipeProps>['variant']
toggleButtonProps?: Partial<ToggleButtonProps>
}
export const ToggleDevice = ({
config,
enabled,
toggle,
variant = 'primaryDark',
toggleButtonProps,
isPermissionDeniedOrPrompted,
}: ToggleDeviceProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
const { kind, shortcut, iconOn, iconOff, longPress } = config
const [pushToTalk, setPushToTalk] = useState(false)
const onKeyDown = () => {
if (pushToTalk || enabled) return
toggle()
setPushToTalk(true)
}
const onKeyUp = () => {
if (!pushToTalk) return
toggle()
setPushToTalk(false)
}
useRegisterKeyboardShortcut({ shortcut, handler: toggle })
useLongPress({ keyCode: longPress?.key, onKeyDown, onKeyUp })
const toggleLabel = useMemo(() => {
const label = t(enabled ? 'disable' : 'enable', {
keyPrefix: `join.${kind}`,
})
return shortcut ? appendShortcutLabel(label, shortcut) : label
}, [enabled, kind, shortcut, t])
const Icon = enabled && !isPermissionDeniedOrPrompted ? iconOn : iconOff
const context = useMaybeRoomContext()
if (kind === 'audioinput' && pushToTalk && context) {
return <ActiveSpeakerWrapper />
}
return (
<div style={{ position: 'relative' }}>
{isPermissionDeniedOrPrompted && <PermissionNeededButton />}
<ToggleButton
isSelected={!enabled}
variant={enabled && !isPermissionDeniedOrPrompted ? variant : 'error2'}
shySelected
onPress={() =>
isPermissionDeniedOrPrompted ? openPermissionsDialog() : toggle()
}
aria-label={toggleLabel}
tooltip={
isPermissionDeniedOrPrompted
? t('tooltip', { keyPrefix: 'permissionsButton' })
: toggleLabel
}
groupPosition="left"
{...toggleButtonProps}
>
<Icon />
</ToggleButton>
</div>
)
}
const ActiveSpeakerWrapper = () => {
const { localParticipant } = useLocalParticipant()
const isSpeaking = useIsSpeaking(localParticipant)
return <ActiveSpeaker isSpeaking={isSpeaking} pushToTalk />
}
@@ -0,0 +1,35 @@
import { useSnapshot } from 'valtio'
import { useMemo } from 'react'
import { permissionsStore } from '@/stores/permissions'
export const useCannotUseDevice = (kind: MediaDeviceKind) => {
const {
isLoading,
isMicrophoneDenied,
isMicrophonePrompted,
isCameraDenied,
isCameraPrompted,
} = useSnapshot(permissionsStore)
return useMemo(() => {
if (isLoading) return true
switch (kind) {
case 'audioinput':
case 'audiooutput': // audiooutput uses microphone permissions
return isMicrophoneDenied || isMicrophonePrompted
case 'videoinput':
return isCameraDenied || isCameraPrompted
default:
return false
}
}, [
kind,
isLoading,
isMicrophoneDenied,
isMicrophonePrompted,
isCameraDenied,
isCameraPrompted,
])
}
@@ -0,0 +1,37 @@
import {
RemixiconComponentType,
RiMicLine,
RiMicOffLine,
RiVideoOffLine,
RiVideoOnLine,
RiVolumeDownLine,
RiVolumeMuteLine,
} from '@remixicon/react'
export interface DeviceIcons {
toggleOn: RemixiconComponentType
toggleOff: RemixiconComponentType
select: RemixiconComponentType
}
const ICONS: Record<MediaDeviceKind | 'default', DeviceIcons> = {
audioinput: {
toggleOn: RiMicLine,
toggleOff: RiMicOffLine,
select: RiMicLine,
},
videoinput: {
toggleOn: RiVideoOnLine,
toggleOff: RiVideoOffLine,
select: RiVideoOnLine,
},
audiooutput: {
toggleOn: RiVolumeDownLine,
toggleOff: RiVolumeMuteLine,
select: RiVolumeDownLine,
},
default: { toggleOn: RiMicLine, toggleOff: RiMicOffLine, select: RiMicLine },
}
export const useDeviceIcons = (kind: MediaDeviceKind): DeviceIcons =>
ICONS[kind] ?? ICONS.default
@@ -0,0 +1,21 @@
import { useMemo } from 'react'
import { Shortcut } from '@/features/shortcuts/types'
export const useDeviceShortcut = (kind: MediaDeviceKind) => {
return useMemo<Shortcut | undefined>(() => {
switch (kind) {
case 'audioinput':
return {
key: 'e',
ctrlKey: true,
}
case 'videoinput':
return {
key: 'd',
ctrlKey: true,
}
default:
return undefined
}
}, [kind])
}
@@ -1,6 +1,8 @@
import { useSnapshot } from 'valtio'
import { userChoicesStore } from '@/stores/userChoices'
import type { VideoResolution } from '@/stores/userChoices'
import { ProcessorSerialized } from '@/features/rooms/livekit/components/blur'
import type { VideoQuality } from 'livekit-client'
export function usePersistentUserChoices() {
const userChoicesSnap = useSnapshot(userChoicesStore)
@@ -16,9 +18,18 @@ export function usePersistentUserChoices() {
saveAudioInputDeviceId: (deviceId: string) => {
userChoicesStore.audioDeviceId = deviceId
},
saveAudioOutputDeviceId: (deviceId: string) => {
userChoicesStore.audioOutputDeviceId = deviceId
},
saveVideoInputDeviceId: (deviceId: string) => {
userChoicesStore.videoDeviceId = deviceId
},
saveVideoPublishResolution: (resolution: VideoResolution) => {
userChoicesStore.videoPublishResolution = resolution
},
saveVideoSubscribeQuality: (quality: VideoQuality) => {
userChoicesStore.videoSubscribeQuality = quality
},
saveUsername: (username: string) => {
userChoicesStore.username = username
},
@@ -0,0 +1,22 @@
import { useEffect, useRef } from 'react'
export const useResolveInitiallyDefaultDeviceId = <
T extends { getDeviceId(): Promise<string | undefined> },
>(
currentId: string,
track: T | undefined,
save: (id: string) => void
) => {
const isInitiated = useRef(false)
useEffect(() => {
if (currentId !== 'default' || !track || isInitiated.current) return
const resolveDefaultDeviceId = async () => {
const actualDeviceId = await track.getDeviceId()
if (actualDeviceId && actualDeviceId !== 'default') {
isInitiated.current = true
save(actualDeviceId)
}
}
resolveDefaultDeviceId()
}, [currentId, track, save])
}
@@ -0,0 +1,51 @@
import { useEffect } from 'react'
import { usePersistentUserChoices } from './usePersistentUserChoices'
import { useRoomContext } from '@livekit/components-react'
import {
RemoteParticipant,
RemoteTrackPublication,
RoomEvent,
Track,
VideoQuality,
} from 'livekit-client'
/**
* This hook sets initial video quality for new participants as they join.
* LiveKit doesn't allow handling video quality preferences at the room level.
*/
export const useVideoResolutionSubscription = () => {
const {
userChoices: { videoSubscribeQuality },
} = usePersistentUserChoices()
const room = useRoomContext()
useEffect(() => {
if (!room) return
const handleTrackPublished = (
publication: RemoteTrackPublication,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_participant: RemoteParticipant
) => {
// By default, the maximum quality is set to high
if (
videoSubscribeQuality === undefined ||
videoSubscribeQuality === VideoQuality.HIGH
)
return
if (
publication.kind === Track.Kind.Video &&
publication.source !== Track.Source.ScreenShare
) {
publication.setVideoQuality(videoSubscribeQuality)
}
}
room.on(RoomEvent.TrackPublished, handleTrackPublished)
return () => {
room.off(RoomEvent.TrackPublished, handleTrackPublished)
}
}, [room, videoSubscribeQuality])
}
@@ -3,98 +3,23 @@ import * as React from 'react'
import { MobileControlBar } from './MobileControlBar'
import { DesktopControlBar } from './DesktopControlBar'
import { SettingsDialogProvider } from '../../components/controls/SettingsDialogContext'
import { useIsMobile } from '@/utils/useIsMobile'
import { usePersistentUserChoices } from '../../hooks/usePersistentUserChoices'
/** @public */
export type ControlBarControls = {
microphone?: boolean
camera?: boolean
chat?: boolean
screenShare?: boolean
leave?: boolean
settings?: boolean
}
/** @public */
export interface ControlBarProps extends React.HTMLAttributes<HTMLDivElement> {
onDeviceError?: (error: { source: Track.Source; error: Error }) => void
variation?: 'minimal' | 'verbose' | 'textOnly'
controls?: ControlBarControls
/**
* If `true`, the user's device choices will be persisted.
* This will enable the user to have the same device choices when they rejoin the room.
* @defaultValue true
* @alpha
*/
saveUserChoices?: boolean
}
/**
* The `ControlBar` prefab gives the user the basic user interface to control their
* media devices (camera, microphone and screen share), open the `Chat` and leave the room.
*
* @remarks
* This component is build with other LiveKit components like `TrackToggle`,
* `DeviceSelectorButton`, `DisconnectButton` and `StartAudio`.
*
* @example
* ```tsx
* <LiveKitRoom>
* <ControlBar />
* </LiveKitRoom>
* ```
* @public
*/
export function ControlBar({ onDeviceError }: ControlBarProps) {
const {
saveAudioInputEnabled,
saveVideoInputEnabled,
saveAudioInputDeviceId,
saveVideoInputDeviceId,
} = usePersistentUserChoices()
const microphoneOnChange = React.useCallback(
(enabled: boolean, isUserInitiated: boolean) =>
isUserInitiated ? saveAudioInputEnabled(enabled) : null,
[saveAudioInputEnabled]
)
const cameraOnChange = React.useCallback(
(enabled: boolean, isUserInitiated: boolean) =>
isUserInitiated ? saveVideoInputEnabled(enabled) : null,
[saveVideoInputEnabled]
)
const barProps = {
onDeviceError,
microphoneOnChange,
cameraOnChange,
saveAudioInputDeviceId,
saveVideoInputDeviceId,
}
const isMobile = useIsMobile()
return (
<SettingsDialogProvider>
{isMobile ? (
<MobileControlBar {...barProps} />
) : (
<DesktopControlBar {...barProps} />
)}
</SettingsDialogProvider>
)
if (isMobile) {
return <MobileControlBar onDeviceError={onDeviceError} />
}
return <DesktopControlBar onDeviceError={onDeviceError} />
}
export interface ControlBarAuxProps {
onDeviceError: ControlBarProps['onDeviceError']
microphoneOnChange: (
enabled: boolean,
isUserInitiated: boolean
) => void | null
cameraOnChange: (enabled: boolean, isUserInitiated: boolean) => void | null
saveAudioInputDeviceId: (deviceId: string) => void
saveVideoInputDeviceId: (deviceId: string) => void
}
export type ControlBarAuxProps = Pick<ControlBarProps, 'onDeviceError'>
@@ -2,7 +2,6 @@ import { supportsScreenSharing } from '@livekit/components-core'
import { ControlBarAuxProps } from './ControlBar'
import { css } from '@/styled-system/css'
import { LeaveButton } from '../../components/controls/LeaveButton'
import { SelectToggleDevice } from '../../components/controls/SelectToggleDevice'
import { Track } from 'livekit-client'
import { ReactionsToggle } from '../../components/controls/ReactionsToggle'
import { HandToggle } from '../../components/controls/HandToggle'
@@ -11,14 +10,12 @@ import { OptionsButton } from '../../components/controls/Options/OptionsButton'
import { StartMediaButton } from '../../components/controls/StartMediaButton'
import { MoreOptions } from './MoreOptions'
import { useRef } from 'react'
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
import { AudioDevicesControl } from '../../components/controls/Device/AudioDevicesControl'
export function DesktopControlBar({
onDeviceError,
microphoneOnChange,
cameraOnChange,
saveAudioInputDeviceId,
saveVideoInputDeviceId,
}: ControlBarAuxProps) {
}: Readonly<ControlBarAuxProps>) {
const browserSupportsScreenSharing = supportsScreenSharing()
const desktopControlBarEl = useRef<HTMLDivElement>(null)
return (
@@ -53,27 +50,15 @@ export function DesktopControlBar({
gap: '0.65rem',
})}
>
<SelectToggleDevice
source={Track.Source.Microphone}
onChange={microphoneOnChange}
<AudioDevicesControl
onDeviceError={(error) =>
onDeviceError?.({ source: Track.Source.Microphone, error })
}
onActiveDeviceChange={(deviceId) =>
saveAudioInputDeviceId(deviceId ?? '')
}
menuVariant="dark"
/>
<SelectToggleDevice
source={Track.Source.Camera}
onChange={cameraOnChange}
<VideoDeviceControl
onDeviceError={(error) =>
onDeviceError?.({ source: Track.Source.Camera, error })
}
onActiveDeviceChange={(deviceId) =>
saveVideoInputDeviceId(deviceId ?? '')
}
menuVariant="dark"
/>
<ReactionsToggle />
{browserSupportsScreenSharing && (
@@ -4,7 +4,6 @@ import { ControlBarAuxProps } from './ControlBar'
import React from 'react'
import { css } from '@/styled-system/css'
import { LeaveButton } from '../../components/controls/LeaveButton'
import { SelectToggleDevice } from '../../components/controls/SelectToggleDevice'
import { Track } from 'livekit-client'
import { HandToggle } from '../../components/controls/HandToggle'
import { Button } from '@/primitives/Button'
@@ -19,24 +18,22 @@ import { ChatToggle } from '../../components/controls/ChatToggle'
import { ParticipantsToggle } from '../../components/controls/Participants/ParticipantsToggle'
import { useSidePanel } from '../../hooks/useSidePanel'
import { LinkButton } from '@/primitives'
import { useSettingsDialog } from '../../components/controls/SettingsDialogContext'
import { ResponsiveMenu } from './ResponsiveMenu'
import { ToolsToggle } from '../../components/controls/ToolsToggle'
import { CameraSwitchButton } from '../../components/controls/CameraSwitchButton'
import { useConfig } from '@/api/useConfig'
import { AudioDevicesControl } from '../../components/controls/Device/AudioDevicesControl'
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
import { useSettingsDialog } from '@/features/settings/hook/useSettingsDialog'
export function MobileControlBar({
onDeviceError,
microphoneOnChange,
cameraOnChange,
saveAudioInputDeviceId,
saveVideoInputDeviceId,
}: ControlBarAuxProps) {
}: Readonly<ControlBarAuxProps>) {
const { t } = useTranslation('rooms')
const [isMenuOpened, setIsMenuOpened] = React.useState(false)
const browserSupportsScreenSharing = supportsScreenSharing()
const { toggleEffects } = useSidePanel()
const { setDialogOpen } = useSettingsDialog()
const { openSettingsDialog } = useSettingsDialog()
const { data } = useConfig()
@@ -62,27 +59,15 @@ export function MobileControlBar({
})}
>
<LeaveButton />
<SelectToggleDevice
source={Track.Source.Microphone}
onChange={microphoneOnChange}
<AudioDevicesControl
onDeviceError={(error) =>
onDeviceError?.({ source: Track.Source.Microphone, error })
}
onActiveDeviceChange={(deviceId) =>
saveAudioInputDeviceId(deviceId ?? '')
}
hideMenu={true}
/>
<SelectToggleDevice
source={Track.Source.Camera}
onChange={cameraOnChange}
<VideoDeviceControl
onDeviceError={(error) =>
onDeviceError?.({ source: Track.Source.Camera, error })
}
onActiveDeviceChange={(deviceId) =>
saveVideoInputDeviceId(deviceId ?? '')
}
hideMenu={true}
/>
<HandToggle />
<Button
@@ -167,7 +152,7 @@ export function MobileControlBar({
)}
<Button
onPress={() => {
setDialogOpen(true)
openSettingsDialog()
setIsMenuOpened(false)
}}
variant="primaryTextDark"
@@ -32,6 +32,8 @@ import { RecordingStateToast } from '@/features/recording'
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
import { useConnectionObserver } from '../hooks/useConnectionObserver'
import { useNoiseReduction } from '../hooks/useNoiseReduction'
import { useVideoResolutionSubscription } from '../hooks/useVideoResolutionSubscription'
import { SettingsDialogProvider } from '@/features/settings/components/SettingsDialogProvider'
const LayoutWrapper = styled(
'div',
@@ -77,6 +79,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
React.useRef<TrackReferenceOrPlaceholder | null>(null)
useConnectionObserver()
useVideoResolutionSubscription()
const tracks = useTracks(
[
@@ -238,6 +241,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
<RoomAudioRenderer />
<ConnectionStateToast />
<RecordingStateToast />
<SettingsDialogProvider />
</div>
)
}
@@ -9,13 +9,16 @@ import {
RiNotification3Line,
RiSettings3Line,
RiSpeakerLine,
RiVideoOnLine,
} from '@remixicon/react'
import { AccountTab } from './tabs/AccountTab'
import { NotificationsTab } from './tabs/NotificationsTab'
import { GeneralTab } from './tabs/GeneralTab'
import { AudioTab } from './tabs/AudioTab'
import { VideoTab } from './tabs/VideoTab'
import { useRef } from 'react'
import { useMediaQuery } from '@/features/rooms/livekit/hooks/useMediaQuery'
import { SettingsDialogExtendedKey } from '@/features/settings/type'
const tabsStyle = css({
maxHeight: '40.625rem', // fixme size copied from meet settings modal
@@ -44,7 +47,9 @@ const tabPanelContainerStyle = css({
export type SettingsDialogExtended = Pick<
DialogProps,
'isOpen' | 'onOpenChange'
>
> & {
defaultSelectedTab?: SettingsDialogExtendedKey
}
export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
// display only icon on small screen
@@ -55,7 +60,11 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
return (
<Dialog innerRef={dialogEl} {...props} role="dialog" type="flex">
<Tabs orientation="vertical" className={tabsStyle}>
<Tabs
orientation="vertical"
className={tabsStyle}
defaultSelectedKey={props.defaultSelectedTab}
>
<div
className={tabListContainerStyle}
style={{
@@ -69,30 +78,39 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
{t('dialog.heading')}
</Heading>
)}
<TabList border={false} aria-label="Chat log orientation example">
<Tab icon highlight id="1">
<TabList border={false}>
<Tab icon highlight id={SettingsDialogExtendedKey.ACCOUNT}>
<RiAccountCircleLine />
{isWideScreen && t('tabs.account')}
{isWideScreen && t(`tabs.${SettingsDialogExtendedKey.ACCOUNT}`)}
</Tab>
<Tab icon highlight id="2">
<Tab icon highlight id={SettingsDialogExtendedKey.AUDIO}>
<RiSpeakerLine />
{isWideScreen && t('tabs.audio')}
{isWideScreen && t(`tabs.${SettingsDialogExtendedKey.AUDIO}`)}
</Tab>
<Tab icon highlight id="3">
<Tab icon highlight id={SettingsDialogExtendedKey.VIDEO}>
<RiVideoOnLine />
{isWideScreen && t(`tabs.${SettingsDialogExtendedKey.VIDEO}`)}
</Tab>
<Tab icon highlight id={SettingsDialogExtendedKey.GENERAL}>
<RiSettings3Line />
{isWideScreen && t('tabs.general')}
{isWideScreen && t(`tabs.${SettingsDialogExtendedKey.GENERAL}`)}
</Tab>
<Tab icon highlight id="4">
<Tab icon highlight id={SettingsDialogExtendedKey.NOTIFICATIONS}>
<RiNotification3Line />
{isWideScreen && t('tabs.notifications')}
{isWideScreen &&
t(`tabs.${SettingsDialogExtendedKey.NOTIFICATIONS}`)}
</Tab>
</TabList>
</div>
<div className={tabPanelContainerStyle}>
<AccountTab id="1" onOpenChange={props.onOpenChange} />
<AudioTab id="2" />
<GeneralTab id="3" />
<NotificationsTab id="4" />
<AccountTab
id={SettingsDialogExtendedKey.ACCOUNT}
onOpenChange={props.onOpenChange}
/>
<AudioTab id={SettingsDialogExtendedKey.AUDIO} />
<VideoTab id={SettingsDialogExtendedKey.VIDEO} />
<GeneralTab id={SettingsDialogExtendedKey.GENERAL} />
<NotificationsTab id={SettingsDialogExtendedKey.NOTIFICATIONS} />
</div>
</Tabs>
</Dialog>
@@ -0,0 +1,20 @@
import { SettingsDialogExtended } from './SettingsDialogExtended'
import { useSnapshot } from 'valtio'
import { settingsStore } from '@/stores/settings'
export const SettingsDialogProvider = () => {
const { areSettingsOpen, defaultSelectedTab } = useSnapshot(settingsStore)
return (
<SettingsDialogExtended
isOpen={areSettingsOpen}
defaultSelectedTab={defaultSelectedTab}
onOpenChange={(v) => {
if (!v && settingsStore.defaultSelectedTab) {
settingsStore.defaultSelectedTab = undefined
}
settingsStore.areSettingsOpen = v
}}
/>
)
}
@@ -1,4 +1,4 @@
import { DialogProps, Field, H, Switch, Text } from '@/primitives'
import { DialogProps, Field, Switch, Text } from '@/primitives'
import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
import {
@@ -9,78 +9,11 @@ import {
import { isSafari } from '@/utils/livekit'
import { useTranslation } from 'react-i18next'
import { SoundTester } from '@/components/SoundTester'
import { HStack } from '@/styled-system/jsx'
import { ActiveSpeaker } from '@/features/rooms/components/ActiveSpeaker'
import { usePersistentUserChoices } from '@/features/rooms/livekit/hooks/usePersistentUserChoices'
import { ReactNode } from 'react'
import { css } from '@/styled-system/css'
import posthog from 'posthog-js'
import { useNoiseReductionAvailable } from '@/features/rooms/livekit/hooks/useNoiseReductionAvailable'
type RowWrapperProps = {
heading: string
children: ReactNode[]
beta?: boolean
}
const BetaBadge = () => (
<span
className={css({
content: '"Beta"',
display: 'block',
letterSpacing: '-0.02rem',
padding: '0 0.25rem',
backgroundColor: '#E8EDFF',
color: '#0063CB',
fontSize: '12px',
fontWeight: 500,
margin: '0 0 0.9375rem 0.3125rem',
lineHeight: '1rem',
borderRadius: '4px',
width: 'fit-content',
height: 'fit-content',
marginTop: { base: '10px', sm: '5px' },
})}
>
Beta
</span>
)
const RowWrapper = ({ heading, children, beta }: RowWrapperProps) => {
return (
<>
<HStack>
<H lvl={2}>{heading}</H>
{beta && <BetaBadge />}
</HStack>
<HStack
gap={0}
style={{
flexWrap: 'wrap',
}}
>
<div
style={{
flex: '1 1 215px',
minWidth: 0,
}}
>
{children[0]}
</div>
<div
style={{
width: '10rem',
justifyContent: 'center',
display: 'flex',
paddingLeft: '1.5rem',
}}
>
{children[1]}
</div>
</HStack>
</>
)
}
import posthog from 'posthog-js'
import { RowWrapper } from './layout/RowWrapper'
export type AudioTabProps = Pick<DialogProps, 'onOpenChange'> &
Pick<TabPanelProps, 'id'>
@@ -92,24 +25,19 @@ export const AudioTab = ({ id }: AudioTabProps) => {
const { localParticipant } = useRoomContext()
const {
userChoices: { noiseReductionEnabled },
userChoices: { noiseReductionEnabled, audioDeviceId, audioOutputDeviceId },
saveAudioInputDeviceId,
saveNoiseReductionEnabled,
saveAudioOutputDeviceId,
} = usePersistentUserChoices()
const isSpeaking = useIsSpeaking(localParticipant)
const {
devices: devicesOut,
activeDeviceId: activeDeviceIdOut,
setActiveMediaDevice: setActiveMediaDeviceOut,
} = useMediaDeviceSelect({ kind: 'audiooutput' })
const { devices: devicesOut, setActiveMediaDevice: setActiveMediaDeviceOut } =
useMediaDeviceSelect({ kind: 'audiooutput' })
const {
devices: devicesIn,
activeDeviceId: activeDeviceIdIn,
setActiveMediaDevice: setActiveMediaDeviceIn,
} = useMediaDeviceSelect({ kind: 'audioinput' })
const { devices: devicesIn, setActiveMediaDevice: setActiveMediaDeviceIn } =
useMediaDeviceSelect({ kind: 'audioinput' })
const itemsOut: DeviceItems = devicesOut.map((d) => ({
value: d.deviceId,
@@ -134,15 +62,6 @@ export const AudioTab = ({ id }: AudioTabProps) => {
defaultSelectedKey: undefined,
}
// No API to directly query the default audio device; this function heuristically finds it.
// Returns the item with value 'default' if present; otherwise, returns the first item in the list.
const getDefaultSelectedKey = (items: DeviceItems) => {
if (!items || items.length === 0) return
const defaultItem =
items.find((item) => item.value === 'default') || items[0]
return defaultItem.value
}
const noiseReductionAvailable = useNoiseReductionAvailable()
return (
@@ -152,11 +71,9 @@ export const AudioTab = ({ id }: AudioTabProps) => {
type="select"
label={t('audio.microphone.label')}
items={itemsIn}
defaultSelectedKey={
activeDeviceIdIn || getDefaultSelectedKey(itemsIn)
}
onSelectionChange={(key) => {
setActiveMediaDeviceIn(key as string)
selectedKey={audioDeviceId}
onSelectionChange={async (key) => {
await setActiveMediaDeviceIn(key as string)
saveAudioInputDeviceId(key as string)
}}
{...disabledProps}
@@ -180,12 +97,11 @@ export const AudioTab = ({ id }: AudioTabProps) => {
type="select"
label={t('audio.speakers.label')}
items={itemsOut}
defaultSelectedKey={
activeDeviceIdOut || getDefaultSelectedKey(itemsOut)
}
onSelectionChange={async (key) =>
setActiveMediaDeviceOut(key as string)
}
selectedKey={audioOutputDeviceId}
onSelectionChange={async (key) => {
await setActiveMediaDeviceOut(key as string)
saveAudioOutputDeviceId(key as string)
}}
{...disabledProps}
style={{
minWidth: 0,
@@ -0,0 +1,238 @@
import { DialogProps, Field } from '@/primitives'
import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
import { useMediaDeviceSelect, useRoomContext } from '@livekit/components-react'
import { useTranslation } from 'react-i18next'
import { usePersistentUserChoices } from '@/features/rooms/livekit/hooks/usePersistentUserChoices'
import { useCallback, useEffect, useState } from 'react'
import { css } from '@/styled-system/css'
import {
createLocalVideoTrack,
LocalVideoTrack,
Track,
VideoPresets,
VideoQuality,
} from 'livekit-client'
import { BackgroundProcessorFactory } from '@/features/rooms/livekit/components/blur'
import { VideoResolution } from '@/stores/userChoices'
import { RowWrapper } from './layout/RowWrapper'
export type VideoTabProps = Pick<DialogProps, 'onOpenChange'> &
Pick<TabPanelProps, 'id'>
type DeviceItems = Array<{ value: string; label: string }>
export const VideoTab = ({ id }: VideoTabProps) => {
const { t } = useTranslation('settings', { keyPrefix: 'video' })
const { localParticipant, remoteParticipants } = useRoomContext()
const {
userChoices: {
videoDeviceId,
processorSerialized,
videoPublishResolution,
videoSubscribeQuality,
},
saveVideoInputDeviceId,
saveVideoPublishResolution,
saveVideoSubscribeQuality,
} = usePersistentUserChoices()
const [videoElement, setVideoElement] = useState<HTMLVideoElement | null>(
null
)
const videoCallbackRef = useCallback((element: HTMLVideoElement | null) => {
setVideoElement(element)
}, [])
const { devices: devicesIn, setActiveMediaDevice: setActiveMediaDeviceIn } =
useMediaDeviceSelect({ kind: 'videoinput' })
const itemsIn: DeviceItems = devicesIn.map((d) => ({
value: d.deviceId,
label: d.label,
}))
// The Permissions API is not fully supported in Firefox and Safari, and attempting to use it for camera permissions
// may raise an error. As a workaround, we infer camera permission status by checking if the list of camera input
// devices (devicesIn) is non-empty. If the list has one or more devices, we assume the user has granted camera access.
const isCamEnabled = devicesIn?.length > 0
const disabledProps = isCamEnabled
? {}
: {
placeholder: t('permissionsRequired'),
isDisabled: true,
}
const handleVideoResolutionChange = async (key: 'h720' | 'h360' | 'h180') => {
const videoPublication = localParticipant.getTrackPublication(
Track.Source.Camera
)
const videoTrack = videoPublication?.track
if (videoTrack) {
saveVideoPublishResolution(key)
await videoTrack.restartTrack({
resolution: VideoPresets[key].resolution,
deviceId: { exact: videoDeviceId },
processor:
BackgroundProcessorFactory.deserializeProcessor(processorSerialized),
})
}
}
/**
* Updates video quality for all existing remote video tracks when user preference changes.
* LiveKit doesn't support setting video quality preferences at the room level for remote participants,
* so this function applies the selected quality to all existing remote video tracks.
* Hook useVideoResolutionSubscription updates quality preferences of new participants joining.
*/
const updateExistingRemoteVideoQuality = (selectedQuality: VideoQuality) => {
remoteParticipants.forEach((participant) => {
participant.videoTrackPublications.forEach((publication) => {
if (publication.videoQuality !== selectedQuality) {
publication.setVideoQuality(selectedQuality)
}
})
})
}
useEffect(() => {
let videoTrack: LocalVideoTrack | null = null
const setUpVideoTrack = async () => {
if (videoElement) {
videoTrack = await createLocalVideoTrack({ deviceId: videoDeviceId })
videoTrack.attach(videoElement)
}
}
setUpVideoTrack()
return () => {
if (videoElement && videoTrack) {
videoTrack.detach()
videoTrack.stop()
}
}
}, [videoDeviceId, videoElement])
return (
<TabPanel padding={'md'} flex id={id}>
<RowWrapper heading={t('camera.heading')}>
<Field
type="select"
label={t('camera.label')}
items={itemsIn}
selectedKey={videoDeviceId}
onSelectionChange={async (key) => {
await setActiveMediaDeviceIn(key as string)
saveVideoInputDeviceId(key as string)
}}
{...disabledProps}
style={{
width: '100%',
}}
/>
<div
role="status"
aria-label={t(
`camera.previewAriaLabel.${localParticipant.isCameraEnabled ? 'enabled' : 'disabled'}`
)}
>
{localParticipant.isCameraEnabled ? (
<>
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<video
ref={videoCallbackRef}
width="160px"
height="56px"
style={{
display: !localParticipant.isCameraEnabled
? 'none'
: undefined,
}}
className={css({
transform: 'rotateY(180deg)',
height: '69px',
width: '160px',
})}
disablePictureInPicture
disableRemotePlayback
/>
</>
) : (
<span
className={css({
display: 'flex',
justifyContent: 'center',
textAlign: 'center',
})}
>
{t('camera.disabled')}
</span>
)}
</div>
</RowWrapper>
<RowWrapper heading={t('resolution.heading')}>
<Field
type="select"
label={t('resolution.publish.label')}
items={[
{
value: 'h720',
label: `${t('resolution.publish.items.high')} (720p)`,
},
{
value: 'h360',
label: `${t('resolution.publish.items.medium')} (360p)`,
},
{
value: 'h180',
label: `${t('resolution.publish.items.low')} (180p)`,
},
]}
selectedKey={videoPublishResolution}
onSelectionChange={async (key) => {
await handleVideoResolutionChange(key as VideoResolution)
}}
style={{
width: '100%',
}}
/>
<></>
</RowWrapper>
<RowWrapper>
<Field
type="select"
label={t('resolution.subscribe.label')}
items={[
{
value: VideoQuality.HIGH.toString(),
label: t('resolution.subscribe.items.high'),
},
{
value: VideoQuality.MEDIUM.toString(),
label: t('resolution.subscribe.items.medium'),
},
{
value: VideoQuality.LOW.toString(),
label: t('resolution.subscribe.items.low'),
},
]}
selectedKey={videoSubscribeQuality?.toString()}
onSelectionChange={(key) => {
if (key == undefined) return
const selectedQuality = Number(String(key))
saveVideoSubscribeQuality(selectedQuality)
updateExistingRemoteVideoQuality(selectedQuality)
}}
style={{
width: '100%',
}}
/>
<></>
</RowWrapper>
</TabPanel>
)
}
@@ -0,0 +1,71 @@
import { ReactNode } from 'react'
import { H } from '@/primitives'
import { HStack } from '@/styled-system/jsx'
import { css } from '@/styled-system/css'
export type RowWrapperProps = {
heading?: string
children: ReactNode[]
beta?: boolean
}
const BetaBadge = () => (
<span
className={css({
content: '"Beta"',
display: 'block',
letterSpacing: '-0.02rem',
padding: '0 0.25rem',
backgroundColor: '#E8EDFF',
color: '#0063CB',
fontSize: '12px',
fontWeight: 500,
margin: '0 0 0.9375rem 0.3125rem',
lineHeight: '1rem',
borderRadius: '4px',
width: 'fit-content',
height: 'fit-content',
marginTop: { base: '10px', sm: '5px' },
})}
>
Beta
</span>
)
export const RowWrapper = ({ heading, children, beta }: RowWrapperProps) => {
return (
<>
{heading && (
<HStack>
<H lvl={2}>{heading}</H>
{beta && <BetaBadge />}
</HStack>
)}
<HStack
gap={0}
style={{
flexWrap: 'wrap',
}}
>
<div
style={{
flex: '1 1 215px',
minWidth: 0,
}}
>
{children[0]}
</div>
<div
style={{
width: '10rem',
justifyContent: 'center',
display: 'flex',
paddingLeft: '1.5rem',
}}
>
{children[1]}
</div>
</HStack>
</>
)
}
@@ -0,0 +1,20 @@
import { useSnapshot } from 'valtio/index'
import { settingsStore } from '@/stores/settings'
import { SettingsDialogExtendedKey } from '@/features/settings/type'
export const useSettingsDialog = () => {
const { areSettingsOpen } = useSnapshot(settingsStore)
const openSettingsDialog = (
defaultSelectedTab?: SettingsDialogExtendedKey
) => {
if (areSettingsOpen) return
if (defaultSelectedTab)
settingsStore.defaultSelectedTab = defaultSelectedTab
settingsStore.areSettingsOpen = true
}
return {
openSettingsDialog,
}
}
@@ -0,0 +1,7 @@
export enum SettingsDialogExtendedKey {
ACCOUNT = 'account',
AUDIO = 'audio',
VIDEO = 'video',
GENERAL = 'general',
NOTIFICATIONS = 'notifications',
}
@@ -10,7 +10,7 @@ export const useKeyboardShortcuts = () => {
useEffect(() => {
// This approach handles basic shortcuts but isn't comprehensive.
// Issues might occur. First draft.
const onKeyDown = (e: KeyboardEvent) => {
const onKeyDown = async (e: KeyboardEvent) => {
const { key, metaKey, ctrlKey } = e
if (!key) return
const shortcutKey = formatShortcutKey({
@@ -20,7 +20,7 @@ export const useKeyboardShortcuts = () => {
const shortcut = shortcutsSnap.shortcuts.get(shortcutKey)
if (!shortcut) return
e.preventDefault()
shortcut()
await shortcut()
}
window.addEventListener('keydown', onKeyDown)
@@ -5,6 +5,7 @@ export type useLongPressProps = {
onKeyDown: () => void
onKeyUp: () => void
longPressThreshold?: number
isDisabled?: boolean
}
export const useLongPress = ({
@@ -12,10 +13,18 @@ export const useLongPress = ({
onKeyDown,
onKeyUp,
longPressThreshold = 300,
isDisabled = false,
}: useLongPressProps) => {
const timeoutIdRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
if (isDisabled) {
if (timeoutIdRef.current) {
clearTimeout(timeoutIdRef.current)
timeoutIdRef.current = null
}
return
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.code != keyCode || timeoutIdRef.current) return
timeoutIdRef.current = setTimeout(() => {
@@ -38,8 +47,12 @@ export const useLongPress = ({
return () => {
window.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('keyup', handleKeyUp)
if (timeoutIdRef.current) {
clearTimeout(timeoutIdRef.current)
timeoutIdRef.current = null
}
}
}, [keyCode, onKeyDown, onKeyUp, longPressThreshold])
}, [keyCode, onKeyDown, onKeyUp, longPressThreshold, isDisabled])
return
}
@@ -5,15 +5,22 @@ import { Shortcut } from '@/features/shortcuts/types'
export type useRegisterKeyboardShortcutProps = {
shortcut?: Shortcut
handler: () => void
handler: () => Promise<void | boolean | undefined> | void
isDisabled?: boolean
}
export const useRegisterKeyboardShortcut = ({
shortcut,
handler,
isDisabled = false,
}: useRegisterKeyboardShortcutProps) => {
useEffect(() => {
if (!shortcut) return
keyboardShortcutsStore.shortcuts.set(formatShortcutKey(shortcut), handler)
}, [handler, shortcut])
const formattedKey = formatShortcutKey(shortcut)
if (isDisabled) {
keyboardShortcutsStore.shortcuts.delete(formattedKey)
} else {
keyboardShortcutsStore.shortcuts.set(formattedKey, handler)
}
}, [handler, shortcut, isDisabled])
}
+17 -1
View File
@@ -7,9 +7,18 @@
"home": "Zur Startseite zurückkehren",
"back": "Dem Meeting erneut beitreten"
},
"join": {
"selectDevice": {
"loading": "Laden…",
"select": "Wählen Sie einen Wert",
"permissionsNeeded": "Genehmigung erforderlich",
"settings": {
"audio": "Audioeinstellungen",
"video": "Videoeinstellungen"
},
"effects": "Effekte anwenden",
"videoinput": {
"choose": "Kamera auswählen",
"permissionsNeeded": "Kamera auswählen - genehmigung erforderlich",
"disable": "Kamera deaktivieren",
"enable": "Kamera aktivieren",
"label": "Kamera",
@@ -17,10 +26,17 @@
},
"audioinput": {
"choose": "Mikrofon auswählen",
"permissionsNeeded": "Mikrofon auswählen - genehmigung erforderlich",
"disable": "Mikrofon deaktivieren",
"enable": "Mikrofon aktivieren",
"label": "Mikrofon"
},
"audiooutput": {
"choose": "Lautsprecher auswählen",
"permissionsNeeded": "Lautsprecher auswählen - genehmigung erforderlich"
}
},
"join": {
"effects": {
"description": "Effekte anwenden",
"title": "Effekte",
+32
View File
@@ -30,6 +30,37 @@
},
"permissionsRequired": "Berechtigungen erforderlich"
},
"video": {
"camera": {
"heading": "Kamera",
"label": "Wählen Sie Ihre Videoeingabe aus",
"disabled": "Kamera deaktiviert",
"previewAriaLabel": {
"enabled": "Videovorschau aktiviert",
"disabled": "Videovorschau deaktiviert"
}
},
"resolution": {
"heading": "Auflösung",
"publish": {
"label": "Wählen Sie Ihre maximale Sendeauflösung (max.)",
"items": {
"high": "Hohe Auflösung",
"medium": "Standardauflösung",
"low": "Niedrige Auflösung"
}
},
"subscribe": {
"label": "Wählen Sie Ihre Empfangsauflösung (max.)",
"items": {
"high": "Hohe Auflösung (automatisch)",
"medium": "Standardauflösung",
"low": "Niedrige Auflösung"
}
}
},
"permissionsRequired": "Berechtigungen erforderlich"
},
"notifications": {
"heading": "Tonbenachrichtigungen",
"label": "Tonbenachrichtigungen für",
@@ -54,6 +85,7 @@
"tabs": {
"account": "Profil",
"audio": "Audio",
"video": "Video",
"general": "Allgemein",
"notifications": "Benachrichtigungen"
}
+17 -1
View File
@@ -7,9 +7,18 @@
"home": "Return to home",
"back": "Rejoin the meeting"
},
"join": {
"selectDevice": {
"loading": "Loading…",
"select": "Select a value",
"permissionsNeeded": "Permission needed",
"settings": {
"audio": "Audio settings",
"video": "Video settings"
},
"effects": "Effects",
"videoinput": {
"choose": "Select camera",
"permissionsNeeded": "Select camera - permission needed",
"disable": "Disable camera",
"enable": "Enable camera",
"label": "Camera",
@@ -17,10 +26,17 @@
},
"audioinput": {
"choose": "Select microphone",
"permissionsNeeded": "Select microphone - permission needed",
"disable": "Disable microphone",
"enable": "Enable microphone",
"label": "Microphone"
},
"audiooutput": {
"choose": "Select speaker",
"permissionsNeeded": "Select speaker - permission needed"
}
},
"join": {
"effects": {
"description": "Apply effects",
"title": "Effects",
+32
View File
@@ -30,6 +30,37 @@
},
"permissionsRequired": "Permissions required"
},
"video": {
"camera": {
"heading": "Camera",
"label": "Select your video input",
"disabled": "Camera disabled",
"previewAriaLabel": {
"enabled": "Video preview enabled",
"disabled": "Video preview disabled"
}
},
"resolution": {
"heading": "Resolution",
"publish": {
"label": "Select your sending resolution (max.)",
"items": {
"high": "High definition",
"medium": "Standard definition",
"low": "Low definition"
}
},
"subscribe": {
"label": "Select your reception resolution (max.)",
"items": {
"high": "High definition (automatic)",
"medium": "Standard definition",
"low": "Low definition"
}
}
},
"permissionsRequired": "Permissions required"
},
"notifications": {
"heading": "Sound notifications",
"label": "sound notifications for",
@@ -54,6 +85,7 @@
"tabs": {
"account": "Profile",
"audio": "Audio",
"video": "Video",
"general": "General",
"notifications": "Notifications"
}
+20 -4
View File
@@ -7,9 +7,18 @@
"home": "Retourner à l'accueil",
"back": "Réintégrer la réunion"
},
"join": {
"selectDevice": {
"loading": "Chargement…",
"select": "Sélectionnez une valeur",
"permissionsNeeded": "Autorisations nécessaires",
"settings": {
"audio": "Paramètres audio",
"video": "Paramètres video"
},
"effects": "Effets d'arrière-plan",
"videoinput": {
"choose": "Choisir la webcam",
"permissionsNeeded": "Choisir la webcam - autorisations nécessaires",
"disable": "Désactiver la webcam",
"enable": "Activer la webcam",
"label": "Webcam",
@@ -17,13 +26,20 @@
},
"audioinput": {
"choose": "Choisir le micro",
"permissionsNeeded": "Choisir le micro - autorisations nécessaires",
"disable": "Désactiver le micro",
"enable": "Activer le micro",
"label": "Microphone"
},
"audiooutput": {
"choose": "Choisir le haut-parleur",
"permissionsNeeded": "Choisir le haut-parleur - autorisations nécessaires"
}
},
"join": {
"heading": "Rejoindre la réunion ?",
"effects": {
"description": "Effets d'arrière plan",
"description": "Effets d'arrière-plan",
"title": "Effets",
"subTitle": "Paramétrez les effets de votre caméra."
},
@@ -182,7 +198,7 @@
"feedback": "Partager votre avis",
"settings": "Paramètres",
"username": "Choisir votre nom",
"effects": "Effets d'arrière plan",
"effects": "Effets d'arrière-plan",
"switchCamera": "Changer de caméra",
"fullscreen": {
"enter": "Plein écran",
@@ -446,7 +462,7 @@
"enable": "Épingler",
"disable": "Annuler l'épinglage"
},
"effects": "Effets d'arrière plan",
"effects": "Effets d'arrière-plan",
"muteParticipant": "Couper le micro de {{name}}",
"fullScreen": "Plein écran"
},
+32
View File
@@ -30,6 +30,37 @@
},
"permissionsRequired": "Autorisations nécessaires"
},
"video": {
"camera": {
"heading": "Caméra",
"label": "Sélectionner votre entrée vidéo",
"disabled": "Caméra désactivée",
"previewAriaLabel": {
"enabled": "Aperçu vidéo activé",
"disabled": "Aperçu vidéo désactivé"
}
},
"resolution": {
"heading": "Résolution",
"publish": {
"label": "Sélectionner votre résolution d'envoi (max.)",
"items": {
"high": "Haute définition",
"medium": "Définition standard",
"low": "Basse définition"
}
},
"subscribe": {
"label": "Sélectionner votre résolution de réception (max.)",
"items": {
"high": "Haute définition (automatique)",
"medium": "Définition standard",
"low": "Basse définition"
}
}
},
"permissionsRequired": "Autorisations nécessaires"
},
"notifications": {
"heading": "Notifications sonores",
"label": "la notification sonore pour",
@@ -54,6 +85,7 @@
"tabs": {
"account": "Profil",
"audio": "Audio",
"video": "Vidéo",
"general": "Général",
"notifications": "Notifications"
}
+17 -1
View File
@@ -7,9 +7,18 @@
"home": "Keer terug naar het hoofdscherm",
"back": "Sluit weer bij de vergadering aan"
},
"join": {
"selectDevice": {
"loading": "Bezig met laden…",
"select": "Selecteer een waarde",
"permissionsNeeded": "Toestemming vereist",
"settings": {
"audio": "Audio-instellingen",
"video": "Video-instellingen"
},
"effects": "Pas effecten toe",
"videoinput": {
"choose": "Selecteer camera",
"permissionsNeeded": "Selecteer camera - Toestemming vereist",
"disable": "Camera uitschakelen",
"enable": "Camera inschakelen",
"label": "Camera",
@@ -17,10 +26,17 @@
},
"audioinput": {
"choose": "Selecteer microfoon",
"permissionsNeeded": "Selecteer microfoon - Toestemming vereist",
"disable": "Microfoon dempen",
"enable": "Microfoon dempen opheffen",
"label": "Microfoon"
},
"audiooutput": {
"choose": "Selecteer luidspreker",
"permissionsNeeded": "Selecteer luidspreker - Toestemming vereist"
}
},
"join": {
"effects": {
"description": "Pas effecten toe",
"title": "Effecten",
+32
View File
@@ -30,6 +30,37 @@
},
"permissionsRequired": "Machtigingen vereist"
},
"video": {
"camera": {
"heading": "Camera",
"label": "Selecteer uw video-ingang",
"disabled": "Camera uitgeschakeld",
"previewAriaLabel": {
"enabled": "Videovoorbeeld ingeschakeld",
"disabled": "Videovoorbeeld uitgeschakeld"
}
},
"resolution": {
"heading": "Resolutie",
"publish": {
"label": "Selecteer uw verzendresolutie (max.)",
"items": {
"high": "Hoge definitie",
"medium": "Standaarddefinitie",
"low": "Lage definitie"
}
},
"subscribe": {
"label": "Selecteer uw ontvangstresolutie (max.)",
"items": {
"high": "Hoge definitie (automatisch)",
"medium": "Standaarddefinitie",
"low": "Lage definitie"
}
}
},
"permissionsRequired": "Machtigingen vereist"
},
"notifications": {
"heading": "Geluidsmeldingen",
"label": "Geluidsmeldingen voor",
@@ -54,6 +85,7 @@
"tabs": {
"account": "Profiel",
"audio": "Audio",
"video": "Video",
"general": "Algemeen",
"notifications": "Meldingen"
}
+25 -7
View File
@@ -46,6 +46,18 @@ const StyledOverlayArrow = styled(OverlayArrow, {
transform: 'rotate(180deg) translateY(-1px)',
},
},
variants: {
variant: {
light: {},
dark: {
fill: 'primaryDark.50',
stroke: 'primaryDark.50',
},
},
},
defaultVariants: {
variant: 'light',
},
})
/**
@@ -56,6 +68,8 @@ const StyledOverlayArrow = styled(OverlayArrow, {
*/
export const Popover = ({
children,
variant = 'light',
withArrow = true,
...dialogProps
}: {
children: [
@@ -64,20 +78,24 @@ export const Popover = ({
| (({ close }: { close: () => void }) => ReactNode)
| ReactNode,
]
} & DialogProps) => {
variant?: 'dark' | 'light'
withArrow?: boolean
} & Omit<DialogProps, 'children'>) => {
const [trigger, popoverContent] = children
return (
<DialogTrigger>
{trigger}
<StyledPopover>
<StyledOverlayArrow>
<svg width={12} height={12} viewBox="0 0 12 12">
<path d="M0 0 L6 6 L12 0" />
</svg>
</StyledOverlayArrow>
{withArrow && (
<StyledOverlayArrow variant={variant}>
<svg width={12} height={12} viewBox="0 0 12 12">
<path d="M0 0 L6 6 L12 0" />
</svg>
</StyledOverlayArrow>
)}
<Dialog {...dialogProps}>
{({ close }) => (
<Box size="sm" type="popover">
<Box size="sm" type="popover" variant={variant}>
{typeof popoverContent === 'function'
? popoverContent({ close })
: popoverContent}
+49 -11
View File
@@ -6,13 +6,14 @@ import {
ListBox,
ListBoxItem,
Select as RACSelect,
SelectProps,
SelectProps as RACSelectProps,
SelectValue,
} from 'react-aria-components'
import { Box } from './Box'
import { StyledPopover } from './Popover'
import { menuRecipe } from '@/primitives/menuRecipe.ts'
import { css } from '@/styled-system/css'
import type { Placement } from '@react-types/overlays'
const StyledButton = styled(Button, {
base: {
@@ -41,6 +42,31 @@ const StyledButton = styled(Button, {
boxShadow: '0 1px 2px rgba(0 0 0 / 0.02)',
},
},
variants: {
variant: {
light: {},
dark: {
backgroundColor: 'primaryDark.100',
fontWeight: 'medium !important',
color: 'white',
'&[data-pressed]': {
backgroundColor: 'primaryDark.900',
color: 'primaryDark.100',
},
'&[data-hovered]': {
backgroundColor: 'primaryDark.300',
color: 'white',
},
'&[data-selected]': {
backgroundColor: 'primaryDark.700 !important',
color: 'primaryDark.100 !important',
},
},
},
},
defaultVariants: {
variant: 'light',
},
})
const StyledSelectValue = styled(SelectValue, {
@@ -62,23 +88,32 @@ const StyledIcon = styled('div', {
},
})
export type SelectProps<T> = Omit<
RACSelectProps<object>,
'items' | 'label' | 'errors'
> & {
iconComponent?: RemixiconComponentType
label: ReactNode
items: Array<{ value: T; label: ReactNode }>
errors?: ReactNode
placement?: Placement
variant?: 'light' | 'dark'
}
export const Select = <T extends string | number>({
label,
iconComponent,
items,
errors,
placement,
variant = 'light',
...props
}: Omit<SelectProps<object>, 'items' | 'label' | 'errors'> & {
iconComponent?: RemixiconComponentType
label: ReactNode
items: Array<{ value: T; label: ReactNode }>
errors?: ReactNode
}) => {
}: SelectProps<T>) => {
const IconComponent = iconComponent
return (
<RACSelect {...props}>
{label}
<StyledButton>
<StyledButton variant={variant}>
{!!IconComponent && (
<StyledIcon>
<IconComponent size={18} />
@@ -90,13 +125,16 @@ export const Select = <T extends string | number>({
className={css({ flexShrink: 0 })}
/>
</StyledButton>
<StyledPopover>
<Box size="sm" type="popover" variant="control">
<StyledPopover placement={placement}>
<Box size="sm" type="popover" variant={variant}>
<ListBox>
{items.map((item) => (
<ListBoxItem
className={
menuRecipe({ extraPadding: true, variant: 'light' }).item
menuRecipe({
extraPadding: true,
variant: variant,
}).item
}
id={item.value}
key={item.value}
+33 -6
View File
@@ -22,16 +22,15 @@ export const StyledSwitch = styled(RACSwitch, {
borderRadius: '1.143rem',
transition: 'all 200ms, outline 200ms',
_before: {
willChange: 'transform',
content: '""',
display: 'block',
margin: '0.125rem',
width: '1.063rem',
height: '1.063rem',
borderRadius: '1.063rem',
border: '2px solid',
borderColor: 'primary.800',
background: 'white',
transition: 'opacity 10ms',
background: 'primary.800',
transition: 'transform 200ms, background-color 200ms',
transitionDelay: '0ms',
},
},
@@ -44,14 +43,29 @@ export const StyledSwitch = styled(RACSwitch, {
color: 'primary.800',
fontSize: '0.75rem',
fontWeight: 'bold',
pointerEvents: 'none',
zIndex: 1,
opacity: 0,
},
'& .cross': {
position: 'absolute',
display: 'block',
top: '50%',
left: '0.375rem',
transform: 'translateY(-50%)',
color: 'white',
fontSize: '0.70rem',
fontWeight: 'bold',
pointerEvents: 'none',
zIndex: 1,
opacity: 1,
transition: 'opacity 200ms',
transitionDelay: '0ms',
},
'&[data-selected] .indicator': {
borderColor: 'primary.800',
background: 'primary.800',
_before: {
border: 'none',
background: 'white',
transform: 'translateX(100%)',
},
@@ -61,6 +75,11 @@ export const StyledSwitch = styled(RACSwitch, {
transition: 'opacity 30ms',
transitionDelay: '150ms',
},
'&[data-selected] .cross': {
opacity: 0,
transition: 'opacity 10ms',
transitionDelay: '0ms',
},
'&[data-disabled] .indicator': {
borderColor: 'primary.200',
background: 'transparent',
@@ -68,6 +87,9 @@ export const StyledSwitch = styled(RACSwitch, {
background: 'primary.200',
},
},
'&[data-disabled] .cross': {
color: 'primary.500',
},
'&[data-focus-visible] .indicator': {
outline: '2px solid!',
outlineColor: 'focusRing!',
@@ -86,7 +108,12 @@ export type SwitchProps = StyledVariantProps<typeof StyledSwitch> &
export const Switch = ({ children, ...props }: SwitchProps) => (
<StyledSwitch {...props}>
<div className="indicator">
<span className="checkmark"></span>
<span className="checkmark" aria-hidden="true">
</span>
<span className="cross" aria-hidden="true">
</span>
</div>
{children}
</StyledSwitch>
@@ -251,6 +251,20 @@ export const buttonRecipe = cva({
color: 'error.300',
},
},
errorCircle: {
backgroundColor: 'error.500',
width: '56px',
height: '56px',
borderRadius: '100%',
color: 'white',
'&[data-hovered]': {
backgroundColor: 'error.600',
},
'&[data-pressed]': {
backgroundColor: 'error.700',
color: 'error.200',
},
},
// @TODO: better handling of colors… this is a mess
success: {
colorPalette: 'success',
@@ -4,6 +4,6 @@ type State = {
enabled: boolean
}
export const ScreenSharePreferenceStore = proxy<State>({
export const screenSharePreferenceStore = proxy<State>({
enabled: true,
})
+12
View File
@@ -0,0 +1,12 @@
import { proxy } from 'valtio'
import { SettingsDialogExtendedKey } from '@/features/settings/type'
type State = {
areSettingsOpen: boolean
defaultSelectedTab?: SettingsDialogExtendedKey
}
export const settingsStore = proxy<State>({
areSettingsOpen: false,
defaultSelectedTab: undefined,
})
+9
View File
@@ -5,15 +5,24 @@ import {
saveUserChoices,
LocalUserChoices as LocalUserChoicesLK,
} from '@livekit/components-core'
import { VideoQuality } from 'livekit-client'
export type VideoResolution = 'h720' | 'h360' | 'h180'
export type LocalUserChoices = LocalUserChoicesLK & {
processorSerialized?: ProcessorSerialized
noiseReductionEnabled?: boolean
audioOutputDeviceId?: string
videoPublishResolution?: VideoResolution
videoSubscribeQuality?: VideoQuality
}
function getUserChoicesState(): LocalUserChoices {
return {
noiseReductionEnabled: false,
audioOutputDeviceId: 'default', // Use 'default' to match LiveKit's standard device selection behavior
videoPublishResolution: 'h720',
videoSubscribeQuality: VideoQuality.HIGH,
...loadUserChoices(),
}
}
@@ -13,8 +13,9 @@ egress:
{{- end }}
{{- end }}
redis:
address: redis-master:6379
address: dev-backend-redis:6379
password: pass
username: user
s3:
access_key: meet
secret: password
@@ -14,8 +14,9 @@ livekit:
port_range_end: 60000
tcp_port: 7881
redis:
address: redis-master:6379
address: dev-backend-redis:6379
password: pass
username: user
keys:
turn:
enabled: true
@@ -35,12 +35,21 @@ backend:
LOGIN_REDIRECT_URL: https://meet.127.0.0.1.nip.io
LOGIN_REDIRECT_URL_FAILURE: https://meet.127.0.0.1.nip.io
LOGOUT_REDIRECT_URL: https://meet.127.0.0.1.nip.io
DB_HOST: postgres-postgresql
DB_NAME: meet
DB_USER: dinum
DB_PASSWORD: pass
DB_HOST: dev-backend-postgres
DB_NAME:
secretKeyRef:
name: dev-backend-postgres
key: database
DB_USER:
secretKeyRef:
name: dev-backend-postgres
key: username
DB_PASSWORD:
secretKeyRef:
name: dev-backend-postgres
key: password
DB_PORT: 5432
REDIS_URL: redis://default:pass@redis-master:6379/1
REDIS_URL: redis://user:pass@dev-backend-redis:6379/1
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
{{- with .Values.livekit.keys }}
{{- range $key, $value := . }}
@@ -58,8 +67,8 @@ backend:
FRONTEND_USE_FRENCH_GOV_FOOTER: True
FRONTEND_USE_PROCONNECT_BUTTON: True
FRONTEND_MANIFEST_LINK: "https://docs.numerique.gouv.fr/docs/1ef86abf-f7e0-46ce-b6c7-8be8b8af4c3d/"
AWS_S3_ENDPOINT_URL: http://minio.meet.svc.cluster.local:9000
AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_ENDPOINT_URL: http://dev-backend-minio.meet.svc.cluster.local:9000
AWS_S3_ACCESS_KEY_ID: dinum
AWS_S3_SECRET_ACCESS_KEY: password
AWS_STORAGE_BUCKET_NAME: meet-media-storage
AWS_S3_REGION_NAME: local
@@ -78,8 +87,7 @@ backend:
- "/bin/sh"
- "-c"
- |
python manage.py migrate --no-input &&
python manage.py create_demo --force
python manage.py migrate --no-input
restartPolicy: Never
command:
+1 -1
View File
@@ -1,4 +1,4 @@
djangoSecretKey: u!vbjDW71aru&OZA%NZQi0x
djangoSecretKey: 7K9mQ2xR8pL3vN6tY1sW4jH5cE0zF9bM2qA7uI3oP6rT1wErt12te12
livekit:
keys:
devkey: secret
@@ -13,8 +13,9 @@ egress:
{{- end }}
{{- end }}
redis:
address: redis-master:6379
address: dev-backend-redis:6379
password: pass
username: user
s3:
access_key: meet
secret: password
@@ -14,8 +14,9 @@ livekit:
port_range_end: 60000
tcp_port: 7881
redis:
address: redis-master:6379
address: dev-backend-redis:6379
password: pass
username: user
keys:
turn:
enabled: true
@@ -35,12 +35,21 @@ backend:
LOGIN_REDIRECT_URL: https://meet.127.0.0.1.nip.io
LOGIN_REDIRECT_URL_FAILURE: https://meet.127.0.0.1.nip.io
LOGOUT_REDIRECT_URL: https://meet.127.0.0.1.nip.io
DB_HOST: postgres-postgresql
DB_NAME: meet
DB_USER: dinum
DB_PASSWORD: pass
DB_HOST: dev-backend-postgres
DB_NAME:
secretKeyRef:
name: dev-backend-postgres
key: database
DB_USER:
secretKeyRef:
name: dev-backend-postgres
key: username
DB_PASSWORD:
secretKeyRef:
name: dev-backend-postgres
key: password
DB_PORT: 5432
REDIS_URL: redis://default:pass@redis-master:6379/1
REDIS_URL: redis://user:pass@dev-backend-redis:6379/1
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
{{- with .Values.livekit.keys }}
{{- range $key, $value := . }}
@@ -57,8 +66,8 @@ backend:
FRONTEND_TRANSCRIPT: "{'form_beta_users': 'https://grist.numerique.gouv.fr/o/docs/forms/3fFfvJoTBEQ6ZiMi8zsQwX/17'}"
FRONTEND_FEEDBACK: "{'url': 'https://grist.numerique.gouv.fr/o/docs/cbMv4G7pLY3Z/USER-RESEARCH-or-LA-SUITE/f/26'}"
FRONTEND_MANIFEST_LINK: "https://docs.numerique.gouv.fr/docs/1ef86abf-f7e0-46ce-b6c7-8be8b8af4c3d/"
AWS_S3_ENDPOINT_URL: http://minio.meet.svc.cluster.local:9000
AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_ENDPOINT_URL: http://dev-backend-minio.meet.svc.cluster.local:9000
AWS_S3_ACCESS_KEY_ID: dinum
AWS_S3_SECRET_ACCESS_KEY: password
AWS_STORAGE_BUCKET_NAME: meet-media-storage
AWS_S3_REGION_NAME: local
@@ -79,15 +88,7 @@ backend:
- "/bin/sh"
- "-c"
- |
while ! python manage.py check --database default > /dev/null 2>&1
do
echo "Database not ready"
sleep 2
done
echo "Database is ready"
python manage.py migrate --no-input &&
python manage.py create_demo --force
python manage.py migrate --no-input
restartPolicy: Never
command:
@@ -102,13 +103,6 @@ backend:
- "/bin/sh"
- "-c"
- |
while ! python manage.py check --database default > /dev/null 2>&1
do
echo "Database not ready"
sleep 2
done
echo "Database is ready"
python manage.py createsuperuser --email admin@example.com --password admin
restartPolicy: Never
@@ -1,4 +1,4 @@
djangoSecretKey: u!vbjDW71aru&OZA%NZQi0x
djangoSecretKey: 7K9mQ2xR8pL3vN6tY1sW4jH5cE0zF9bM2qA7uI3oP6rT1wErt12te12
livekit:
keys:
devkey: secret
+2 -1
View File
@@ -13,8 +13,9 @@ egress:
{{- end }}
{{- end }}
redis:
address: redis-master:6379
address: dev-backend-redis:6379
password: pass
username: user
s3:
access_key: meet
secret: password
@@ -14,8 +14,9 @@ livekit:
port_range_end: 60000
tcp_port: 7881
redis:
address: redis-master:6379
address: dev-backend-redis:6379
password: pass
username: user
keys:
turn:
enabled: true
+17 -23
View File
@@ -57,12 +57,21 @@ backend:
LOGIN_REDIRECT_URL: https://meet.127.0.0.1.nip.io
LOGIN_REDIRECT_URL_FAILURE: https://meet.127.0.0.1.nip.io
LOGOUT_REDIRECT_URL: https://meet.127.0.0.1.nip.io
DB_HOST: postgres-postgresql
DB_NAME: meet
DB_USER: dinum
DB_PASSWORD: pass
DB_HOST: dev-backend-postgres
DB_NAME:
secretKeyRef:
name: dev-backend-postgres
key: database
DB_USER:
secretKeyRef:
name: dev-backend-postgres
key: username
DB_PASSWORD:
secretKeyRef:
name: dev-backend-postgres
key: password
DB_PORT: 5432
REDIS_URL: redis://default:pass@redis-master:6379/1
REDIS_URL: redis://user:pass@dev-backend-redis:6379/1
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
{{- with .Values.livekit.keys }}
{{- range $key, $value := . }}
@@ -74,8 +83,8 @@ backend:
ALLOW_UNREGISTERED_ROOMS: False
FRONTEND_SILENCE_LIVEKIT_DEBUG: False
FRONTEND_SUPPORT: "{'id': '58ea6697-8eba-4492-bc59-ad6562585041'}"
AWS_S3_ENDPOINT_URL: http://minio.meet.svc.cluster.local:9000
AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_ENDPOINT_URL: http://dev-backend-minio.meet.svc.cluster.local:9000
AWS_S3_ACCESS_KEY_ID: dinum
AWS_S3_SECRET_ACCESS_KEY: password
AWS_STORAGE_BUCKET_NAME: meet-media-storage
AWS_S3_REGION_NAME: local
@@ -100,15 +109,7 @@ backend:
- "/bin/sh"
- "-c"
- |
while ! python manage.py check --database default > /dev/null 2>&1
do
echo "Database not ready"
sleep 2
done
echo "Database is ready"
python manage.py migrate --no-input &&
python manage.py create_demo --force
python manage.py migrate --no-input
restartPolicy: Never
command:
@@ -123,13 +124,6 @@ backend:
- "/bin/sh"
- "-c"
- |
while ! python manage.py check --database default > /dev/null 2>&1
do
echo "Database not ready"
sleep 2
done
echo "Database is ready"
python manage.py createsuperuser --email admin@example.com --password admin
restartPolicy: Never
+1 -1
View File
@@ -1,4 +1,4 @@
djangoSecretKey: u!vbjDW71aru&OZA%NZQi0x
djangoSecretKey: 7K9mQ2xR8pL3vN6tY1sW4jH5cE0zF9bM2qA7uI3oP6rT1wErt12te12
livekit:
keys:
devkey: secret
+63 -99
View File
@@ -13,109 +13,13 @@ environments:
- env.d/{{ .Environment.Name }}/values.secrets.yaml
repositories:
- name: bitnami
url: registry-1.docker.io/bitnamicharts
oci: true
- name: livekit
url: https://helm.livekit.io
- name: dev-backends
url: https://suitenumerique.github.io/helm-dev-backend
releases:
- name: postgres
installed: {{ regexMatch "^dev.*" .Environment.Name | toYaml }}
missingFileHandler: Warn
namespace: {{ .Namespace }}
chart: bitnami/postgresql
version: 13.1.5
values:
- auth:
username: dinum
password: pass
database: meet
- tls:
enabled: true
autoGenerated: true
- name: keycloak
installed: {{ or (eq .Environment.Name "dev-keycloak") (eq .Environment.Name "dev-dinum") | toYaml }}
missingFileHandler: Warn
namespace: {{ .Namespace }}
chart: bitnami/keycloak
version: 17.3.6
values:
- postgresql:
auth:
username: keycloak
password: keycloak
database: keycloak
- extraEnvVars:
- name: KEYCLOAK_EXTRA_ARGS
value: "--import-realm"
- name: KC_HOSTNAME_URL
value: https://keycloak.127.0.0.1.nip.io
- extraVolumes:
- name: import
configMap:
name: meet-keycloak
- extraVolumeMounts:
- name: import
mountPath: /opt/bitnami/keycloak/data/import/
- auth:
adminUser: su
adminPassword: su
- proxy: edge
- ingress:
enabled: true
hostname: keycloak.127.0.0.1.nip.io
- extraDeploy:
- apiVersion: v1
kind: ConfigMap
metadata:
name: meet-keycloak
data:
meet.json: |
{{ readFile "../../docker/auth/realm.json" | replace "http://localhost:3200" "https://meet.127.0.0.1.nip.io" | indent 14 }}
- name: minio
installed: {{ regexMatch "^dev.*" .Environment.Name | toYaml }}
namespace: {{ .Namespace }}
missingFileHandler: Warn
chart: bitnami/minio
version: 12.10.10
values:
- auth:
rootUser: meet
rootPassword: password
- provisioning:
enabled: true
buckets:
- name: meet-media-storage
versioning: true
- ingress:
enabled: true
hostname: minio-console.127.0.0.1.nip.io
servicePort: 9001
annotations:
nginx.ingress.kubernetes.io/proxy-body-size: "0"
kubernetes.io/ingress.class: nginx
extraVolumes:
- name: mkcert
secret:
secretName: mkcert
extraVolumeMounts:
- mountPath: /certs/CAs/
name: mkcert
- name: redis
installed: {{ regexMatch "^dev.*" .Environment.Name | toYaml }}
missingFileHandler: Warn
namespace: {{ .Namespace }}
chart: bitnami/redis
version: 18.19.2
values:
- auth:
password: pass
architecture: standalone
- name: extra
installed: {{ regexMatch "^dev.*" .Environment.Name | toYaml }}
missingFileHandler: Warn
@@ -129,6 +33,8 @@ releases:
enablePermanentRedirect: {{ .Values | get "enablePermanentRedirect" "False"}}
oldDomain: {{ .Values | get "oldDomain" "demo.com" }}
newDomain: {{ .Values | get "newDomain" "demo.com" }}
- realm: |
{{ readFile "../../docker/auth/realm.json" | replace "http://localhost:3200" "https://meet.127.0.0.1.nip.io" | indent 8 }}
- name: meet
version: {{ .Values.version }}
@@ -162,3 +68,61 @@ releases:
- env.d/{{ .Environment.Name }}/values.secrets.yaml
secrets:
- env.d/{{ .Environment.Name }}/secrets.enc.yaml
- name: dev-backend
namespace: {{ .Namespace }}
chart: dev-backends/dev-backend
version: 0.0.1
values:
- postgres:
enabled: true
name: postgres
#serviceNameOverride: postgres
image: postgres:16-alpine
username: dinum
password: pass
database: dinum
size: 1Gi
- redis:
enabled: true
name: redis
#serviceNameOverride: redis
image: redis:8.2-alpine
username: user
password: pass
- minio:
enabled: true
image: minio/minio
name: minio
#serviceNameOverride: minio
hostname: minio.127.0.0.1.nip.io
tls:
enabled: true
secretName: meet-tls
username: dinum
password: password
bucket: meet-media-storage
size: 1Gi
- keycloak:
enabled: true
image: quay.io/keycloak/keycloak:20.0.1
name: keycloak
#serviceNameOverride: keycloak
hostname: keycloak.127.0.0.1.nip.io
username: admin
password: pass
tls:
enabled: true
secretName: meet-tls
db:
username: dinum
password: pass
database: keycloak
size: 1Gi
image: postgres:16-alpine
realm:
name: meet
username: meet
password: meet
email: meet@example.com