├── .jellyfish-version ├── assets ├── .tool-versions ├── .yarnrc.yml ├── src │ ├── api │ │ ├── .openapi-generator │ │ │ ├── VERSION │ │ │ └── FILES │ │ ├── .gitignore │ │ ├── .npmignore │ │ ├── index.ts │ │ ├── .openapi-generator-ignore │ │ ├── git_push.sh │ │ └── base.ts │ ├── server-sdk │ │ ├── .openapi-generator │ │ │ ├── VERSION │ │ │ └── FILES │ │ ├── .gitignore │ │ ├── .npmignore │ │ ├── index.ts │ │ ├── .openapi-generator-ignore │ │ ├── git_push.sh │ │ └── base.ts │ ├── vite-env.d.ts │ ├── features │ │ ├── shared │ │ │ ├── utils │ │ │ │ ├── noop.tsx │ │ │ │ └── localStorage.ts │ │ │ ├── hooks │ │ │ │ ├── useToast.tsx │ │ │ │ ├── useDebounce.tsx │ │ │ │ ├── useEffectOnChange.tsx │ │ │ │ └── useSmartphoneViewport.tsx │ │ │ ├── consts.tsx │ │ │ ├── components │ │ │ │ ├── BlockingScreen.tsx │ │ │ │ ├── Checkbox.tsx │ │ │ │ ├── Toast.tsx │ │ │ │ ├── Select.tsx │ │ │ │ ├── Page404.tsx │ │ │ │ ├── BackgroundRight.tsx │ │ │ │ ├── PlainLink.tsx │ │ │ │ ├── modal │ │ │ │ │ └── Modal.tsx │ │ │ │ ├── Button.tsx │ │ │ │ └── Input.tsx │ │ │ └── context │ │ │ │ └── ToastContext.tsx │ │ ├── home-page │ │ │ ├── types.ts │ │ │ └── components │ │ │ │ ├── Navbar.tsx │ │ │ │ ├── LeavingRoomScreen.tsx │ │ │ │ ├── HomePageLayout.tsx │ │ │ │ └── HomePageVideoTile.tsx │ │ ├── room-page │ │ │ ├── consts.tsx │ │ │ ├── components │ │ │ │ ├── NameTag.tsx │ │ │ │ ├── SoundIcon.tsx │ │ │ │ ├── InitialsImage.tsx │ │ │ │ ├── DisabledTrackIcon.tsx │ │ │ │ ├── PageLayout.tsx │ │ │ │ ├── Sidebar.tsx │ │ │ │ ├── PeopleComponent.tsx │ │ │ │ ├── PinComponents.tsx │ │ │ │ └── Navbar.tsx │ │ │ ├── utils │ │ │ │ ├── computeLeftUpperIcon.tsx │ │ │ │ └── getVideoGridConfig.tsx │ │ │ └── icons │ │ │ │ ├── Pin.tsx │ │ │ │ ├── ChevronDown.tsx │ │ │ │ ├── Chat.tsx │ │ │ │ ├── Camera.tsx │ │ │ │ ├── MenuDots.tsx │ │ │ │ ├── ShareSquare.tsx │ │ │ │ ├── CameraOff.tsx │ │ │ │ ├── Close.tsx │ │ │ │ ├── HangUp.tsx │ │ │ │ ├── RotateRight.tsx │ │ │ │ ├── SoundBig.tsx │ │ │ │ ├── Screenshare.tsx │ │ │ │ ├── Microphone.tsx │ │ │ │ └── MicrophoneOff.tsx │ │ ├── devices │ │ │ ├── disableSafariCache.ts │ │ │ ├── BlurProcessorWorker.ts │ │ │ ├── DeviceSelector.tsx │ │ │ ├── emptyVideoWorker.ts │ │ │ ├── LocalMediaMessagesBoundary.tsx │ │ │ └── MediaSettingsModal.tsx │ │ └── recording │ │ │ └── useRecording.ts │ ├── pages │ │ ├── room │ │ │ ├── errorMessage.ts │ │ │ ├── components │ │ │ │ ├── StreamPlayer │ │ │ │ │ ├── simulcast │ │ │ │ │ │ ├── SimulcastReceivingEncoding.tsx │ │ │ │ │ │ ├── LayerButton.tsx │ │ │ │ │ │ ├── Tooltip.tsx │ │ │ │ │ │ ├── SimulcastEncodingToSend.tsx │ │ │ │ │ │ └── SimulcastEncodingToReceive.tsx │ │ │ │ │ ├── PeerInfoLayer.tsx │ │ │ │ │ ├── GenericMediaPlayerTile.tsx │ │ │ │ │ ├── LocalMediaPlayerTile.tsx │ │ │ │ │ ├── PinnedTilesSection.tsx │ │ │ │ │ ├── MediaPlayer.tsx │ │ │ │ │ ├── rtcMOS2.ts │ │ │ │ │ ├── RemoteMediaPlayerTile.tsx │ │ │ │ │ ├── Tile.tsx │ │ │ │ │ └── rtcMOS1.ts │ │ │ │ └── MediaControlButton.tsx │ │ │ ├── hooks │ │ │ │ ├── useStoreFirstNonNullValue.tsx │ │ │ │ ├── useToggle.tsx │ │ │ │ ├── useAcquireWakeLockAutomatically.tsx │ │ │ │ ├── useSimulcastRemoteEncoding.tsx │ │ │ │ ├── useSimulcastSend.tsx │ │ │ │ └── useAutomaticEncodingSwitching.tsx │ │ │ ├── bandwidth.tsx │ │ │ ├── utils.tsx │ │ │ ├── consts.ts │ │ │ └── RoomSidebar.tsx │ │ └── types.ts │ ├── room.api.tsx │ ├── main.tsx │ ├── contexts │ │ ├── UserContext.tsx │ │ ├── ModalContext.tsx │ │ └── DeveloperInfoContext.tsx │ ├── Routes.tsx │ ├── App.tsx │ └── fishjam.ts ├── .eslintignore ├── .prettierrc ├── public │ ├── favicon.png │ └── shaders │ │ └── blur │ │ ├── vertex.glsl │ │ └── fragment.glsl ├── .dockerignore ├── postcss.config.js ├── openapitools.json ├── tsconfig.node.json ├── .gitignore ├── .eslintrc.cjs ├── index.html ├── .eslintrc ├── tsconfig.json ├── vite.config.ts ├── nginx.conf ├── Dockerfile └── package.json ├── test ├── test_helper.exs ├── videoroom_web │ └── controllers │ │ └── error_json_test.exs └── support │ ├── peer.ex │ ├── protos │ └── jellyfish │ │ └── peer_notifications.pb.ex │ └── conn_case.ex ├── infra ├── alloy │ ├── Dockerfile │ └── config.alloy ├── grafana │ ├── Dockerfile │ └── provisioning │ │ ├── datasources │ │ └── loki.yml │ │ └── dashboards │ │ └── dashboard.yml └── loki │ ├── Dockerfile │ └── loki-config.yaml ├── .gitmodules ├── .formatter.exs ├── config ├── integration_test.exs ├── prod.exs ├── test.exs ├── config.exs ├── runtime.exs └── dev.exs ├── lib ├── videoroom_web │ ├── controllers │ │ ├── room_json.ex │ │ ├── fallback_controller.ex │ │ ├── error_json.ex │ │ └── room_controler.ex │ ├── api_spec │ │ ├── error.ex │ │ └── token.ex │ ├── api_spec.ex │ ├── router.ex │ ├── endpoint.ex │ └── telemetry.ex ├── videoroom.ex ├── videoroom │ ├── room_registry.ex │ └── application.ex └── videoroom_web.ex ├── .dockerignore ├── .env.example ├── .github └── workflows │ ├── debug_build_and_deploy.yml │ ├── staging_build_and_deploy.yml │ ├── sandbox_build_and_deploy.yml │ └── production_build_and_deploy.yml ├── .circleci └── config.yml ├── docker-compose-integration.yaml ├── .gitignore ├── docker-compose-dev.yaml ├── Dockerfile ├── docker-compose-deploy.yaml ├── openapi.yaml └── mix.exs /.jellyfish-version: -------------------------------------------------------------------------------- 1 | 0.4.2 2 | -------------------------------------------------------------------------------- /assets/.tool-versions: -------------------------------------------------------------------------------- 1 | nodejs v20.13.1 2 | -------------------------------------------------------------------------------- /assets/.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | -------------------------------------------------------------------------------- /assets/src/api/.openapi-generator/VERSION: -------------------------------------------------------------------------------- 1 | 7.4.0 2 | -------------------------------------------------------------------------------- /assets/src/server-sdk/.openapi-generator/VERSION: -------------------------------------------------------------------------------- 1 | 7.4.0 2 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start(capture_log: true) 2 | -------------------------------------------------------------------------------- /assets/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /assets/.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | src/server-sdk 4 | src/api 5 | -------------------------------------------------------------------------------- /assets/src/api/.gitignore: -------------------------------------------------------------------------------- 1 | wwwroot/*.js 2 | node_modules 3 | typings 4 | dist 5 | -------------------------------------------------------------------------------- /assets/src/server-sdk/.gitignore: -------------------------------------------------------------------------------- 1 | wwwroot/*.js 2 | node_modules 3 | typings 4 | dist 5 | -------------------------------------------------------------------------------- /assets/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "tabWidth": 2, 4 | "printWidth": 120 5 | } 6 | -------------------------------------------------------------------------------- /infra/alloy/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM grafana/alloy:v1.2.0 2 | COPY ./config.alloy /etc/alloy/config.alloy -------------------------------------------------------------------------------- /infra/grafana/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM grafana/grafana:10.4.5 2 | COPY ./provisioning /etc/grafana/provisioning -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "protos"] 2 | path = protos 3 | url = https://github.com/fishjam-dev/protos.git 4 | -------------------------------------------------------------------------------- /infra/loki/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM grafana/loki:2.9.2 2 | 3 | COPY ./loki-config.yaml /etc/loki/local-config.yaml 4 | -------------------------------------------------------------------------------- /assets/public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fishjam-dev/fishjam-videoroom/HEAD/assets/public/favicon.png -------------------------------------------------------------------------------- /assets/.dockerignore: -------------------------------------------------------------------------------- 1 | **/node_modules/ 2 | **/dist 3 | .git 4 | npm-debug.log 5 | .coverage 6 | .coverage.* 7 | .aws 8 | -------------------------------------------------------------------------------- /assets/src/api/.npmignore: -------------------------------------------------------------------------------- 1 | # empty npmignore to ensure all required files (e.g., in the dist folder) are published by npm -------------------------------------------------------------------------------- /assets/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /assets/src/features/shared/utils/noop.tsx: -------------------------------------------------------------------------------- 1 | const noop = () => { 2 | //no operation 3 | }; 4 | 5 | export default noop; 6 | -------------------------------------------------------------------------------- /assets/src/server-sdk/.npmignore: -------------------------------------------------------------------------------- 1 | # empty npmignore to ensure all required files (e.g., in the dist folder) are published by npm -------------------------------------------------------------------------------- /assets/src/api/.openapi-generator/FILES: -------------------------------------------------------------------------------- 1 | .gitignore 2 | .npmignore 3 | api.ts 4 | base.ts 5 | common.ts 6 | configuration.ts 7 | git_push.sh 8 | index.ts 9 | -------------------------------------------------------------------------------- /assets/src/server-sdk/.openapi-generator/FILES: -------------------------------------------------------------------------------- 1 | .gitignore 2 | .npmignore 3 | api.ts 4 | base.ts 5 | common.ts 6 | configuration.ts 7 | git_push.sh 8 | index.ts 9 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | inputs: [ 3 | "{lib,test,config}/**/*.{ex,exs}", 4 | ".formatter.exs", 5 | "*.exs" 6 | ], 7 | import_deps: [:open_api_spex, :phoenix, :protobuf] 8 | ] 9 | -------------------------------------------------------------------------------- /assets/public/shaders/blur/vertex.glsl: -------------------------------------------------------------------------------- 1 | attribute vec4 a_Position; 2 | varying vec2 v_pos; 3 | 4 | void main() { 5 | gl_Position = a_Position; 6 | v_pos = (a_Position.xy + 1.0) / 2.0; 7 | } -------------------------------------------------------------------------------- /assets/src/features/home-page/types.ts: -------------------------------------------------------------------------------- 1 | export type MobileLoginStepType = "create-room" | "preview-settings"; 2 | 3 | export type MobileLoginStep = { content: JSX.Element; button: JSX.Element }; 4 | -------------------------------------------------------------------------------- /assets/openapitools.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@openapitools/openapi-generator-cli/config.schema.json", 3 | "spaces": 2, 4 | "generator-cli": { 5 | "version": "7.4.0" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /assets/src/features/shared/hooks/useToast.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | import { ToastContext } from "../context/ToastContext"; 3 | 4 | const useToastContext = () => useContext(ToastContext); 5 | 6 | export default useToastContext; 7 | -------------------------------------------------------------------------------- /infra/grafana/provisioning/datasources/loki.yml: -------------------------------------------------------------------------------- 1 | apiVersion: 1 2 | 3 | datasources: 4 | - id: 1 5 | uid: loki 6 | name: Loki 7 | type: loki 8 | access: proxy 9 | url: http://loki:3100 10 | jsonData: 11 | maxLines: 1000 -------------------------------------------------------------------------------- /assets/src/pages/room/errorMessage.ts: -------------------------------------------------------------------------------- 1 | export type ErrorMessage = { 2 | message: string; 3 | id?: string; 4 | }; 5 | export const messageComparator = (a: ErrorMessage | undefined, b: ErrorMessage | undefined) => 6 | a === b || (a?.message === b?.message && a?.id === b?.id); 7 | -------------------------------------------------------------------------------- /assets/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /config/integration_test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :fishjam_server_sdk, 4 | server_address: "fishjam:5002", 5 | server_api_token: "development" 6 | 7 | config :videoroom, 8 | fishjam_address: "fishjam:5002", 9 | peer_disconnected_timeout: 1, 10 | peerless_purge_timeout: 3 11 | -------------------------------------------------------------------------------- /lib/videoroom_web/controllers/room_json.ex: -------------------------------------------------------------------------------- 1 | defmodule VideoroomWeb.RoomJSON do 2 | @moduledoc false 3 | 4 | @spec show(map()) :: %{data: map()} 5 | def show(%{token: token, fishjam_address: fishjam_address}) do 6 | %{data: %{token: token, fishjam_address: fishjam_address}} 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/videoroom.ex: -------------------------------------------------------------------------------- 1 | defmodule Videoroom do 2 | @moduledoc """ 3 | Videoroom keeps the contexts that define your domain 4 | and business logic. 5 | 6 | Contexts are also responsible for managing your data, regardless 7 | if it comes from the database, an external API or others. 8 | """ 9 | end 10 | -------------------------------------------------------------------------------- /lib/videoroom_web/api_spec/error.ex: -------------------------------------------------------------------------------- 1 | defmodule Videoroom.ApiSpec.Error do 2 | @moduledoc false 3 | 4 | require OpenApiSpex 5 | 6 | OpenApiSpex.schema(%{ 7 | title: "Error", 8 | description: "Error message", 9 | type: :string, 10 | example: "Failed to add peer" 11 | }) 12 | end 13 | -------------------------------------------------------------------------------- /assets/src/features/shared/consts.tsx: -------------------------------------------------------------------------------- 1 | export const MOBILE_WIDTH_BREAKPOINT = 768; // under this width a device is considered a mobile phone. Equal to typical md breakpoint. 2 | export const MAX_MOBILE_WIDTH_BREAKPOINT = 926; // over this width a device is not a mobile phone. Equal to iphone 12 max pro viewport height 3 | -------------------------------------------------------------------------------- /assets/src/features/room-page/consts.tsx: -------------------------------------------------------------------------------- 1 | export const neutralButtonStyle = "border-brand-dark-blue-400 text-brand-dark-blue-500 bg-white"; 2 | export const activeButtonStyle = "text-brand-white bg-brand-dark-blue-400 border-brand-dark-blue-400"; 3 | export const redButtonStyle = "text-brand-white bg-brand-red border-brand-red"; 4 | -------------------------------------------------------------------------------- /assets/src/features/devices/disableSafariCache.ts: -------------------------------------------------------------------------------- 1 | export const disableSafariCache = () => { 2 | // https://stackoverflow.com/questions/8788802/prevent-safari-loading-from-cache-when-back-button-is-clicked 3 | window.onpageshow = (event) => { 4 | if (event.persisted) { 5 | window.location.reload(); 6 | } 7 | }; 8 | }; 9 | -------------------------------------------------------------------------------- /infra/grafana/provisioning/dashboards/dashboard.yml: -------------------------------------------------------------------------------- 1 | apiVersion: 1 2 | 3 | providers: 4 | - name: 'Default' 5 | orgId: 1 6 | folder: '' 7 | type: file 8 | disableDeletion: false 9 | updateIntervalSeconds: 10 10 | allowUiUpdates: true 11 | options: 12 | path: /etc/grafana/provisioning/dashboards 13 | 14 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Elixir Artifacts 2 | /_build/ 3 | /deps/ 4 | /doc/ 5 | /cover/ 6 | /.fetch 7 | *.ez 8 | APPNAME-*.tar 9 | erl_crash.dump 10 | 11 | 12 | # Node Artifacts 13 | npm-debug.log 14 | /assets/node_modules/ 15 | /priv/static/ 16 | 17 | 18 | # File uploads 19 | /uploads 20 | /test/uploads 21 | 22 | 23 | # Docker only 24 | /test/ 25 | /.iex.exs 26 | -------------------------------------------------------------------------------- /assets/.gitignore: -------------------------------------------------------------------------------- 1 | .yarn 2 | # Logs 3 | logs 4 | *.log 5 | npm-debug.log* 6 | yarn-debug.log* 7 | yarn-error.log* 8 | pnpm-debug.log* 9 | lerna-debug.log* 10 | 11 | node_modules 12 | dist 13 | dist-ssr 14 | *.local 15 | 16 | # Editor directories and files 17 | .vscode/* 18 | !.vscode/extensions.json 19 | .idea 20 | .DS_Store 21 | *.suo 22 | *.ntvs* 23 | *.njsproj 24 | *.sln 25 | *.sw? 26 | -------------------------------------------------------------------------------- /lib/videoroom_web/api_spec/token.ex: -------------------------------------------------------------------------------- 1 | defmodule VideoroomWeb.ApiSpec.Token do 2 | @moduledoc false 3 | 4 | require OpenApiSpex 5 | 6 | OpenApiSpex.schema(%{ 7 | title: "PeerToken", 8 | description: "Peer token used for authorizing websocket connection to the Fishjam Server", 9 | type: :string, 10 | example: "SFMyNTY.g2gDdAAhiOL4CdsaboT9-jtMzhoI" 11 | }) 12 | end 13 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # For production, don't forget to configure the url host 4 | # to something meaningful, Phoenix uses this information 5 | # when generating URLs. 6 | 7 | # Do not print debug messages in production 8 | config :logger, level: :info 9 | 10 | # Runtime production configuration, including reading 11 | # of environment variables, is done on config/runtime.exs. 12 | -------------------------------------------------------------------------------- /assets/src/features/home-page/components/Navbar.tsx: -------------------------------------------------------------------------------- 1 | import MembraneLogo from "../../shared/components/MembraneLogo"; 2 | import PlainLink from "../../shared/components/PlainLink"; 3 | import { FC } from "react"; 4 | 5 | const Navbar: FC = () => { 6 | return ( 7 | 8 | 9 | 10 | ); 11 | }; 12 | 13 | export default Navbar; 14 | -------------------------------------------------------------------------------- /assets/src/features/devices/BlurProcessorWorker.ts: -------------------------------------------------------------------------------- 1 | let intervalId: number; 2 | 3 | self.onmessage = (event) => { 4 | if (event.data.type === "start") { 5 | intervalId = self.setInterval(animationLoop, 1_000 / event.data.fps); 6 | } else if (event.data.type === "stop") { 7 | self.clearInterval(intervalId); 8 | } 9 | }; 10 | 11 | self.onclose = () => { 12 | self.clearInterval(intervalId); 13 | } 14 | 15 | function animationLoop() { 16 | self.postMessage("tick"); 17 | } 18 | -------------------------------------------------------------------------------- /assets/src/pages/room/components/StreamPlayer/simulcast/SimulcastReceivingEncoding.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { TrackEncoding } from "@fishjam-dev/react-client"; 3 | 4 | type Props = { 5 | encoding?: TrackEncoding; 6 | }; 7 | 8 | export const SimulcastReceivingEncoding: FC = ({ encoding }: Props) => ( 9 |
10 | Encoding: {encoding} 11 |
12 | ); 13 | -------------------------------------------------------------------------------- /assets/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: {browser: true, es2020: true}, 3 | extends: [ 4 | 'eslint:recommended', 5 | 'plugin:@typescript-eslint/recommended', 6 | 'plugin:react-hooks/recommended', 7 | ], 8 | parser: '@typescript-eslint/parser', 9 | parserOptions: {ecmaVersion: 'latest', sourceType: 'module'}, 10 | plugins: ['react-refresh'], 11 | rules: { 12 | 'react-refresh/only-export-components': 'warn', 13 | }, 14 | } 15 | -------------------------------------------------------------------------------- /assets/src/features/shared/hooks/useDebounce.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | const useDebounce = (value: T, delayMs: number): T => { 4 | const [debouncedValue, setDebouncedValue] = useState(value); 5 | 6 | useEffect(() => { 7 | const timer = setTimeout(() => setDebouncedValue(value), delayMs); 8 | 9 | return () => clearTimeout(timer); 10 | }, [value, delayMs]); 11 | 12 | return debouncedValue; 13 | }; 14 | 15 | export default useDebounce; 16 | -------------------------------------------------------------------------------- /assets/src/pages/room/hooks/useStoreFirstNonNullValue.tsx: -------------------------------------------------------------------------------- 1 | import { TrackEncoding } from "@fishjam-dev/react-client"; 2 | import { useEffect, useState } from "react"; 3 | 4 | export const useStoreFirstNonNullValue = (variable: TrackEncoding | null) => { 5 | const [value, setValue] = useState(variable); 6 | useEffect(() => { 7 | if (value === null && variable) { 8 | setValue(variable); 9 | } 10 | }, [value, variable]); 11 | 12 | return value; 13 | }; 14 | -------------------------------------------------------------------------------- /test/videoroom_web/controllers/error_json_test.exs: -------------------------------------------------------------------------------- 1 | defmodule VideoroomWeb.ErrorJSONTest do 2 | use VideoroomWeb.ConnCase, async: true 3 | 4 | test "renders 404" do 5 | assert VideoroomWeb.ErrorJSON.render("404.json", %{}) == %{ 6 | errors: %{detail: "Not Found"} 7 | } 8 | end 9 | 10 | test "renders 500" do 11 | assert VideoroomWeb.ErrorJSON.render("500.json", %{}) == 12 | %{errors: %{detail: "Internal Server Error"}} 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | #FISHJAM 2 | # TURN default configuration 3 | # note: loopback address as EXTERNAL_IP cannot be used inside a Docker container 4 | # EXTERNAL_IP= 5 | JF_SERVER_API_TOKEN="development" 6 | 7 | # SSL 8 | DOMAIN=localhost 9 | # BE_JF_ADDRESS= 10 | # BE_HOST= 11 | # BE_JF_SECURE_CONNECTION=false 12 | # JF_CHECK_ORIGIN=false 13 | 14 | GF_SECURITY_ADMIN_USER=ADMIN 15 | GF_SECURITY_ADMIN_PASSWORD=PASSWORD 16 | 17 | 18 | AGENT_PORT_APP_RECEIVER=8027 19 | 20 | -------------------------------------------------------------------------------- /assets/src/pages/room/hooks/useToggle.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useState } from "react"; 2 | 3 | type UseToggleResult = [boolean, () => void]; 4 | 5 | export const useToggle = (initialState = false, callback?: (newValue: boolean) => void): UseToggleResult => { 6 | const [state, setState] = useState(initialState); 7 | 8 | const toggle = useCallback(() => { 9 | setState((prevState) => { 10 | callback?.(!prevState); 11 | return !prevState; 12 | }); 13 | }, [callback]); 14 | 15 | return [state, toggle]; 16 | }; 17 | -------------------------------------------------------------------------------- /assets/src/api/index.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /* eslint-disable */ 3 | /** 4 | * Videoroom 5 | * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) 6 | * 7 | * The version of the OpenAPI document: 0.1.0 8 | * 9 | * 10 | * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). 11 | * https://openapi-generator.tech 12 | * Do not edit the class manually. 13 | */ 14 | 15 | 16 | export * from "./api"; 17 | export * from "./configuration"; 18 | 19 | -------------------------------------------------------------------------------- /assets/src/server-sdk/index.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /* eslint-disable */ 3 | /** 4 | * Videoroom 5 | * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) 6 | * 7 | * The version of the OpenAPI document: 0.1.0 8 | * 9 | * 10 | * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). 11 | * https://openapi-generator.tech 12 | * Do not edit the class manually. 13 | */ 14 | 15 | 16 | export * from "./api"; 17 | export * from "./configuration"; 18 | 19 | -------------------------------------------------------------------------------- /assets/src/features/room-page/components/NameTag.tsx: -------------------------------------------------------------------------------- 1 | import { LOCAL_PEER_NAME } from "../../../pages/room/consts"; 2 | 3 | type NameTagProps = { 4 | name: string; 5 | }; 6 | 7 | const NameTag = ({ name }: NameTagProps) => { 8 | const isMyself = name === LOCAL_PEER_NAME; 9 | const bgColor = isMyself ? "bg-brand-pink-500" : "bg-brand-dark-blue-400"; 10 | 11 | return ( 12 |
13 | {name} 14 |
15 | ); 16 | }; 17 | 18 | export default NameTag; 19 | -------------------------------------------------------------------------------- /assets/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Membrane Videoroom WebRTC demo 9 | 10 | 11 | 12 |
13 |
14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /assets/src/features/room-page/utils/computeLeftUpperIcon.tsx: -------------------------------------------------------------------------------- 1 | import { MediaPlayerTileConfig } from "../../../pages/types"; 2 | import { DisabledMicIcon, isLoading, showDisabledIcon } from "../components/DisabledTrackIcon"; 3 | import SoundIcon from "../components/SoundIcon"; 4 | 5 | export const getTileUpperLeftIcon = (config: MediaPlayerTileConfig): JSX.Element | null => { 6 | if (config.typeName !== "local" && config.typeName !== "remote") return null; 7 | 8 | if (showDisabledIcon(config.audio)) return ; 9 | 10 | return ; 11 | }; 12 | -------------------------------------------------------------------------------- /assets/src/pages/room/bandwidth.tsx: -------------------------------------------------------------------------------- 1 | import { BandwidthLimit, SimulcastBandwidthLimit, TrackBandwidthLimit } from "@fishjam-dev/react-client"; 2 | import { TrackType } from "../../fishjam.ts"; 3 | 4 | const NO_LIMIT: BandwidthLimit = 0; 5 | const DEFAULT_LIMIT: BandwidthLimit = 1500; 6 | export const SIMULCAST_BANDWIDTH_LIMITS: SimulcastBandwidthLimit = new Map([ 7 | ["h", 1500], 8 | ["m", 500], 9 | ["l", 100], 10 | ]); 11 | 12 | export const selectBandwidthLimit = (type: TrackType, simulcast: boolean): TrackBandwidthLimit => { 13 | if (type === "audio") return NO_LIMIT; 14 | if (simulcast) return SIMULCAST_BANDWIDTH_LIMITS; 15 | return DEFAULT_LIMIT; 16 | }; 17 | -------------------------------------------------------------------------------- /.github/workflows/debug_build_and_deploy.yml: -------------------------------------------------------------------------------- 1 | name: Staging Build and Deploy 2 | on: 3 | push: 4 | branches: 5 | - "debug-staging" 6 | paths: 7 | - "**" 8 | 9 | env: 10 | DOMAIN: room.fishjam.work 11 | BE_HOST: server.room.fishjam.work 12 | PROJECT: staging 13 | COMPOSE_FILE_NAME: docker-compose-deploy.yaml 14 | 15 | jobs: 16 | deploy1: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Debug staging 20 | uses: JimCronqvist/action-ssh@master 21 | with: 22 | hosts: ${{ secrets.STAGING_HOST }} 23 | privateKey: ${{ secrets.SSH_PRIV_KEY }} 24 | command: | 25 | cat test.txt 26 | 27 | -------------------------------------------------------------------------------- /assets/src/pages/room/utils.tsx: -------------------------------------------------------------------------------- 1 | const animalEmoji = ["🐶", "🐼", "🐧", "🐛", "🐙", "🦄", "🐷", "🐳", "🦁", "🦀", "🦦", "🐊", "🦇", "🐝", "🐌", "🐸"]; 2 | 3 | export const getRandomAnimalEmoji = () => animalEmoji[Math.floor(Math.random() * animalEmoji.length)]; 4 | 5 | export const groupBy = ( 6 | arr: Array, 7 | criteria: (it: IN) => KEY 8 | ): Partial>> => 9 | arr.reduce((acc, currentValue) => { 10 | if (!acc[criteria(currentValue)]) { 11 | acc[criteria(currentValue)] = []; 12 | } 13 | acc[criteria(currentValue)].push(currentValue); 14 | return acc; 15 | }, {} as Record>); 16 | -------------------------------------------------------------------------------- /lib/videoroom_web/controllers/fallback_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule VideoroomWeb.FallbackController do 2 | @moduledoc """ 3 | Translates controller action results into valid `Plug.Conn` responses. 4 | 5 | See `Phoenix.Controller.action_fallback/1` for more details. 6 | """ 7 | use VideoroomWeb, :controller 8 | 9 | # This clause is an example of how to handle resources that cannot be found. 10 | 11 | @spec call(Plug.Conn.t(), tuple()) :: Plug.Conn.t() 12 | def call(conn, {:error, :not_found}) do 13 | conn 14 | |> put_status(:not_found) 15 | |> put_view(html: VideoroomWeb.ErrorHTML, json: VideoroomWeb.ErrorJSON) 16 | |> render(:"404") 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | orbs: 3 | elixir: membraneframework/elixir@1 4 | 5 | executors: 6 | machine_executor_amd64: 7 | machine: 8 | image: ubuntu-2204:2022.04.2 9 | environment: 10 | architecture: "amd64" 11 | platform: "linux/amd64" 12 | 13 | jobs: 14 | test: 15 | executor: machine_executor_amd64 16 | steps: 17 | - checkout 18 | - run: docker compose -f docker-compose-integration.yaml run test 19 | 20 | workflows: 21 | version: 2 22 | build: 23 | jobs: 24 | - elixir/build_test: 25 | cache-version: 2 26 | - test 27 | - elixir/lint: 28 | cache-version: 2 29 | docs: false 30 | -------------------------------------------------------------------------------- /docker-compose-integration.yaml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | test: 5 | image: hexpm/elixir:1.14.4-erlang-25.3.2-alpine-3.16.5 6 | command: sh -c "cd app/ && apk add git && mix local.hex --force && mix local.rebar --force && mix deps.get && mix test --warnings-as-errors" 7 | environment: 8 | MIX_ENV: integration_test 9 | BE_JF_ADDRESS: fishjam:5002 10 | BE_JF_SECURE_CONNECTION: false 11 | 12 | volumes: 13 | - .:/app 14 | depends_on: 15 | fishjam: 16 | condition: service_healthy 17 | 18 | fishjam: 19 | extends: 20 | file: docker-compose-dev.yaml 21 | service: fishjam 22 | environment: 23 | - JF_HOST=fishjam:5002 24 | -------------------------------------------------------------------------------- /lib/videoroom_web/controllers/error_json.ex: -------------------------------------------------------------------------------- 1 | defmodule VideoroomWeb.ErrorJSON do 2 | # If you want to customize a particular status code, 3 | # you may add your own clauses, such as: 4 | # 5 | # def render("500.json", _assigns) do 6 | # %{errors: %{detail: "Internal Server Error"}} 7 | # end 8 | 9 | # By default, Phoenix returns the status message from 10 | # the template name. For example, "404.json" becomes 11 | # "Not Found". 12 | 13 | # credo:disable-for-this-file 14 | 15 | @spec render(any, any) :: %{errors: %{detail: <<_::16, _::_*8>>}} 16 | def render(template, _assigns) do 17 | %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}} 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /assets/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": [ 5 | "@typescript-eslint", 6 | "react-hooks" 7 | ], 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/eslint-recommended", 11 | "plugin:@typescript-eslint/recommended" 12 | ], 13 | "rules": { 14 | "react-hooks/rules-of-hooks": "error", 15 | "react-hooks/exhaustive-deps": "error", 16 | "@typescript-eslint/ban-ts-comment": "off", 17 | "@typescript-eslint/no-unused-vars": [ 18 | "warn", 19 | { 20 | "argsIgnorePattern": "^_", 21 | "varsIgnorePattern": "^_", 22 | "caughtErrorsIgnorePattern": "^_" 23 | } 24 | ] 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /assets/src/pages/room/hooks/useAcquireWakeLockAutomatically.tsx: -------------------------------------------------------------------------------- 1 | import { usePageVisibility } from "react-page-visibility"; 2 | import { useEffect } from "react"; 3 | 4 | export const useAcquireWakeLockAutomatically = () => { 5 | // https://caniuse.com/mdn-api_wakelock 6 | const isSupported = typeof window !== "undefined" && "wakeLock" in navigator; 7 | const isVisible = usePageVisibility(); 8 | 9 | useEffect(() => { 10 | if (!isSupported) return; 11 | 12 | if (isVisible) { 13 | const request = navigator.wakeLock.request("screen"); 14 | 15 | return () => { 16 | request.then((wakeLockSentinel) => wakeLockSentinel.release()); 17 | }; 18 | } 19 | }, [isSupported, isVisible]); 20 | }; 21 | -------------------------------------------------------------------------------- /assets/src/features/room-page/icons/Pin.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import { FC, SVGAttributes } from "react"; 3 | 4 | const Pin: FC> = (props) => { 5 | return ( 6 | 16 | 17 | 18 | 19 | ); 20 | }; 21 | 22 | export default Pin; 23 | -------------------------------------------------------------------------------- /assets/src/room.api.tsx: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { RoomApi } from "./api"; 3 | import { BACKEND_URL } from "./pages/room/consts"; 4 | 5 | const API = new RoomApi(undefined, BACKEND_URL.origin, axios); 6 | 7 | export const getTokenAndAddress = (roomId: string) => 8 | API.videoroomWebRoomControllerShow(roomId).then((resp) => { 9 | // @ts-ignore 10 | const address = resp?.data?.data?.fishjam_address || ""; 11 | 12 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 13 | // @ts-ignore 14 | const token = resp?.data?.data?.token || ""; 15 | return { token: token, serverAddress: address }; 16 | }); 17 | 18 | export const startRecording = (roomId: string) => 19 | API.videoroomWebRoomControllerStartRecording(roomId); 20 | -------------------------------------------------------------------------------- /assets/src/features/shared/utils/localStorage.ts: -------------------------------------------------------------------------------- 1 | export const loadObject = (key: string, defaultValue: T): T => { 2 | const stringValue = loadString(key, ""); 3 | if (stringValue === "") { 4 | return defaultValue; 5 | } 6 | return JSON.parse(stringValue) as T; 7 | }; 8 | export const loadString = (key: string, defaultValue = "") => { 9 | const value = localStorage.getItem(key); 10 | if (value === null || value === undefined) { 11 | return defaultValue; 12 | } 13 | return value; 14 | }; 15 | export const saveObject = (key: string, value: T) => { 16 | const stringValue = JSON.stringify(value); 17 | saveString(key, stringValue); 18 | }; 19 | export const saveString = (key: string, value: string) => { 20 | localStorage.setItem(key, value); 21 | }; 22 | -------------------------------------------------------------------------------- /infra/alloy/config.alloy: -------------------------------------------------------------------------------- 1 | faro.receiver "default" { 2 | server { 3 | listen_address = "0.0.0.0" 4 | listen_port = env("AGENT_PORT_APP_RECEIVER") 5 | api_key = env("ALLOY_API_KEY") 6 | cors_allowed_origins = ["*"] 7 | max_allowed_payload_size = "10MiB" 8 | 9 | rate_limiting { 10 | rate = 50 11 | } 12 | } 13 | 14 | 15 | extra_log_labels = { 16 | app = "videoroom", 17 | kind = "", 18 | } 19 | 20 | sourcemaps { 21 | } 22 | 23 | output { 24 | logs = [loki.write.default.receiver] 25 | traces = [] 26 | } 27 | } 28 | 29 | loki.write "default" { 30 | endpoint { 31 | url = "http://loki:3100/loki/api/v1/push" 32 | } 33 | } 34 | 35 | -------------------------------------------------------------------------------- /assets/src/features/room-page/icons/ChevronDown.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import { FC, SVGAttributes } from "react"; 3 | 4 | const ChevronDown: FC> = (props) => { 5 | return ( 6 | 16 | 23 | 24 | ); 25 | }; 26 | 27 | export default ChevronDown; 28 | -------------------------------------------------------------------------------- /assets/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "noImplicitAny": false, 23 | }, 24 | "include": ["src"], 25 | "exclude": ["src/api", "src/server-sdk"], 26 | "references": [{ "path": "./tsconfig.node.json" }] 27 | } 28 | 29 | -------------------------------------------------------------------------------- /assets/src/features/room-page/components/SoundIcon.tsx: -------------------------------------------------------------------------------- 1 | import SoundBig from "../icons/SoundBig"; 2 | import clsx from "clsx"; 3 | import useDebounce from "../../shared/hooks/useDebounce"; 4 | 5 | type Props = { 6 | visible: boolean; 7 | }; 8 | 9 | const SoundIcon = ({ visible }: Props) => { 10 | const debounceDelay = 200; 11 | const isVisibleDebounced = useDebounce(visible, debounceDelay); 12 | 13 | return ( 14 |
22 | 23 |
24 | ); 25 | }; 26 | 27 | export default SoundIcon; 28 | -------------------------------------------------------------------------------- /assets/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import App from "./App.tsx"; 4 | import "./index.css"; 5 | import { 6 | getWebInstrumentations, 7 | initializeFaro, 8 | } from '@grafana/faro-web-sdk'; 9 | 10 | 11 | 12 | initializeFaro({ 13 | url: `${window.location.origin}/collect`, 14 | apiKey: import.meta.env.VITE_ALLOY_API_KEY, 15 | app: { 16 | name: 'Videoroom', 17 | version: '1.0.0', 18 | }, 19 | instrumentations: [ 20 | ...getWebInstrumentations({ 21 | captureConsole: true, 22 | }), 23 | ], 24 | beforeSend: (item) => { 25 | return item 26 | } 27 | }); 28 | 29 | 30 | 31 | ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( 32 | 33 | 34 | 35 | ); 36 | -------------------------------------------------------------------------------- /assets/src/contexts/UserContext.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useState } from "react"; 2 | 3 | export type UserContextType = { 4 | username: string | null; 5 | setUsername: (name: string) => void; 6 | }; 7 | 8 | const UserContext = React.createContext(undefined); 9 | 10 | type Props = { 11 | children: React.ReactNode; 12 | }; 13 | 14 | export const UserProvider = ({ children }: Props) => { 15 | const [username, setUsername] = useState(null); 16 | 17 | return {children}; 18 | }; 19 | 20 | export const useUser = (): UserContextType => { 21 | const context = useContext(UserContext); 22 | if (!context) throw new Error("useUser must be used within a UserProvider"); 23 | return context; 24 | }; 25 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # We don't run a server during test. If one is required, 4 | # you can enable the server option below. 5 | config :videoroom, VideoroomWeb.Endpoint, 6 | http: [ip: {127, 0, 0, 1}, port: 4002], 7 | secret_key_base: "ZARcyCI5SXpckH0BXnmeifEvUBnxxyqIR6PZDcmHKOUH3EbUHCeep0pdjXLYYdSq", 8 | server: false 9 | 10 | config :fishjam_server_sdk, 11 | server_address: "localhost:5002", 12 | server_api_token: "development" 13 | 14 | config :videoroom, 15 | divo: "docker-compose-dev.yaml", 16 | divo_wait: [dwell: 1500, max_tries: 50], 17 | fishjam_address: "localhost:5002", 18 | peer_disconnected_timeout: 1, 19 | peerless_purge_timeout: 3 20 | 21 | config :logger, level: :info 22 | 23 | # Initialize plugs at runtime for faster test compilation 24 | config :phoenix, :plug_init_mode, :runtime 25 | -------------------------------------------------------------------------------- /assets/src/features/shared/components/BlockingScreen.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import { FC } from "react"; 3 | import RotateRight from "../../room-page/icons/RotateRight"; 4 | 5 | const BlockingScreen: FC<{ message?: string }> = ({ message }) => { 6 | return ( 7 |
13 | 14 |
Ooops!
15 |
{message}
16 |
17 | ); 18 | }; 19 | 20 | export default BlockingScreen; 21 | -------------------------------------------------------------------------------- /assets/src/contexts/ModalContext.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useState } from "react"; 2 | 3 | export type ModalContextType = { 4 | setOpen: (value: boolean) => void; 5 | isOpen: boolean; 6 | }; 7 | 8 | const ModelContext = React.createContext(undefined); 9 | 10 | type Props = { 11 | children: React.ReactNode; 12 | }; 13 | 14 | export const ModalProvider = ({ children }: Props) => { 15 | const [isOpen, setIsOpen] = useState(false); 16 | 17 | return ( 18 | setIsOpen(value), isOpen }}>{children} 19 | ); 20 | }; 21 | 22 | export const useModal = (): ModalContextType => { 23 | const context = useContext(ModelContext); 24 | if (!context) throw new Error("useModal must be used within a ModalProvider"); 25 | return context; 26 | }; 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # Assets 8 | /assets/node_modules/ 9 | 10 | # The directory Mix downloads your dependencies sources to. 11 | /deps/ 12 | 13 | # Where 3rd-party dependencies like ExDoc output generated docs. 14 | /doc/ 15 | 16 | # Ignore .fetch files in case you like to edit your project deps locally. 17 | /.fetch 18 | 19 | # If the VM crashes, it generates a dump, let's ignore it too. 20 | erl_crash.dump 21 | 22 | # Also ignore archive artifacts (built via "mix archive.build"). 23 | *.ez 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | 28 | # Ignore package tarball (built via "mix hex.build"). 29 | videoroom-*.tar 30 | 31 | # IntelliJ 32 | .idea 33 | *.iml 34 | 35 | .env 36 | 37 | certbot/ 38 | 39 | .vscode -------------------------------------------------------------------------------- /assets/src/features/devices/DeviceSelector.tsx: -------------------------------------------------------------------------------- 1 | import { SelectOption } from "../shared/components/Select"; 2 | import Input from "../shared/components/Input"; 3 | 4 | type Props = { 5 | name: string; 6 | devices: MediaDeviceInfo[] | null; 7 | setInput: (value: string | null) => void; 8 | inputValue: string | null; 9 | }; 10 | 11 | export const DeviceSelector = ({ name, devices, setInput, inputValue }: Props) => { 12 | const options: SelectOption[] = (devices || []).map(({ deviceId, label }) => ({ 13 | value: deviceId, 14 | label, 15 | })); 16 | 17 | return ( 18 | { 24 | setInput(option.value); 25 | }} 26 | value={options.find(({ value }) => value === inputValue)} 27 | /> 28 | ); 29 | }; 30 | -------------------------------------------------------------------------------- /assets/src/features/room-page/components/InitialsImage.tsx: -------------------------------------------------------------------------------- 1 | export const computeInitials = (name: string): string => { 2 | return name 3 | .split(" ") 4 | .filter((_, index) => index <= 1) 5 | .map((word) => word.charAt(0)) 6 | .join("") 7 | .toLocaleUpperCase(); 8 | }; 9 | 10 | type InitialsImageProps = { 11 | initials: string; 12 | }; 13 | 14 | const InitialsImage = ({ initials }: InitialsImageProps) => { 15 | return ( 16 |
17 |
18 | {initials} 19 |
20 |
21 | ); 22 | }; 23 | 24 | export default InitialsImage; 25 | -------------------------------------------------------------------------------- /docker-compose-dev.yaml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | fishjam: 5 | image: "ghcr.io/fishjam-dev/fishjam:${FISHJAM_VERSION:-edge}" 6 | container_name: fishjam 7 | restart: on-failure 8 | healthcheck: 9 | test: > 10 | curl --fail -H "authorization: Bearer development" http://localhost:5002/room || exit 1 11 | interval: 3s 12 | retries: 2 13 | timeout: 2s 14 | start_period: 30s 15 | environment: 16 | JF_PORT: 5002 17 | JF_HOST: "localhost:5002" 18 | JF_WEBRTC_TURN_IP: "${EXTERNAL_IP:-127.0.0.1}" 19 | JF_WEBRTC_TURN_LISTEN_IP: "0.0.0.0" 20 | JF_WEBRTC_TURN_PORT_RANGE: "50000-50050" 21 | JF_WEBRTC_TURN_TCP_PORT: "49999" 22 | JF_SERVER_API_TOKEN: "development" 23 | JF_CHECK_ORIGIN: "false" 24 | ports: 25 | - "5002:5002" 26 | - "49999:49999" 27 | - "50000-50050:50000-50050/udp" 28 | -------------------------------------------------------------------------------- /assets/src/features/room-page/components/DisabledTrackIcon.tsx: -------------------------------------------------------------------------------- 1 | import MicrophoneOff from "../icons/MicrophoneOff"; 2 | import { TrackWithId } from "../../../pages/types"; 3 | import clsx from "clsx"; 4 | 5 | type DisabledMicIconProps = { 6 | isLoading: boolean; 7 | }; 8 | 9 | export const DisabledMicIcon = ({ isLoading }: DisabledMicIconProps) => { 10 | return ( 11 |
12 | 13 |
14 | ); 15 | }; 16 | 17 | export const isLoading = (track: TrackWithId | null) => 18 | track !== null && track?.stream === undefined && track?.metadata?.active === true; 19 | export const showDisabledIcon = (track: TrackWithId | null) => 20 | track === null || !track?.stream || track?.metadata?.active === false; 21 | -------------------------------------------------------------------------------- /assets/src/features/room-page/icons/Chat.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import { FC, SVGAttributes } from "react"; 3 | 4 | const Chat: FC> = (props) => { 5 | return ( 6 | 16 | 17 | 18 | 19 | 20 | 21 | ); 22 | }; 23 | 24 | export default Chat; 25 | -------------------------------------------------------------------------------- /assets/src/features/room-page/icons/Camera.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import { FC, SVGAttributes } from "react"; 3 | 4 | const Camera: FC> = (props) => { 5 | return ( 6 | 16 | 22 | 23 | 24 | ); 25 | }; 26 | 27 | export default Camera; 28 | -------------------------------------------------------------------------------- /test/support/peer.ex: -------------------------------------------------------------------------------- 1 | defmodule Videoroom.Test.Peer do 2 | @moduledoc false 3 | 4 | # A module mocking the websocket connection from a WebRTC peer 5 | 6 | use WebSockex 7 | 8 | alias Fishjam.PeerMessage 9 | alias Fishjam.PeerMessage.AuthRequest 10 | 11 | @spec start_link(binary | WebSockex.Conn.t(), any) :: {:ok, pid} 12 | def start_link(url, token) do 13 | connect(url, token) 14 | end 15 | 16 | @impl true 17 | def handle_info(:terminate, state) do 18 | {:close, state} 19 | end 20 | 21 | @impl true 22 | def handle_frame(_frame, state) do 23 | {:ok, state} 24 | end 25 | 26 | defp connect(url, token) do 27 | auth_request = 28 | %PeerMessage{content: {:auth_request, %AuthRequest{token: token}}} |> PeerMessage.encode() 29 | 30 | {:ok, client} = WebSockex.start_link(url, __MODULE__, []) 31 | :ok = WebSockex.send_frame(client, {:binary, auth_request}) 32 | 33 | {:ok, client} 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /assets/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react"; 3 | import checker from "vite-plugin-checker"; 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | server: { 8 | // https://vitejs.dev/config/server-options.html#server-host 9 | // true - listen on all addresses, including LAN and public addresses 10 | host: false, 11 | // https: true, 12 | port: 5005, 13 | strictPort: true, 14 | proxy: { 15 | "/api": { 16 | target: "http://127.0.0.1:5004", 17 | changeOrigin: false, 18 | }, 19 | "/socket/peer/websocket": { 20 | ws: true, 21 | target: "ws://127.0.0.1:5002", 22 | changeOrigin: false, 23 | }, 24 | }, 25 | }, 26 | plugins: [ 27 | react(), 28 | checker({ 29 | typescript: true, 30 | eslint: { 31 | lintCommand: "eslint --ext .ts,.tsx", 32 | }, 33 | }), 34 | ], 35 | }); 36 | -------------------------------------------------------------------------------- /assets/src/features/room-page/icons/MenuDots.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import { FC, SVGAttributes } from "react"; 3 | 4 | const MenuDots: FC> = (props) => { 5 | return ( 6 | 15 | 20 | 21 | ); 22 | }; 23 | 24 | export default MenuDots; 25 | -------------------------------------------------------------------------------- /assets/src/pages/room/components/StreamPlayer/simulcast/LayerButton.tsx: -------------------------------------------------------------------------------- 1 | import { Tooltip } from "./Tooltip"; 2 | import Button from "../../../../../features/shared/components/Button"; 3 | import clsx from "clsx"; 4 | 5 | export type LayerButtonProps = { 6 | text: string; 7 | tooltipText: string; 8 | onClick: () => void; 9 | disabled?: boolean; 10 | selected?: boolean; 11 | tooltipCss?: string; 12 | }; 13 | 14 | export const LayerButton = ({ onClick, text, tooltipText, disabled, selected, tooltipCss = "" }: LayerButtonProps) => ( 15 | 16 | 27 | 28 | ); 29 | -------------------------------------------------------------------------------- /assets/src/features/shared/components/Checkbox.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import { FC } from "react"; 3 | 4 | export type CheckboxProps = { 5 | label: string; 6 | id: string; 7 | status: boolean; 8 | onChange: () => void; 9 | disabled?: boolean; 10 | textSize?: "small" | "base"; 11 | }; 12 | 13 | 14 | export const Checkbox: FC = ({ label, id, status, onChange, disabled, textSize = "small" }: CheckboxProps) => { 15 | return ( 16 |
17 | 20 | 29 |
30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /lib/videoroom_web/api_spec.ex: -------------------------------------------------------------------------------- 1 | defmodule VideoroomWeb.ApiSpec do 2 | @moduledoc false 3 | @behaviour OpenApiSpex.OpenApi 4 | 5 | alias OpenApiSpex.{Info, Paths, Schema, Server} 6 | 7 | # OpenAPISpex master specification 8 | 9 | @impl OpenApiSpex.OpenApi 10 | def spec() do 11 | %OpenApiSpex.OpenApi{ 12 | info: %Info{ 13 | title: "Videoroom", 14 | version: "0.1.0" 15 | }, 16 | servers: [ 17 | Server.from_endpoint(VideoroomWeb.Endpoint) 18 | ], 19 | paths: Paths.from_router(VideoroomWeb.Router) 20 | } 21 | |> OpenApiSpex.resolve_schema_modules() 22 | end 23 | 24 | @spec data(String.t(), Schema.t()) :: {String.t(), String.t(), Schema.t()} 25 | def data(description, schema) do 26 | {description, "application/json", schema} 27 | end 28 | 29 | @spec error(String.t()) :: {String.t(), String.t(), module()} 30 | def error(description) do 31 | {description, "application/json", VideoroomWeb.ApiSpec.Error} 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /assets/src/features/shared/components/Toast.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import Close from "../../room-page/icons/Close"; 3 | import { ToastType } from "../context/ToastContext"; 4 | import Button from "./Button"; 5 | import { FC } from "react"; 6 | 7 | type ToastProps = ToastType & { onClose: () => void }; 8 | 9 | const Toast: FC = ({ id, message, onClose, type = "information" }) => { 10 | return ( 11 |
21 |
{message}
22 | 25 |
26 | ); 27 | }; 28 | 29 | export default Toast; 30 | -------------------------------------------------------------------------------- /assets/src/features/room-page/icons/ShareSquare.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import { FC, SVGAttributes } from "react"; 3 | 4 | const ShareSquare: FC> = (props) => { 5 | return ( 6 | 16 | 22 | 23 | ); 24 | }; 25 | 26 | export default ShareSquare; 27 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Config module. 3 | # 4 | # This configuration file is loaded before any dependency and 5 | # is restricted to this project. 6 | 7 | # General application configuration 8 | import Config 9 | 10 | # Configures the endpoint 11 | config :videoroom, VideoroomWeb.Endpoint, 12 | url: [host: "localhost"], 13 | render_errors: [ 14 | formats: [json: VideoroomWeb.ErrorJSON], 15 | layout: false 16 | ], 17 | pubsub_server: Videoroom.PubSub, 18 | live_view: [signing_salt: "qiQmVeXX"] 19 | 20 | # Configures Elixir's Logger 21 | config :logger, :console, 22 | format: "$time $metadata[$level] $message\n", 23 | metadata: [:request_id, :room_name] 24 | 25 | # Use Jason for JSON parsing in Phoenix 26 | config :phoenix, :json_library, Jason 27 | 28 | # Import environment specific config. This must remain at the bottom 29 | # of this file so it overrides the configuration defined above. 30 | import_config "#{config_env()}.exs" 31 | -------------------------------------------------------------------------------- /assets/src/pages/types.ts: -------------------------------------------------------------------------------- 1 | import { TrackEncoding } from "@fishjam-dev/react-client"; 2 | 3 | const StreamSourceValues = ["local", "remote"] as const; 4 | export type StreamSource = (typeof StreamSourceValues)[number]; 5 | 6 | export type TrackWithId = { 7 | stream?: MediaStream; 8 | track?: MediaStreamTrack; 9 | remoteTrackId: string | null; 10 | encodingQuality: TrackEncoding | null; 11 | metadata?: any; // eslint-disable-line @typescript-eslint/no-explicit-any 12 | isSpeaking?: boolean; 13 | enabled?: boolean; 14 | }; 15 | 16 | // Media Tile Types 17 | type CommonTile = { 18 | mediaPlayerId: string; 19 | peerId: string; 20 | video: TrackWithId | null; 21 | displayName: string; 22 | streamSource: StreamSource; 23 | }; 24 | 25 | export type PeerTileConfig = { 26 | typeName: StreamSource; 27 | audio: TrackWithId | null; 28 | isSpeaking: boolean; 29 | initials: string; 30 | } & CommonTile; 31 | 32 | export type ScreenShareTileConfig = { 33 | typeName: "screenShare"; 34 | } & CommonTile; 35 | 36 | export type MediaPlayerTileConfig = PeerTileConfig | ScreenShareTileConfig; 37 | -------------------------------------------------------------------------------- /assets/src/features/devices/emptyVideoWorker.ts: -------------------------------------------------------------------------------- 1 | let requestId: number | null = null; 2 | let ctx: any | null = null; 3 | 4 | self.onmessage = (event) => { 5 | if (event.data.action === "init") { 6 | const canvasElement = event.data.canvas; 7 | 8 | canvasElement.width = 1280; 9 | canvasElement.height = 720; 10 | 11 | ctx = canvasElement.getContext("2d"); 12 | if (!ctx) throw "ctx is null"; 13 | 14 | const draw = (_time: DOMHighResTimeStamp) => { 15 | ctx.fillStyle = "black"; 16 | ctx.fillRect(0, 0, 1280, 720); 17 | 18 | requestId = requestAnimationFrame(draw); 19 | }; 20 | 21 | requestId = requestAnimationFrame(draw); 22 | } else if (event.data.action === "stop") { 23 | if (requestId) { 24 | cancelAnimationFrame(requestId); 25 | } 26 | } else if (event.data.action === "start") { 27 | const draw = (_time: DOMHighResTimeStamp) => { 28 | ctx.fillStyle = "black"; 29 | ctx.fillRect(0, 0, 1280, 720); 30 | 31 | requestId = requestAnimationFrame(draw); 32 | }; 33 | 34 | requestId = requestAnimationFrame(draw); 35 | } 36 | }; 37 | -------------------------------------------------------------------------------- /assets/src/features/room-page/icons/CameraOff.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import { FC, SVGAttributes } from "react"; 3 | 4 | const CameraOff: FC> = (props) => { 5 | return ( 6 | 16 | 22 | 23 | 29 | 30 | 31 | ); 32 | }; 33 | 34 | export default CameraOff; 35 | -------------------------------------------------------------------------------- /test/support/protos/jellyfish/peer_notifications.pb.ex: -------------------------------------------------------------------------------- 1 | defmodule Fishjam.PeerMessage.Authenticated do 2 | @moduledoc false 3 | 4 | use Protobuf, protoc_gen_elixir_version: "0.12.0", syntax: :proto3 5 | end 6 | 7 | defmodule Fishjam.PeerMessage.AuthRequest do 8 | @moduledoc false 9 | 10 | use Protobuf, protoc_gen_elixir_version: "0.12.0", syntax: :proto3 11 | 12 | field :token, 1, type: :string 13 | end 14 | 15 | defmodule Fishjam.PeerMessage.MediaEvent do 16 | @moduledoc false 17 | 18 | use Protobuf, protoc_gen_elixir_version: "0.12.0", syntax: :proto3 19 | 20 | field :data, 1, type: :string 21 | end 22 | 23 | defmodule Fishjam.PeerMessage do 24 | @moduledoc false 25 | 26 | use Protobuf, protoc_gen_elixir_version: "0.12.0", syntax: :proto3 27 | 28 | oneof :content, 0 29 | 30 | field :authenticated, 1, type: Fishjam.PeerMessage.Authenticated, oneof: 0 31 | 32 | field :auth_request, 2, 33 | type: Fishjam.PeerMessage.AuthRequest, 34 | json_name: "authRequest", 35 | oneof: 0 36 | 37 | field :media_event, 3, type: Fishjam.PeerMessage.MediaEvent, json_name: "mediaEvent", oneof: 0 38 | end 39 | -------------------------------------------------------------------------------- /assets/src/features/room-page/icons/Close.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import { FC, SVGAttributes } from "react"; 3 | 4 | const Close: FC> = (props) => { 5 | return ( 6 | 15 | 20 | 21 | ); 22 | }; 23 | 24 | export default Close; 25 | -------------------------------------------------------------------------------- /assets/src/api/.openapi-generator-ignore: -------------------------------------------------------------------------------- 1 | # OpenAPI Generator Ignore 2 | # Generated by openapi-generator https://github.com/openapitools/openapi-generator 3 | 4 | # Use this file to prevent files from being overwritten by the generator. 5 | # The patterns follow closely to .gitignore or .dockerignore. 6 | 7 | # As an example, the C# client generator defines ApiClient.cs. 8 | # You can make changes and tell OpenAPI Generator to ignore just this file by uncommenting the following line: 9 | #ApiClient.cs 10 | 11 | # You can match any string of characters against a directory, file or extension with a single asterisk (*): 12 | #foo/*/qux 13 | # The above matches foo/bar/qux and foo/baz/qux, but not foo/bar/baz/qux 14 | 15 | # You can recursively match patterns against a directory, file or extension with a double asterisk (**): 16 | #foo/**/qux 17 | # This matches foo/bar/qux, foo/baz/qux, and foo/bar/baz/qux 18 | 19 | # You can also negate patterns with an exclamation (!). 20 | # For example, you can ignore all files in a docs folder with the file extension .md: 21 | #docs/*.md 22 | # Then explicitly reverse the ignore rule for a single file: 23 | #!docs/README.md 24 | -------------------------------------------------------------------------------- /assets/src/server-sdk/.openapi-generator-ignore: -------------------------------------------------------------------------------- 1 | # OpenAPI Generator Ignore 2 | # Generated by openapi-generator https://github.com/openapitools/openapi-generator 3 | 4 | # Use this file to prevent files from being overwritten by the generator. 5 | # The patterns follow closely to .gitignore or .dockerignore. 6 | 7 | # As an example, the C# client generator defines ApiClient.cs. 8 | # You can make changes and tell OpenAPI Generator to ignore just this file by uncommenting the following line: 9 | #ApiClient.cs 10 | 11 | # You can match any string of characters against a directory, file or extension with a single asterisk (*): 12 | #foo/*/qux 13 | # The above matches foo/bar/qux and foo/baz/qux, but not foo/bar/baz/qux 14 | 15 | # You can recursively match patterns against a directory, file or extension with a double asterisk (**): 16 | #foo/**/qux 17 | # This matches foo/bar/qux, foo/baz/qux, and foo/bar/baz/qux 18 | 19 | # You can also negate patterns with an exclamation (!). 20 | # For example, you can ignore all files in a docs folder with the file extension .md: 21 | #docs/*.md 22 | # Then explicitly reverse the ignore rule for a single file: 23 | #!docs/README.md 24 | -------------------------------------------------------------------------------- /lib/videoroom_web/router.ex: -------------------------------------------------------------------------------- 1 | defmodule VideoroomWeb.Router do 2 | use VideoroomWeb, :router 3 | 4 | pipeline :api do 5 | plug :accepts, ["json"] 6 | end 7 | 8 | scope "/api", VideoroomWeb do 9 | pipe_through :api 10 | 11 | get "/room/:room_name", RoomController, :show 12 | get "/room/:room_name/exist", RoomController, :room_exists 13 | post "/room/:room_name/start_recording", RoomController, :start_recording 14 | end 15 | 16 | # Enable LiveDashboard in development 17 | if Application.compile_env(:videoroom, :dev_routes) do 18 | # If you want to use the LiveDashboard in production, you should put 19 | # it behind authentication and allow only admins to access it. 20 | # If your application does not have an admins-only section yet, 21 | # you can use Plug.BasicAuth to set up some basic authentication 22 | # as long as you are also using SSL (which you should anyway). 23 | import Phoenix.LiveDashboard.Router 24 | 25 | scope "/dev" do 26 | pipe_through [:fetch_session, :protect_from_forgery] 27 | 28 | live_dashboard "/dashboard", metrics: VideoroomWeb.Telemetry 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /assets/src/features/shared/hooks/useEffectOnChange.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | 3 | /** 4 | * This hook only triggers an effect function if the value passed as the first parameter changes. 5 | * 6 | * @param value - Effect will only activate if the value changes. 7 | * @param effect - Imperative function that can return a cleanup function. 8 | * @param comparator - If the value is not a primitive type, such as a numerical or string value, 9 | * you must provide a comparator function to prevent unnecessary invocations. 10 | */ 11 | const useEffectOnChange = ( 12 | value: T, 13 | effect: (prevValue?: T, currentValue?: T) => void | (() => void), 14 | comparator?: (a: T, b?: T) => boolean 15 | ) => { 16 | const prevValueRef = useRef(); 17 | 18 | useEffect(() => { 19 | prevValueRef.current = value; 20 | }); 21 | 22 | const prevValue = prevValueRef.current; 23 | 24 | useEffect(() => { 25 | if (comparator ? !comparator(value, prevValue) : value !== prevValue) { 26 | return effect(prevValue, value); 27 | } else return; 28 | }, [comparator, effect, prevValue, value]); 29 | }; 30 | 31 | export default useEffectOnChange; 32 | -------------------------------------------------------------------------------- /infra/loki/loki-config.yaml: -------------------------------------------------------------------------------- 1 | auth_enabled: false 2 | 3 | server: 4 | http_listen_port: 3100 5 | 6 | ingester: 7 | lifecycler: 8 | address: 127.0.0.1 9 | ring: 10 | kvstore: 11 | store: inmemory 12 | replication_factor: 1 13 | final_sleep: 0s 14 | chunk_idle_period: 5m 15 | chunk_retain_period: 30s 16 | wal: 17 | dir: /tmp/loki/wal 18 | 19 | schema_config: 20 | configs: 21 | - from: 2020-05-15 22 | store: boltdb-shipper 23 | object_store: filesystem 24 | schema: v11 25 | index: 26 | prefix: index_ 27 | period: 24h 28 | 29 | storage_config: 30 | boltdb_shipper: 31 | active_index_directory: /tmp/loki/boltdb-shipper-active 32 | cache_location: /tmp/loki/boltdb-shipper-cache 33 | cache_ttl: 24h 34 | shared_store: filesystem 35 | filesystem: 36 | directory: /tmp/loki/chunks 37 | 38 | limits_config: 39 | enforce_metric_name: false 40 | reject_old_samples: true 41 | reject_old_samples_max_age: 168h 42 | 43 | chunk_store_config: 44 | max_look_back_period: 0s 45 | 46 | compactor: 47 | working_directory: /tmp/loki/compactor 48 | shared_store: filesystem 49 | 50 | -------------------------------------------------------------------------------- /assets/src/features/room-page/components/PageLayout.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import { FC, PropsWithChildren } from "react"; 3 | import BlockingScreen from "../../shared/components/BlockingScreen"; 4 | import Navbar from "./Navbar"; 5 | import useSmartphoneViewport from "../../shared/hooks/useSmartphoneViewport"; 6 | 7 | const PageLayout: FC = ({ children }) => { 8 | const { isSmartphone, isHorizontal } = useSmartphoneViewport(); 9 | 10 | const shouldBlockScreen = isSmartphone && isHorizontal; 11 | return ( 12 | <> 13 | {shouldBlockScreen && } 14 |
22 | 23 | 24 |
{children}
25 |
26 | 27 | ); 28 | }; 29 | 30 | export default PageLayout; 31 | -------------------------------------------------------------------------------- /assets/src/features/room-page/icons/HangUp.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import { FC, SVGAttributes } from "react"; 3 | 4 | const HangUp: FC> = (props) => { 5 | return ( 6 | 16 | 22 | 23 | ); 24 | }; 25 | 26 | export default HangUp; 27 | -------------------------------------------------------------------------------- /assets/src/features/room-page/icons/RotateRight.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import { FC, SVGAttributes } from "react"; 3 | 4 | const RotateRight: FC> = (props) => { 5 | return ( 6 | 16 | 23 | 24 | 25 | ); 26 | }; 27 | 28 | export default RotateRight; 29 | -------------------------------------------------------------------------------- /assets/src/features/room-page/icons/SoundBig.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import { FC, SVGAttributes } from "react"; 3 | 4 | const SoundBig: FC> = (props) => { 5 | return ( 6 | 15 | 20 | 25 | 30 | 31 | ); 32 | }; 33 | 34 | export default SoundBig; 35 | -------------------------------------------------------------------------------- /assets/src/features/room-page/icons/Screenshare.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import { FC, SVGAttributes } from "react"; 3 | 4 | const Screenshare: FC> = (props) => { 5 | return ( 6 | 16 | 17 | 18 | 19 | 20 | 26 | 27 | ); 28 | }; 29 | 30 | export default Screenshare; 31 | -------------------------------------------------------------------------------- /assets/nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 5005; 3 | 4 | 5 | location /grafana/ { 6 | proxy_pass http://grafana:3000/; 7 | proxy_set_header Host $http_host; 8 | proxy_set_header X-Real-IP $remote_addr; 9 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 10 | proxy_set_header X-Forwarded-Proto $scheme; 11 | } 12 | 13 | # Proxy Grafana Live WebSocket connections 14 | location /grafana/api/live/ { 15 | proxy_http_version 1.1; 16 | proxy_set_header Upgrade $http_upgrade; 17 | proxy_set_header Connection "upgrade"; 18 | proxy_set_header Host $http_host; 19 | proxy_pass http://grafana:3000/api/live/; 20 | } 21 | 22 | 23 | location /collect { 24 | proxy_pass_request_headers on; 25 | proxy_pass_request_body on; 26 | proxy_pass http://alloy:8027/collect; 27 | proxy_set_header Host $host; 28 | proxy_set_header X-Real-IP $remote_addr; 29 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 30 | proxy_set_header X-Forwarded-Proto $scheme; 31 | } 32 | 33 | 34 | 35 | location / { 36 | root /usr/share/nginx/html/; 37 | include /etc/nginx/mime.types; 38 | try_files $uri $uri/ /index.html; 39 | } 40 | } -------------------------------------------------------------------------------- /assets/src/pages/room/hooks/useSimulcastRemoteEncoding.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useRef, useState } from "react"; 2 | import { TrackEncoding } from "@fishjam-dev/react-client"; 3 | import { useClient } from "../../../fishjam.ts"; 4 | 5 | export type UseSimulcastRemoteEncodingResult = { 6 | targetEncoding: TrackEncoding | null; 7 | setTargetEncoding: (quality: TrackEncoding) => void; 8 | }; 9 | 10 | export const useSimulcastRemoteEncoding = ( 11 | peerId: string | null, 12 | trackId: string | null 13 | ): UseSimulcastRemoteEncodingResult => { 14 | const [targetEncoding, setTargetEncodingState] = useState(null); 15 | 16 | const client = useClient(); 17 | 18 | const lastSelectedEncoding = useRef(null); 19 | const setTargetEncoding = useCallback( 20 | (encoding: TrackEncoding) => { 21 | if (lastSelectedEncoding.current === encoding) return; 22 | lastSelectedEncoding.current = encoding; 23 | setTargetEncodingState(encoding); 24 | 25 | if (!trackId || !peerId || !client) return; 26 | client.setTargetTrackEncoding(trackId, encoding); 27 | }, 28 | [peerId, trackId, client] 29 | ); 30 | 31 | return { setTargetEncoding, targetEncoding }; 32 | }; 33 | -------------------------------------------------------------------------------- /assets/src/pages/room/components/StreamPlayer/simulcast/Tooltip.tsx: -------------------------------------------------------------------------------- 1 | import { FC, PropsWithChildren } from "react"; 2 | import clsx from "clsx"; 3 | 4 | export type TooltipProps = PropsWithChildren & { 5 | text: string; 6 | textCss?: string; 7 | }; 8 | export const Tooltip: FC = ({ children, text, textCss }: TooltipProps) => { 9 | return ( 10 |
11 | {children} 12 | 13 |
14 |
20 | 26 | {text} 27 | 28 |
29 |
30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /test/support/conn_case.ex: -------------------------------------------------------------------------------- 1 | defmodule VideoroomWeb.ConnCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | tests that require setting up a connection. 5 | 6 | Such tests rely on `Phoenix.ConnTest` and also 7 | import other functionality to make it easier 8 | to build common data structures and query the data layer. 9 | 10 | Finally, if the test case interacts with the database, 11 | we enable the SQL sandbox, so changes done to the database 12 | are reverted at the end of every test. If you are using 13 | PostgreSQL, you can even run database tests asynchronously 14 | by setting `use VideoroomWeb.ConnCase, async: true`, although 15 | this option is not recommended for other databases. 16 | """ 17 | 18 | use ExUnit.CaseTemplate 19 | 20 | using do 21 | quote do 22 | # The default endpoint for testing 23 | @endpoint VideoroomWeb.Endpoint 24 | 25 | use VideoroomWeb, :verified_routes 26 | 27 | # Import conveniences for testing with connections 28 | import Plug.Conn 29 | import Phoenix.ConnTest 30 | import VideoroomWeb.ConnCase 31 | end 32 | end 33 | 34 | setup _tags do 35 | {:ok, conn: Phoenix.ConnTest.build_conn()} 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /assets/src/features/room-page/icons/Microphone.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import { FC, SVGAttributes } from "react"; 3 | 4 | const Microphone: FC> = (props) => { 5 | return ( 6 | 15 | 16 | 17 | ); 18 | }; 19 | 20 | export default Microphone; 21 | -------------------------------------------------------------------------------- /lib/videoroom/room_registry.ex: -------------------------------------------------------------------------------- 1 | defmodule Videoroom.RoomRegistry do 2 | @moduledoc false 3 | 4 | alias Videoroom.Meeting 5 | 6 | @room_table :room_table 7 | 8 | @spec create() :: atom() 9 | def create() do 10 | :ets.new(@room_table, [:named_table, :set, :public]) 11 | end 12 | 13 | @spec lookup_meeting(Meeting.name()) :: {:ok, Fishjam.Room.id()} | {:error, :unregistered} 14 | def lookup_meeting(name), do: lookup(name) 15 | 16 | @spec lookup_room(Fishjam.Room.id()) :: {:ok, Meeting.name()} | {:error, :unregistered} 17 | def lookup_room(id), do: lookup(id) 18 | 19 | defp lookup(name_or_id) do 20 | case :ets.lookup(@room_table, name_or_id) do 21 | [] -> 22 | {:error, :unregistered} 23 | 24 | [{^name_or_id, id_or_name}] -> 25 | {:ok, id_or_name} 26 | end 27 | end 28 | 29 | @spec insert_new(Meeting.name(), Fishjam.Room.id()) :: boolean() 30 | def insert_new(name, room_id) do 31 | :ets.insert_new(@room_table, [{name, room_id}, {room_id, name}]) 32 | end 33 | 34 | @spec delete(Meeting.name()) :: true 35 | def delete(name) do 36 | case lookup_meeting(name) do 37 | {:ok, id} -> 38 | :ets.delete(@room_table, name) 39 | :ets.delete(@room_table, id) 40 | 41 | _error -> 42 | true 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /assets/src/pages/room/components/StreamPlayer/PeerInfoLayer.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import clsx from "clsx"; 3 | 4 | type Props = { 5 | topLeft?: JSX.Element | null; 6 | topRight?: JSX.Element | null; 7 | bottomLeft?: JSX.Element | null; 8 | bottomRight?: JSX.Element | null; 9 | tileSize?: "M" | "L"; 10 | }; 11 | 12 | type Corner = { 13 | x: "left-0" | "right-0"; 14 | y: "top-0" | "bottom-0"; 15 | content?: JSX.Element | null; 16 | }; 17 | 18 | const PeerInfoLayer: FC = ({ topLeft, topRight, bottomLeft, bottomRight, tileSize = "L" }: Props) => { 19 | const corners: Corner[] = [ 20 | { x: "left-0", y: "top-0", content: topLeft }, 21 | { x: "right-0", y: "top-0", content: topRight }, 22 | { x: "left-0", y: "bottom-0", content: bottomLeft }, 23 | { x: "right-0", y: "bottom-0", content: bottomRight }, 24 | ]; 25 | const paddingClassName = { M: "p-3", L: "p-4" }; 26 | 27 | return ( 28 | <> 29 | {corners.map((corner) => ( 30 |
35 | {corner.content} 36 |
37 | ))} 38 | 39 | ); 40 | }; 41 | 42 | export default PeerInfoLayer; 43 | -------------------------------------------------------------------------------- /assets/Dockerfile: -------------------------------------------------------------------------------- 1 | # ==== CONFIGURE ===== 2 | FROM node:20-alpine3.20 as builder 3 | RUN corepack enable 4 | 5 | WORKDIR '/app' 6 | 7 | ARG FE_BE_HOST 8 | ARG ALLOY_API_KEY 9 | ARG FISHJAM_ROOM_VERSION 10 | ARG FISHJAM_VERSION 11 | ENV VITE_BE_HOST=$FE_BE_HOST 12 | ENV VITE_FISHJAM_ROOM_VERSION=$FISHJAM_ROOM_VERSION 13 | ENV VITE_FISHJAM_VERSION=$FISHJAM_VERSION 14 | ENV VITE_ALLOY_API_KEY=$ALLOY_API_KEY 15 | 16 | # Install nodejs and npm 17 | RUN apk add --no-cache git 18 | 19 | # Own the app directory 20 | RUN chown node:node ./ 21 | 22 | # It's adviced to run the container as non-root user 23 | # for added security if the base image has the user 24 | USER node 25 | 26 | COPY package*.json ./ 27 | COPY .yarnrc.yml ./ 28 | COPY yarn.lock ./ 29 | 30 | # ==== BUILD ===== 31 | # Install dependencies 32 | RUN yarn --immutable 33 | 34 | # Copy application files 35 | COPY . . 36 | 37 | # Build the app 38 | RUN yarn build 39 | 40 | # ==== SERVE ===== 41 | 42 | # Bundle static assets with nginx 43 | FROM nginx:1.25-alpine as production 44 | 45 | ENV NODE_ENV=production 46 | 47 | # Copy built assets from `builder` image 48 | COPY --from=builder /app/dist /usr/share/nginx/html 49 | 50 | # Add your nginx.conf 51 | COPY nginx.conf /etc/nginx/conf.d/default.conf 52 | 53 | # Expose port 54 | EXPOSE 5005 55 | 56 | # Start nginx 57 | CMD ["nginx", "-g", "daemon off;"] 58 | -------------------------------------------------------------------------------- /lib/videoroom/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Videoroom.Application do 2 | # See https://hexdocs.pm/elixir/Application.html 3 | # for more information on OTP Applications 4 | @moduledoc false 5 | 6 | use Application 7 | 8 | alias Videoroom.RoomRegistry 9 | 10 | @impl true 11 | def start(_type, _args) do 12 | RoomRegistry.create() 13 | 14 | children = [ 15 | # Start the Telemetry supervisor 16 | VideoroomWeb.Telemetry, 17 | # Registry and Supervisor, which manage Meetings 18 | {Registry, keys: :unique, name: Videoroom.Registry}, 19 | Videoroom.RoomService, 20 | # Start the PubSub system 21 | {Phoenix.PubSub, name: Videoroom.PubSub}, 22 | # Start the Endpoint (http/https) 23 | VideoroomWeb.Endpoint 24 | # Start a worker by calling: Videoroom.Worker.start_link(arg) 25 | # {Videoroom.Worker, arg} 26 | ] 27 | 28 | # See https://hexdocs.pm/elixir/Supervisor.html 29 | # for other strategies and supported options 30 | opts = [strategy: :one_for_one, name: Videoroom.Supervisor] 31 | Supervisor.start_link(children, opts) 32 | end 33 | 34 | # Tell Phoenix to update the endpoint configuration 35 | # whenever the application is updated. 36 | @impl true 37 | def config_change(changed, _new, removed) do 38 | VideoroomWeb.Endpoint.config_change(changed, removed) 39 | :ok 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /assets/src/pages/room/components/StreamPlayer/GenericMediaPlayerTile.tsx: -------------------------------------------------------------------------------- 1 | import { ForwardedRef, forwardRef } from "react"; 2 | import MediaPlayer from "./MediaPlayer"; 3 | import clsx from "clsx"; 4 | 5 | export interface Props { 6 | video?: MediaStream | null; 7 | audio?: MediaStream | null; 8 | flipHorizontally?: boolean; 9 | layers?: JSX.Element; 10 | className?: string; 11 | blockFillContent?: boolean; 12 | } 13 | 14 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 15 | const GenericMediaPlayerTile = forwardRef( 16 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 17 | ({ video, audio, flipHorizontally, layers, className, blockFillContent }: Props, ref: ForwardedRef) => { 18 | return ( 19 |
28 | 34 | {layers} 35 |
36 | ); 37 | } 38 | ); 39 | 40 | export default GenericMediaPlayerTile; 41 | -------------------------------------------------------------------------------- /assets/src/features/shared/components/Select.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentProps, FC } from "react"; 2 | import ReactSelect from "react-select"; 3 | import clsx from "clsx"; 4 | 5 | export type SelectProps = Omit, "onChange"> & { 6 | onChange?: (value: SelectOption) => void; 7 | controlClassName?: string; 8 | }; 9 | 10 | export type SelectOption = { 11 | value: string; 12 | label: string; 13 | }; 14 | 15 | export const Select: FC = ({ onChange, controlClassName, ...otherProps }) => { 16 | return ( 17 | { 22 | return { 23 | ...base, 24 | top: "calc(100% + 11px)", 25 | }; 26 | }, 27 | }} 28 | classNames={{ 29 | control: (state) => clsx(controlClassName, state.isFocused && "border-brand-sea-blue-400"), 30 | option: (state) => 31 | clsx( 32 | "px-4 py-3.5 hover:bg-brand-dark-blue-100 focus-within:bg-brand-dark-blue-100", 33 | state.isFocused && "bg-brand-dark-blue-100" 34 | ), 35 | menu: () => 36 | "max-h-40 rounded-lg border-brand-dark-blue-200 border-2 overflow-y-auto bg-brand-white flex flex-col", 37 | }} 38 | onChange={(v) => onChange?.(v as SelectOption)} 39 | {...otherProps} 40 | /> 41 | ); 42 | }; 43 | -------------------------------------------------------------------------------- /assets/src/pages/room/components/StreamPlayer/LocalMediaPlayerTile.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentProps, FC } from "react"; 2 | import { SimulcastEncodingToSend } from "./simulcast/SimulcastEncodingToSend"; 3 | import { UseSimulcastLocalEncoding, useSimulcastSend } from "../../hooks/useSimulcastSend"; 4 | import GenericMediaPlayerTile from "./GenericMediaPlayerTile"; 5 | import { useDeveloperInfo } from "../../../../contexts/DeveloperInfoContext.tsx"; 6 | 7 | export type Props = { 8 | showSimulcast: boolean; 9 | } & ComponentProps; 10 | 11 | const LocalMediaPlayerTile: FC = 12 | ({ 13 | video, 14 | audio, 15 | flipHorizontally, 16 | showSimulcast, 17 | layers, 18 | className, 19 | blockFillContent 20 | }: Props) => { 21 | const localEncoding: UseSimulcastLocalEncoding = useSimulcastSend(); 22 | const { simulcast } = useDeveloperInfo(); 23 | 24 | return ( 25 | 34 | {layers} 35 | {showSimulcast && } 36 | 37 | } 38 | /> 39 | ); 40 | }; 41 | 42 | export default LocalMediaPlayerTile; 43 | -------------------------------------------------------------------------------- /assets/src/features/room-page/components/Sidebar.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import { FC } from "react"; 3 | import Button from "../../shared/components/Button"; 4 | import ChevronDown from "../icons/ChevronDown"; 5 | import PeopleComponent from "./PeopleComponent"; 6 | import { useSelector } from "../../../fishjam"; 7 | 8 | type SidebarProps = { 9 | onClose?: () => void; 10 | }; 11 | 12 | const Sidebar: FC = ({ onClose }) => { 13 | const peoples = useSelector((s) => Object.values(s.remote || {}).length + 1); 14 | 15 | return ( 16 |
24 | {/* close button should be replaced with swipe down feature in the future */} 25 | 28 |
29 | {`People (${peoples})`} 30 |
31 | 32 |
33 | 34 |
35 |
36 | ); 37 | }; 38 | 39 | export default Sidebar; 40 | -------------------------------------------------------------------------------- /assets/src/features/home-page/components/LeavingRoomScreen.tsx: -------------------------------------------------------------------------------- 1 | import { FC, SyntheticEvent } from "react"; 2 | import Button from "../../shared/components/Button"; 3 | import HomePageLayout from "./HomePageLayout"; 4 | import { useNavigate } from "react-router-dom"; 5 | 6 | interface Props { 7 | roomId: string; 8 | } 9 | 10 | const LeavingRoomScreen: FC = ({ roomId }) => { 11 | const navigate = useNavigate(); 12 | 13 | const rejoinHref = `/room/${roomId}`; 14 | 15 | const onRejoin = (e: SyntheticEvent) => { 16 | e.preventDefault(); 17 | navigate(rejoinHref); 18 | } 19 | 20 | return ( 21 | 22 |
23 |
24 |

You've left the meeting.

25 |

What would you like to do next?

26 |
27 | 28 |
29 | 32 | 35 |
36 |
37 |
38 | ); 39 | }; 40 | 41 | export default LeavingRoomScreen; 42 | -------------------------------------------------------------------------------- /assets/src/features/home-page/components/HomePageLayout.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import { FC, PropsWithChildren } from "react"; 3 | import BlockingScreen from "../../shared/components/BlockingScreen"; 4 | 5 | import Navbar from "./Navbar"; 6 | import useSmartphoneViewport from "../../shared/hooks/useSmartphoneViewport"; 7 | import { FISHJAM_VERSION, FISHJAM_ROOM_VERSION } from "../../../pages/room/consts"; 8 | 9 | const HomePageLayout: FC = ({ children }) => { 10 | const { isSmartphone, isHorizontal } = useSmartphoneViewport(); 11 | const shouldBlockScreen = isSmartphone && isHorizontal; 12 | 13 | return ( 14 | <> 15 | {shouldBlockScreen && } 16 |
25 |
26 | 27 |
28 | {`${FISHJAM_ROOM_VERSION} (fishjam ${FISHJAM_VERSION})`} 29 |
30 |
31 | 32 |
{children}
33 |
34 | 35 | ); 36 | }; 37 | 38 | export default HomePageLayout; 39 | -------------------------------------------------------------------------------- /assets/src/features/shared/components/Page404.tsx: -------------------------------------------------------------------------------- 1 | import Navbar from "../../home-page/components/Navbar"; 2 | import BinocularsIcon from "../icons/BinocularsIcon"; 3 | import BackgroundRight from "./BackgroundRight"; 4 | import Button from "./Button"; 5 | import { FC } from "react"; 6 | 7 | const Page404: FC = () => { 8 | return ( 9 |
10 |
11 | 12 | 13 |
14 | 15 |
16 | 17 |
18 | 19 |
24 | 25 | 26 |
27 |
404 — Page not found
28 |
Ooops! This page does not exist.
29 |
30 | 31 | 34 |
35 |
36 | ); 37 | }; 38 | 39 | export default Page404; 40 | -------------------------------------------------------------------------------- /assets/src/pages/room/hooks/useSimulcastSend.tsx: -------------------------------------------------------------------------------- 1 | import { useToggle } from "./useToggle"; 2 | import { TrackEncoding } from "@fishjam-dev/react-client"; 3 | import { useClient, useCurrentUserVideoTrackId } from "../../../fishjam"; 4 | 5 | export type UseSimulcastLocalEncoding = { 6 | highQuality: boolean; 7 | toggleHighQuality: () => void; 8 | mediumQuality: boolean; 9 | toggleMediumQuality: () => void; 10 | lowQuality: boolean; 11 | toggleLowQuality: () => void; 12 | }; 13 | 14 | export const useSimulcastSend = (): UseSimulcastLocalEncoding => { 15 | const client = useClient() 16 | const trackId = useCurrentUserVideoTrackId(); 17 | 18 | const toggleRemoteEncoding = (status: boolean, encodingName: TrackEncoding) => { 19 | if (!trackId) { 20 | throw Error("Toggling simulcast layer is not possible when trackId is null"); 21 | } 22 | 23 | status ? client?.enableTrackEncoding(trackId, encodingName) : client?.disableTrackEncoding(trackId, encodingName); 24 | }; 25 | 26 | const [highQuality, toggleHighQuality] = useToggle(true, (encoding) => { 27 | toggleRemoteEncoding(encoding, "h"); 28 | }); 29 | const [mediumQuality, toggleMediumQuality] = useToggle(true, (encoding) => { 30 | toggleRemoteEncoding(encoding, "m"); 31 | }); 32 | const [lowQuality, toggleLowQuality] = useToggle(true, (encoding) => { 33 | toggleRemoteEncoding(encoding, "l"); 34 | }); 35 | 36 | return { 37 | highQuality, 38 | toggleHighQuality, 39 | mediumQuality, 40 | toggleMediumQuality, 41 | lowQuality, 42 | toggleLowQuality, 43 | }; 44 | }; 45 | -------------------------------------------------------------------------------- /assets/public/shaders/blur/fragment.glsl: -------------------------------------------------------------------------------- 1 | precision mediump float; 2 | varying vec2 v_pos; 3 | 4 | uniform sampler2D texture; 5 | uniform sampler2D resizedTexture; 6 | uniform sampler2D confidenceTexture; 7 | uniform vec2 u_size; 8 | 9 | const float gamma=1.8; 10 | 11 | const int CONV_SIDE=5; 12 | const float BLUR_RADIUS=float(CONV_SIDE); 13 | 14 | vec4 blur(sampler2D texture, sampler2D confidence, vec2 size, vec2 un) { 15 | float n = 0.0; 16 | vec3 res = vec3(0); 17 | vec2 delta = 1.0 /size; 18 | vec2 confidence_delta = 1.0 / size * 2.; 19 | 20 | for(int x = -CONV_SIDE; x <= CONV_SIDE; x++){ 21 | for(int y = -CONV_SIDE; y <= CONV_SIDE; y++){ 22 | vec2 u_ = un + delta * vec2(x,y); 23 | vec2 cu_=un + confidence_delta * vec2(x,y); 24 | float c = 1.0 - texture2D(confidence, cu_).x; 25 | 26 | if (distance(u_, un) <= BLUR_RADIUS) { 27 | res += texture2D(texture, u_).rgb * c; 28 | n += c; 29 | } 30 | } 31 | } 32 | 33 | if (n > 1.0) { 34 | res /= n; 35 | } 36 | 37 | return vec4(res, 1.0); 38 | } 39 | 40 | void main() { 41 | vec4 blurredValue = blur(resizedTexture, confidenceTexture, vec2(1280, 720) / 4., v_pos); 42 | vec4 sharpValue = texture2D(texture, v_pos); 43 | 44 | vec4 filtered_confidence = smoothstep(0.1, 0.8, texture2D(confidenceTexture, v_pos)); 45 | 46 | if (filtered_confidence.x < 0.5) { 47 | gl_FragColor = blurredValue; 48 | } else { 49 | gl_FragColor = mix(blurredValue, sharpValue, filtered_confidence.x); 50 | } 51 | } 52 | 53 | 54 | -------------------------------------------------------------------------------- /assets/src/features/shared/components/BackgroundRight.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import { FC, SVGAttributes } from "react"; 3 | 4 | const BackgroundRight: FC> = (props) => { 5 | const { className, ...otherProps } = props; 6 | 7 | return ( 8 | 17 | 18 | 25 | 30 | 31 | 38 | 39 | 40 | ); 41 | }; 42 | 43 | export default BackgroundRight; 44 | -------------------------------------------------------------------------------- /assets/src/Routes.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import RoomPage from "./pages/room/RoomPage"; 3 | import { Outlet, createBrowserRouter, useLocation, useParams } from "react-router-dom"; 4 | import { useUser } from "./contexts/UserContext"; 5 | import VideoroomHomePage from "./features/home-page/components/VideoroomHomePage"; 6 | import LeavingRoomScreen from "./features/home-page/components/LeavingRoomScreen"; 7 | import Page404 from "./features/shared/components/Page404"; 8 | import { MediaSettingsModal } from "./features/devices/MediaSettingsModal"; 9 | 10 | function PageWrapper() { 11 | return ( 12 | <> 13 | 14 | 15 | 16 | ); 17 | } 18 | 19 | const RoomPageWrapper: React.FC = () => { 20 | const match = useParams(); 21 | const roomId: string | undefined = match?.roomId; 22 | const { state } = useLocation(); 23 | const isLeavingRoom = !!state?.isLeavingRoom; 24 | 25 | const { username } = useUser(); 26 | 27 | if (isLeavingRoom && roomId) { 28 | return (); 29 | } 30 | 31 | return username && roomId ? () : (); 32 | }; 33 | 34 | export const router = createBrowserRouter([ 35 | { 36 | path: "/", 37 | element: , 38 | children: [ 39 | { 40 | path: "", 41 | element: 42 | }, 43 | 44 | { 45 | path: "/room/:roomId", 46 | element: 47 | }, 48 | { 49 | path: "*", 50 | element: 51 | } 52 | ] 53 | } 54 | ]); 55 | -------------------------------------------------------------------------------- /lib/videoroom_web/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule VideoroomWeb.Endpoint do 2 | use Phoenix.Endpoint, otp_app: :videoroom 3 | 4 | # The session will be stored in the cookie and signed, 5 | # this means its contents can be read but not tampered with. 6 | # Set :encryption_salt if you would also like to encrypt it. 7 | @session_options [ 8 | store: :cookie, 9 | key: "_videoroom_key", 10 | signing_salt: "h7Xl8mm4", 11 | same_site: "Lax" 12 | ] 13 | 14 | socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]] 15 | 16 | # Serve at "/" the static files from "priv/static" directory. 17 | # 18 | # You should set gzip to true if you are running phx.digest 19 | # when deploying your static files in production. 20 | plug Plug.Static, 21 | at: "/", 22 | from: :videoroom, 23 | gzip: false, 24 | only: VideoroomWeb.static_paths() 25 | 26 | # Code reloading can be explicitly enabled under the 27 | # :code_reloader configuration of your endpoint. 28 | if code_reloading? do 29 | plug Phoenix.CodeReloader 30 | end 31 | 32 | plug Phoenix.LiveDashboard.RequestLogger, 33 | param_key: "request_logger", 34 | cookie_key: "request_logger" 35 | 36 | plug Plug.RequestId 37 | plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] 38 | 39 | plug Plug.Parsers, 40 | parsers: [:urlencoded, :multipart, :json], 41 | pass: ["*/*"], 42 | json_decoder: Phoenix.json_library() 43 | 44 | plug Plug.MethodOverride 45 | plug Plug.Head 46 | plug Plug.Session, @session_options 47 | plug CORSPlug, origin: "*" 48 | plug VideoroomWeb.Router 49 | end 50 | -------------------------------------------------------------------------------- /assets/src/features/room-page/components/PeopleComponent.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import { FC } from "react"; 3 | import { computeInitials } from "./InitialsImage"; 4 | import { useSelector } from "../../../fishjam"; 5 | 6 | type PeopleListItem = { 7 | peerId: string; 8 | displayName: string; 9 | initials: string; 10 | }; 11 | 12 | const PeopleComponent: FC = () => { 13 | const localUser: PeopleListItem = useSelector((state) => ({ 14 | peerId: state?.local?.id || "Unknown", 15 | displayName: `${state?.local?.metadata?.name} (You)` || "", 16 | initials: computeInitials(state?.local?.metadata?.name || ""), 17 | })); 18 | 19 | const remoteUsers: PeopleListItem[] = useSelector((state) => 20 | Object.values(state.remote || {}).map((peer) => ({ 21 | peerId: peer.id ?? "Unknown", 22 | displayName: peer.metadata?.name || "", 23 | initials: computeInitials(peer?.metadata?.name || ""), 24 | })) 25 | ); 26 | 27 | // const remoteUsers: PeopleListItem[] = []; 28 | 29 | const allPeers: PeopleListItem[] = [localUser, ...remoteUsers]; 30 | 31 | return ( 32 |
33 | {allPeers.map((peer) => ( 34 |
35 |
36 | {peer.initials} 37 |
38 |
{peer.displayName}
39 |
40 | ))} 41 |
42 | ); 43 | }; 44 | export default PeopleComponent; 45 | -------------------------------------------------------------------------------- /assets/src/pages/room/components/StreamPlayer/simulcast/SimulcastEncodingToSend.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { UseSimulcastLocalEncoding } from "../../../hooks/useSimulcastSend"; 3 | import { LayerButton } from "./LayerButton"; 4 | 5 | type Props = { 6 | localEncoding: UseSimulcastLocalEncoding; 7 | disabled?: boolean; 8 | }; 9 | 10 | export const SimulcastEncodingToSend: FC = ({ localEncoding, disabled }: Props) => { 11 | const { highQuality, toggleHighQuality, mediumQuality, toggleMediumQuality, lowQuality, toggleLowQuality } = 12 | localEncoding; 13 | 14 | return ( 15 |
16 |
Encodings to send
17 | toggleHighQuality()} 22 | tooltipText={highQuality ? "Disable High" : "Enable High"} 23 | tooltipCss="right-10" 24 | /> 25 | toggleMediumQuality()} 30 | tooltipText={mediumQuality ? "Disable Medium" : "Enable Medium"} 31 | tooltipCss="right-10" 32 | /> 33 | toggleLowQuality()} 38 | tooltipText={lowQuality ? "Disable Low" : "Enable Low"} 39 | tooltipCss="right-10" 40 | /> 41 |
42 | ); 43 | }; 44 | -------------------------------------------------------------------------------- /assets/src/features/shared/components/PlainLink.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import { FC, ReactNode, SyntheticEvent, useCallback } from "react"; 3 | import { Link } from "react-router-dom"; 4 | import noop from "../utils/noop"; 5 | 6 | export type PlainLinkProps = { 7 | children?: ReactNode; 8 | href?: string; 9 | onClick?: (e: SyntheticEvent) => void | boolean | Promise; 10 | disabled?: boolean; 11 | className?: string; 12 | name?: string; 13 | button?: boolean; 14 | reload?: boolean; 15 | }; 16 | const PlainLink: FC = ({ href, className, name, children, onClick, disabled, reload }) => { 17 | const onClickInner = useCallback( 18 | (e: SyntheticEvent) => (disabled ? noop() : onClick?.(e)), 19 | [disabled, onClick] 20 | ); 21 | 22 | const hrefInner = disabled || !href ? "" : href; 23 | 24 | // only onClick 25 | if (onClick && !href) { 26 | return ( 27 | 30 | ); 31 | } 32 | 33 | // external links 34 | if (href?.startsWith("http://") || href?.startsWith("https://")) { 35 | return ( 36 | 44 | {children} 45 | 46 | ); 47 | } 48 | 49 | // internal links 50 | return ( 51 | 52 | {children} 53 | 54 | ); 55 | }; 56 | 57 | export default PlainLink; 58 | -------------------------------------------------------------------------------- /config/runtime.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | if System.get_env("BE_PHX_SERVER") do 4 | config :videoroom, VideoroomWeb.Endpoint, server: true 5 | end 6 | 7 | if config_env() == :test do 8 | # FIXME it seems that divo tries to do docker cleanup 9 | # before RoomService exits, which results in a bunch of error 10 | # logs at the end of tests - RoomService is linked to the 11 | # Notifier and Notifier to the WS connection to the JF 12 | Divo.Suite.start(services: [:fishjam], auto_start: false) 13 | end 14 | 15 | if config_env() == :prod do 16 | secret_key_base = 17 | System.get_env("BE_SECRET_KEY_BASE") || Base.encode64(:crypto.strong_rand_bytes(48)) 18 | 19 | host = System.get_env("BE_HOST") || "example.com" 20 | port = String.to_integer(System.get_env("BE_PORT") || "5004") 21 | 22 | config :videoroom, VideoroomWeb.Endpoint, 23 | url: [host: host, port: 443, scheme: "https"], 24 | http: [ 25 | ip: {0, 0, 0, 0, 0, 0, 0, 0}, 26 | port: port 27 | ], 28 | secret_key_base: secret_key_base 29 | 30 | secure_connection? = System.get_env("BE_JF_SECURE_CONNECTION", "false") == "true" 31 | 32 | config :fishjam_server_sdk, 33 | secure?: secure_connection?, 34 | server_api_token: 35 | System.get_env("BE_JF_SERVER_API_TOKEN") || 36 | raise(""" 37 | Environment variable BE_JF_SERVER_API_TOKEN is missing. 38 | """) 39 | 40 | config :videoroom, 41 | fishjam_address: 42 | System.get_env("BE_JF_ADDRESS") || raise("Environment variable BE_JF_ADDRESS is missing."), 43 | peer_disconnected_timeout: 44 | String.to_integer(System.get_env("PEER_DISCONNECTED_TIMEOUT") || "120"), 45 | peerless_purge_timeout: String.to_integer(System.get_env("PEERLESS_PURGE_TIMEOUT") || "60") 46 | end 47 | -------------------------------------------------------------------------------- /lib/videoroom_web.ex: -------------------------------------------------------------------------------- 1 | defmodule VideoroomWeb do 2 | @moduledoc """ 3 | The entrypoint for defining your web interface, such 4 | as controllers, components, channels, and so on. 5 | 6 | This can be used in your application as: 7 | 8 | use VideoroomWeb, :controller 9 | use VideoroomWeb, :html 10 | 11 | The definitions below will be executed for every controller, 12 | component, etc, so keep them short and clean, focused 13 | on imports, uses and aliases. 14 | 15 | Do NOT define functions inside the quoted expressions 16 | below. Instead, define additional modules and import 17 | those modules here. 18 | """ 19 | 20 | # credo:disable-for-this-file 21 | 22 | def static_paths, do: ~w(assets fonts images favicon.ico robots.txt) 23 | 24 | def router do 25 | quote do 26 | use Phoenix.Router, helpers: false 27 | 28 | # Import common connection and controller functions to use in pipelines 29 | import Plug.Conn 30 | import Phoenix.Controller 31 | end 32 | end 33 | 34 | def channel do 35 | quote do 36 | use Phoenix.Channel 37 | end 38 | end 39 | 40 | def controller do 41 | quote do 42 | use Phoenix.Controller, 43 | formats: [:json] 44 | 45 | import Plug.Conn 46 | 47 | unquote(verified_routes()) 48 | end 49 | end 50 | 51 | def verified_routes do 52 | quote do 53 | use Phoenix.VerifiedRoutes, 54 | endpoint: VideoroomWeb.Endpoint, 55 | router: VideoroomWeb.Router, 56 | statics: VideoroomWeb.static_paths() 57 | end 58 | end 59 | 60 | @doc """ 61 | When used, dispatch to the appropriate controller/view/etc. 62 | """ 63 | defmacro __using__(which) when is_atom(which) do 64 | apply(__MODULE__, which, []) 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # STEP 1: Use an official Elixir runtime as a parent image 2 | FROM hexpm/elixir:1.14.4-erlang-25.3.2-alpine-3.16.5 AS builder 3 | 4 | # install git 5 | RUN apk add --no-cache git 6 | 7 | # Install hex and rebar 8 | RUN mix local.hex --force && \ 9 | mix local.rebar --force 10 | 11 | # Initialize a new application 12 | RUN mkdir /app 13 | RUN mkdir /dist 14 | 15 | WORKDIR /app 16 | 17 | # Set the MIX environment 18 | ENV MIX_ENV=prod 19 | 20 | # The order of the following commands is important. 21 | # It ensures that: 22 | # * any changes in the `lib` directory will only trigger videoroom compilation 23 | # * any changes in the `config` directory will trigger both videoroom 24 | # and deps compilation but not deps fetching 25 | # * any changes in the `config/runtime.exs` won't trigger anything 26 | COPY mix.exs mix.lock ./ 27 | RUN mix deps.get --only $MIX_ENV 28 | 29 | COPY config/config.exs config/${MIX_ENV}.exs config/ 30 | RUN mix deps.compile 31 | 32 | COPY lib lib 33 | RUN mix compile 34 | 35 | COPY config/runtime.exs config/ 36 | 37 | # Build a release and list the contents of the release directory 38 | RUN mix release --overwrite --path /dist 39 | 40 | 41 | # STEP 2: Use an official Erlang runtime as a parent image for the runtime environment 42 | FROM hexpm/elixir:1.14.4-erlang-25.3.2-alpine-3.16.5 43 | 44 | # Install openssl 45 | RUN apk add --no-cache openssl ncurses-libs 46 | 47 | # Set the PORT 48 | ENV BE_PORT=5004 49 | 50 | # Create an environment variable with the directory where the app is going to be installed 51 | ENV APP_HOME=/opt/app 52 | 53 | # Create the application directory 54 | RUN mkdir "$APP_HOME" 55 | 56 | # Copy over the build artifact from step #1 and set correct permissions 57 | COPY --from=builder /dist "$APP_HOME" 58 | WORKDIR "$APP_HOME" 59 | 60 | # Expose relevant ports for the application 61 | EXPOSE 5004 62 | 63 | # keep the release running 64 | 65 | CMD ["./bin/videoroom", "start"] 66 | -------------------------------------------------------------------------------- /assets/src/features/room-page/icons/MicrophoneOff.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import { FC, SVGAttributes } from "react"; 3 | 4 | const MicrophoneOff: FC> = (props) => { 5 | return ( 6 | 15 | 20 | 21 | ); 22 | }; 23 | 24 | export default MicrophoneOff; 25 | -------------------------------------------------------------------------------- /assets/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { RouterProvider } from "react-router-dom"; 3 | import { DeveloperInfoProvider } from "./contexts/DeveloperInfoContext"; 4 | import { router } from "./Routes"; 5 | import { UserProvider } from "./contexts/UserContext"; 6 | import { ToastProvider } from "./features/shared/context/ToastContext"; 7 | import { ModalProvider } from "./contexts/ModalContext"; 8 | import { LocalMediaMessagesBoundary } from "./features/devices/LocalMediaMessagesBoundary"; 9 | import { LocalPeerMediaProvider } from "./features/devices/LocalPeerMediaContext"; 10 | import { disableSafariCache } from "./features/devices/disableSafariCache"; 11 | import ReactModal from "react-modal"; 12 | import "./index.css"; 13 | 14 | import { StreamingErrorBoundary } from "./features/streaming/StreamingErrorBoundary"; 15 | import { FishjamContextProvider } from "./fishjam"; 16 | 17 | // When returning to the videoroom page from another domain using the 'Back' button on the Safari browser, 18 | // the page is served from the cache, which prevents lifecycle events from being triggered. 19 | // As a result, the camera and microphone do not start. To resolve this issue, one simple solution is to disable the cache. 20 | disableSafariCache(); 21 | ReactModal.setAppElement("#root"); 22 | 23 | const App: FC = () => { 24 | return ( 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | ); 43 | }; 44 | 45 | export default App; 46 | -------------------------------------------------------------------------------- /assets/src/features/shared/context/ToastContext.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, ReactNode, useCallback, useMemo, useState } from "react"; 2 | import Toast from "../components/Toast"; 3 | 4 | const DEFAULT_TOAST_TIMEOUT = 2500; // milliseconds 5 | 6 | export type ToastType = { 7 | id: string; 8 | message?: string; 9 | timeout?: number | "INFINITY"; // milliseconds 10 | type?: "information" | "error" 11 | }; 12 | 13 | export const ToastContext = createContext({ 14 | addToast: (newToast: ToastType) => console.error(`Unknown error while adding toast: ${newToast}`), 15 | removeToast: (toastId: string) => console.error(`Unknown error while removing toast: ${toastId}`) 16 | }); 17 | 18 | export const ToastProvider = ({ children }: { children?: ReactNode }) => { 19 | const [toasts, setToasts] = useState([]); 20 | 21 | const addToast = useCallback( 22 | (newToast: ToastType) => { 23 | const toastExists = toasts.find((el) => el.id == newToast.id); 24 | 25 | if (toastExists) return; 26 | 27 | setToasts((prev) => [...prev, newToast]); 28 | if (newToast.timeout === "INFINITY") return; 29 | 30 | setTimeout(() => { 31 | removeToast(newToast.id); 32 | }, newToast.timeout || DEFAULT_TOAST_TIMEOUT); 33 | }, 34 | [toasts] 35 | ); 36 | 37 | const removeToast = useCallback((toastId: string) => { 38 | document.getElementById(toastId)?.classList.add("fadeOut"); 39 | setTimeout(() => { 40 | setToasts((prev) => prev.filter((t) => t.id != toastId)); 41 | }, 2000); 42 | }, []); 43 | 44 | const value = useMemo(() => ({ addToast, removeToast }), [removeToast, addToast]); 45 | 46 | return ( 47 | 48 | {children} 49 | 50 |
51 | {toasts.map((toast, idx) => ( 52 | removeToast(toast.id)}> 53 | ))} 54 |
55 |
56 | ); 57 | }; 58 | -------------------------------------------------------------------------------- /assets/src/api/git_push.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # ref: https://help.github.com/articles/adding-an-existing-project-to-github-using-the-command-line/ 3 | # 4 | # Usage example: /bin/sh ./git_push.sh wing328 openapi-petstore-perl "minor update" "gitlab.com" 5 | 6 | git_user_id=$1 7 | git_repo_id=$2 8 | release_note=$3 9 | git_host=$4 10 | 11 | if [ "$git_host" = "" ]; then 12 | git_host="github.com" 13 | echo "[INFO] No command line input provided. Set \$git_host to $git_host" 14 | fi 15 | 16 | if [ "$git_user_id" = "" ]; then 17 | git_user_id="GIT_USER_ID" 18 | echo "[INFO] No command line input provided. Set \$git_user_id to $git_user_id" 19 | fi 20 | 21 | if [ "$git_repo_id" = "" ]; then 22 | git_repo_id="GIT_REPO_ID" 23 | echo "[INFO] No command line input provided. Set \$git_repo_id to $git_repo_id" 24 | fi 25 | 26 | if [ "$release_note" = "" ]; then 27 | release_note="Minor update" 28 | echo "[INFO] No command line input provided. Set \$release_note to $release_note" 29 | fi 30 | 31 | # Initialize the local directory as a Git repository 32 | git init 33 | 34 | # Adds the files in the local repository and stages them for commit. 35 | git add . 36 | 37 | # Commits the tracked changes and prepares them to be pushed to a remote repository. 38 | git commit -m "$release_note" 39 | 40 | # Sets the new remote 41 | git_remote=$(git remote) 42 | if [ "$git_remote" = "" ]; then # git remote not defined 43 | 44 | if [ "$GIT_TOKEN" = "" ]; then 45 | echo "[INFO] \$GIT_TOKEN (environment variable) is not set. Using the git credential in your environment." 46 | git remote add origin https://${git_host}/${git_user_id}/${git_repo_id}.git 47 | else 48 | git remote add origin https://${git_user_id}:"${GIT_TOKEN}"@${git_host}/${git_user_id}/${git_repo_id}.git 49 | fi 50 | 51 | fi 52 | 53 | git pull origin master 54 | 55 | # Pushes (Forces) the changes in the local repository up to the remote repository 56 | echo "Git pushing to https://${git_host}/${git_user_id}/${git_repo_id}.git" 57 | git push origin master 2>&1 | grep -v 'To https' 58 | -------------------------------------------------------------------------------- /assets/src/server-sdk/git_push.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # ref: https://help.github.com/articles/adding-an-existing-project-to-github-using-the-command-line/ 3 | # 4 | # Usage example: /bin/sh ./git_push.sh wing328 openapi-petstore-perl "minor update" "gitlab.com" 5 | 6 | git_user_id=$1 7 | git_repo_id=$2 8 | release_note=$3 9 | git_host=$4 10 | 11 | if [ "$git_host" = "" ]; then 12 | git_host="github.com" 13 | echo "[INFO] No command line input provided. Set \$git_host to $git_host" 14 | fi 15 | 16 | if [ "$git_user_id" = "" ]; then 17 | git_user_id="GIT_USER_ID" 18 | echo "[INFO] No command line input provided. Set \$git_user_id to $git_user_id" 19 | fi 20 | 21 | if [ "$git_repo_id" = "" ]; then 22 | git_repo_id="GIT_REPO_ID" 23 | echo "[INFO] No command line input provided. Set \$git_repo_id to $git_repo_id" 24 | fi 25 | 26 | if [ "$release_note" = "" ]; then 27 | release_note="Minor update" 28 | echo "[INFO] No command line input provided. Set \$release_note to $release_note" 29 | fi 30 | 31 | # Initialize the local directory as a Git repository 32 | git init 33 | 34 | # Adds the files in the local repository and stages them for commit. 35 | git add . 36 | 37 | # Commits the tracked changes and prepares them to be pushed to a remote repository. 38 | git commit -m "$release_note" 39 | 40 | # Sets the new remote 41 | git_remote=$(git remote) 42 | if [ "$git_remote" = "" ]; then # git remote not defined 43 | 44 | if [ "$GIT_TOKEN" = "" ]; then 45 | echo "[INFO] \$GIT_TOKEN (environment variable) is not set. Using the git credential in your environment." 46 | git remote add origin https://${git_host}/${git_user_id}/${git_repo_id}.git 47 | else 48 | git remote add origin https://${git_user_id}:"${GIT_TOKEN}"@${git_host}/${git_user_id}/${git_repo_id}.git 49 | fi 50 | 51 | fi 52 | 53 | git pull origin master 54 | 55 | # Pushes (Forces) the changes in the local repository up to the remote repository 56 | echo "Git pushing to https://${git_host}/${git_user_id}/${git_repo_id}.git" 57 | git push origin master 2>&1 | grep -v 'To https' 58 | -------------------------------------------------------------------------------- /assets/src/api/base.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /* eslint-disable */ 3 | /** 4 | * Videoroom 5 | * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) 6 | * 7 | * The version of the OpenAPI document: 0.1.0 8 | * 9 | * 10 | * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). 11 | * https://openapi-generator.tech 12 | * Do not edit the class manually. 13 | */ 14 | 15 | 16 | import type { Configuration } from './configuration'; 17 | // Some imports not used depending on template conditions 18 | // @ts-ignore 19 | import type { AxiosPromise, AxiosInstance, RawAxiosRequestConfig } from 'axios'; 20 | import globalAxios from 'axios'; 21 | 22 | export const BASE_PATH = "http://localhost:5004".replace(/\/+$/, ""); 23 | 24 | /** 25 | * 26 | * @export 27 | */ 28 | export const COLLECTION_FORMATS = { 29 | csv: ",", 30 | ssv: " ", 31 | tsv: "\t", 32 | pipes: "|", 33 | }; 34 | 35 | /** 36 | * 37 | * @export 38 | * @interface RequestArgs 39 | */ 40 | export interface RequestArgs { 41 | url: string; 42 | options: RawAxiosRequestConfig; 43 | } 44 | 45 | /** 46 | * 47 | * @export 48 | * @class BaseAPI 49 | */ 50 | export class BaseAPI { 51 | protected configuration: Configuration | undefined; 52 | 53 | constructor(configuration?: Configuration, protected basePath: string = BASE_PATH, protected axios: AxiosInstance = globalAxios) { 54 | if (configuration) { 55 | this.configuration = configuration; 56 | this.basePath = configuration.basePath ?? basePath; 57 | } 58 | } 59 | }; 60 | 61 | /** 62 | * 63 | * @export 64 | * @class RequiredError 65 | * @extends {Error} 66 | */ 67 | export class RequiredError extends Error { 68 | constructor(public field: string, msg?: string) { 69 | super(msg); 70 | this.name = "RequiredError" 71 | } 72 | } 73 | 74 | interface ServerMap { 75 | [key: string]: { 76 | url: string, 77 | description: string, 78 | }[]; 79 | } 80 | 81 | /** 82 | * 83 | * @export 84 | */ 85 | export const operationServerMap: ServerMap = { 86 | } 87 | -------------------------------------------------------------------------------- /assets/src/server-sdk/base.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /* eslint-disable */ 3 | /** 4 | * Videoroom 5 | * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) 6 | * 7 | * The version of the OpenAPI document: 0.1.0 8 | * 9 | * 10 | * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). 11 | * https://openapi-generator.tech 12 | * Do not edit the class manually. 13 | */ 14 | 15 | 16 | import type { Configuration } from './configuration'; 17 | // Some imports not used depending on template conditions 18 | // @ts-ignore 19 | import type { AxiosPromise, AxiosInstance, RawAxiosRequestConfig } from 'axios'; 20 | import globalAxios from 'axios'; 21 | 22 | export const BASE_PATH = "http://localhost:5004".replace(/\/+$/, ""); 23 | 24 | /** 25 | * 26 | * @export 27 | */ 28 | export const COLLECTION_FORMATS = { 29 | csv: ",", 30 | ssv: " ", 31 | tsv: "\t", 32 | pipes: "|", 33 | }; 34 | 35 | /** 36 | * 37 | * @export 38 | * @interface RequestArgs 39 | */ 40 | export interface RequestArgs { 41 | url: string; 42 | options: RawAxiosRequestConfig; 43 | } 44 | 45 | /** 46 | * 47 | * @export 48 | * @class BaseAPI 49 | */ 50 | export class BaseAPI { 51 | protected configuration: Configuration | undefined; 52 | 53 | constructor(configuration?: Configuration, protected basePath: string = BASE_PATH, protected axios: AxiosInstance = globalAxios) { 54 | if (configuration) { 55 | this.configuration = configuration; 56 | this.basePath = configuration.basePath ?? basePath; 57 | } 58 | } 59 | }; 60 | 61 | /** 62 | * 63 | * @export 64 | * @class RequiredError 65 | * @extends {Error} 66 | */ 67 | export class RequiredError extends Error { 68 | constructor(public field: string, msg?: string) { 69 | super(msg); 70 | this.name = "RequiredError" 71 | } 72 | } 73 | 74 | interface ServerMap { 75 | [key: string]: { 76 | url: string, 77 | description: string, 78 | }[]; 79 | } 80 | 81 | /** 82 | * 83 | * @export 84 | */ 85 | export const operationServerMap: ServerMap = { 86 | } 87 | -------------------------------------------------------------------------------- /assets/src/features/room-page/components/PinComponents.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useCallback, useEffect, useRef, useState } from "react"; 2 | import clsx from "clsx"; 3 | import Pin from "../icons/Pin"; 4 | import Button from "../../shared/components/Button"; 5 | 6 | type PinUserButtonProps = { 7 | pinned: boolean; 8 | onClick: () => void; 9 | }; 10 | 11 | export const PinTileLayer: FC = ({ pinned, onClick }: PinUserButtonProps) => { 12 | const pinText = pinned ? "Unpin" : "Pin"; 13 | const [showLayer, setShowLayer] = useState(false); 14 | const timeRef = useRef(null); 15 | 16 | const restartTimer = useCallback(() => { 17 | const hideAfterMs = 3_000; 18 | 19 | if (timeRef.current) { 20 | clearTimeout(timeRef.current); 21 | } 22 | 23 | setShowLayer(true); 24 | timeRef.current = setTimeout(() => { 25 | setShowLayer(false); 26 | }, hideAfterMs); 27 | }, []); 28 | 29 | useEffect(() => { 30 | return () => { 31 | if (timeRef.current) clearTimeout(timeRef.current); 32 | }; 33 | }, []); 34 | 35 | return ( 36 |
37 | {showLayer && ( 38 | 54 | )} 55 |
56 | ); 57 | }; 58 | 59 | export const PinIndicator: FC = () => { 60 | return ( 61 |
62 | 63 |
64 | ); 65 | }; 66 | -------------------------------------------------------------------------------- /.github/workflows/staging_build_and_deploy.yml: -------------------------------------------------------------------------------- 1 | name: Staging Build and Deploy 2 | on: 3 | push: 4 | branches: 5 | - "main" 6 | 7 | env: 8 | DOMAIN: room.fishjam.work 9 | BE_HOST: server.room.fishjam.work 10 | PROJECT: staging 11 | COMPOSE_FILE_NAME: docker-compose-deploy.yaml 12 | 13 | jobs: 14 | deploy1: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v2 18 | 19 | - name: Set versions 20 | id: versions 21 | run: | 22 | cat << EOF >> $GITHUB_OUTPUT 23 | fishjam_room=${GITHUB_SHA:0:7} 24 | fishjam=$(cat .fishjam-version) 25 | EOF 26 | 27 | - name: Prepare .env file for the deployment 28 | id: variables_population 29 | run: | 30 | echo "DOMAIN=${{env.DOMAIN}} 31 | GF_SECURITY_ADMIN_PASSWORD=${{secrets.GF_SECURITY_ADMIN_PASSWORD}} 32 | GF_SECURITY_ADMIN_USER=${{secrets.GF_SECURITY_ADMIN_USER}} 33 | ALLOY_API_KEY=${{secrets.ALLOY_API_KEY}} 34 | FISHJAM_ROOM_VERSION=${{ steps.versions.outputs.fishjam_room }} 35 | FISHJAM_VERSION=${{ steps.versions.outputs.fishjam }} 36 | JF_SERVER_API_TOKEN=${{secrets.SERVER_API_TOKEN_CLOUD_STAGING}} 37 | BE_HOST=${{env.BE_HOST}} 38 | BE_JF_SECURE_CONNECTION=true 39 | BE_JF_ADDRESS=${{vars.JF_HOST_CLOUD_STAGING}}" > .env 40 | 41 | - name: Remove old containers on first host 42 | uses: JimCronqvist/action-ssh@master 43 | with: 44 | hosts: ${{ secrets.STAGING_HOST }} 45 | privateKey: ${{ secrets.SSH_PRIV_KEY }} 46 | command: | 47 | docker ps -aq | xargs -r docker stop | xargs -r docker rm 48 | 49 | - name: Deploy docker compose to a pre-configured server on first host 50 | uses: TapTap21/docker-remote-deployment-action@v1.1 51 | with: 52 | remote_docker_host: ${{ secrets.STAGING_HOST }} 53 | ssh_private_key: ${{ secrets.SSH_PRIV_KEY }} 54 | ssh_public_key: ${{ secrets.SSH_PUB_KEY }} 55 | stack_file_name: ${{ env.COMPOSE_FILE_NAME }} 56 | args: -p ${{ env.PROJECT }} --env-file .env up -d --remove-orphans --build 57 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # For development, we disable any cache and enable 4 | # debugging and code reloading. 5 | # 6 | # The watchers configuration can be used to run external 7 | # watchers to your application. For example, we use it 8 | # with esbuild to bundle .js and .css sources. 9 | config :videoroom, VideoroomWeb.Endpoint, 10 | # Binding to loopback ipv4 address prevents access from other machines. 11 | # Change to `ip: {0, 0, 0, 0}` to allow access from other machines. 12 | http: [ip: {127, 0, 0, 1}, port: 5004], 13 | check_origin: false, 14 | code_reloader: true, 15 | debug_errors: true, 16 | secret_key_base: "Ovvgkm2prqt9EuqBAuHVGRtJj35AcVVnwSkk3dcTmZYZ9Gfigrg1bOSokrung9aT", 17 | watchers: [] 18 | 19 | config :fishjam_server_sdk, 20 | server_address: "localhost:5002", 21 | server_api_token: System.get_env("BE_JF_SERVER_API_TOKEN", "development") 22 | 23 | # ## SSL Support 24 | # 25 | # In order to use HTTPS in development, a self-signed 26 | # certificate can be generated by running the following 27 | # Mix task: 28 | # 29 | # mix phx.gen.cert 30 | # 31 | # Run `mix help phx.gen.cert` for more information. 32 | # 33 | # The `http:` config above can be replaced with: 34 | # 35 | # https: [ 36 | # port: 4001, 37 | # cipher_suite: :strong, 38 | # keyfile: "priv/cert/selfsigned_key.pem", 39 | # certfile: "priv/cert/selfsigned.pem" 40 | # ], 41 | # 42 | # If desired, both `http:` and `https:` keys can be 43 | # configured to run both http and https servers on 44 | # different ports. 45 | 46 | # Enable dev routes for dashboard and mailbox 47 | config :videoroom, 48 | dev_routes: true, 49 | peer_disconnected_timeout: 60, 50 | peerless_purge_timeout: 120, 51 | fishjam_address: "localhost:5002" 52 | 53 | # Do not include metadata nor timestamps in development logs 54 | config :logger, :console, format: "[$level] $message\n" 55 | 56 | # Set a higher stacktrace during development. Avoid configuring such 57 | # in production as building large stacktraces may be expensive. 58 | config :phoenix, :stacktrace_depth, 20 59 | 60 | # Initialize plugs at runtime for faster development compilation 61 | config :phoenix, :plug_init_mode, :runtime 62 | -------------------------------------------------------------------------------- /docker-compose-deploy.yaml: -------------------------------------------------------------------------------- 1 | version: '2.2' 2 | 3 | services: 4 | frontend: 5 | build: 6 | context: ./assets 7 | dockerfile: ./Dockerfile 8 | args: 9 | ALLOY_API_KEY: ${ALLOY_API_KEY} 10 | FE_BE_HOST: ${BE_HOST:-$DOMAIN:5004} 11 | FISHJAM_ROOM_VERSION: ${FISHJAM_ROOM_VERSION} 12 | FISHJAM_VERSION: ${FISHJAM_VERSION} 13 | container_name: frontend 14 | restart: unless-stopped 15 | depends_on: 16 | - backend 17 | ports: 18 | - "127.0.0.1:5005:5005" 19 | 20 | backend: 21 | build: 22 | context: . 23 | dockerfile: ./Dockerfile 24 | container_name: backend 25 | environment: 26 | BE_PORT: 5004 27 | BE_HOST: ${BE_HOST:-$DOMAIN} 28 | BE_PHX_SERVER: "true" 29 | BE_JF_SECURE_CONNECTION: ${BE_JF_SECURE_CONNECTION:-false} 30 | BE_JF_SERVER_API_TOKEN: ${JF_SERVER_API_TOKEN} 31 | BE_JF_ADDRESS: ${BE_JF_ADDRESS} 32 | restart: unless-stopped 33 | ports: 34 | - "127.0.0.1:5004:5004" 35 | 36 | grafana: 37 | build: 38 | context: ./infra/grafana/ 39 | dockerfile: Dockerfile 40 | container_name: grafana 41 | ports: 42 | - "127.0.0.1:3000:3000" 43 | environment: 44 | GF_SECURITY_ADMIN_PASSWORD: "${GF_SECURITY_ADMIN_PASSWORD}" 45 | GF_SECURITY_ADMIN_USER: "${GF_SECURITY_ADMIN_USER}" 46 | GF_SERVER_ROOT_URL: "http://${DOMAIN}/grafana" 47 | 48 | loki: 49 | build: 50 | context: ./infra/loki/ 51 | dockerfile: Dockerfile 52 | container_name: loki 53 | command: -config.file=/etc/loki/local-config.yaml 54 | volumes: 55 | - loki-data:/loki 56 | 57 | grafana-alloy: 58 | build: 59 | context: ./infra/alloy/ 60 | dockerfile: Dockerfile 61 | container_name: alloy 62 | environment: 63 | - ALLOY_API_KEY=${ALLOY_API_KEY} 64 | - AGENT_PORT_APP_RECEIVER=${AGENT_PORT_APP_RECEIVER:-8027} 65 | entrypoint: 66 | - '/bin/alloy' 67 | - 'run' 68 | - '--server.http.listen-addr=127.0.0.1:12345' 69 | - '--config.extra-args="-config.expand-env"' 70 | - '${ALLOY_CONFIG_PATH:-/etc/alloy}/${ALLOY_CONFIG_FILE:-config.alloy}' 71 | 72 | volumes: 73 | loki-data: 74 | -------------------------------------------------------------------------------- /assets/src/features/devices/LocalMediaMessagesBoundary.tsx: -------------------------------------------------------------------------------- 1 | import { FC, PropsWithChildren, useEffect } from "react"; 2 | import useToast from "../shared/hooks/useToast"; 3 | import useEffectOnChange from "../shared/hooks/useEffectOnChange"; 4 | import { 5 | PeerMetadata, 6 | TrackMetadata, 7 | useCamera, 8 | useClient, 9 | useMicrophone 10 | } from "../../fishjam.ts"; 11 | import { ClientEvents } from "@fishjam-dev/react-client"; 12 | 13 | const prepareErrorMessage = (videoDeviceError: string | null, audioDeviceError: string | null): null | string => { 14 | if (videoDeviceError && audioDeviceError) { 15 | return "Access to camera and microphone is blocked"; 16 | } else if (videoDeviceError) { 17 | return "Access to camera is blocked"; 18 | } else if (audioDeviceError) { 19 | return "Access to microphone is blocked"; 20 | } else return null; 21 | }; 22 | 23 | export const LocalMediaMessagesBoundary: FC = ({ children }) => { 24 | const { addToast } = useToast(); 25 | 26 | // todo change to events 27 | const microphone = useMicrophone(); 28 | const camera = useCamera(); 29 | 30 | useEffectOnChange( 31 | [camera.error, microphone.error], 32 | () => { 33 | const message = prepareErrorMessage(camera.error?.name ?? null, microphone.error?.name ?? null); 34 | 35 | if (message) { 36 | addToast({ 37 | id: "device-not-allowed-error", 38 | message: message, 39 | timeout: "INFINITY", 40 | type: "error" 41 | }); 42 | } 43 | }, 44 | (next, prev) => prev?.[0] === next[0] && prev?.[1] === next[1] 45 | ); 46 | 47 | const client = useClient(); 48 | 49 | useEffect(() => { 50 | const screeShareStarted: ClientEvents["localTrackAdded"] = (event) => { 51 | if (event.trackMetadata?.type === "screensharing") { 52 | addToast({ id: "screen-sharing", message: "You are sharing the screen now", timeout: 4000 }); 53 | } 54 | }; 55 | 56 | client.on("localTrackAdded", screeShareStarted); 57 | 58 | return () => { 59 | client.removeListener("localTrackAdded", screeShareStarted); 60 | }; 61 | }, []); 62 | 63 | return <>{children}; 64 | }; 65 | -------------------------------------------------------------------------------- /assets/src/pages/room/components/StreamPlayer/PinnedTilesSection.tsx: -------------------------------------------------------------------------------- 1 | import { FC, ReactNode } from "react"; 2 | import { MediaPlayerTileConfig } from "../../../types"; 3 | import { TrackEncoding } from "@fishjam-dev/react-client"; 4 | import { PinTileLayer } from "../../../../features/room-page/components/PinComponents"; 5 | import { getGridConfig, GridConfigType } from "../../../../features/room-page/utils/getVideoGridConfig"; 6 | import clsx from "clsx"; 7 | import Tile from "./Tile"; 8 | 9 | type WrapperProps = { 10 | children: ReactNode; 11 | twoPinnedTiles: boolean; 12 | gridConfig: GridConfigType; 13 | }; 14 | 15 | const PinnedTilesWrapper: FC = ({ children, twoPinnedTiles, gridConfig }: WrapperProps) => { 16 | const columnWrapper =
{children}
; 17 | const activeGridWrapper = ( 18 |
19 |
20 | {children} 21 |
22 |
23 | ); 24 | return twoPinnedTiles ? columnWrapper : activeGridWrapper; 25 | }; 26 | 27 | type Props = { 28 | pinnedTiles: MediaPlayerTileConfig[]; 29 | unpin: (tileIdToUnpin: string) => void; 30 | showSimulcast: boolean; 31 | forceEncoding?: TrackEncoding; 32 | }; 33 | 34 | const PinnedTilesSection: FC = ({ pinnedTiles, unpin, showSimulcast, forceEncoding }: Props) => { 35 | const gridConfig = getGridConfig(pinnedTiles.length); 36 | const className = clsx(gridConfig.span, gridConfig.tileClass); 37 | return ( 38 | 39 | {pinnedTiles.map((tile) => ( 40 | unpin(tile.mediaPlayerId)} />} 47 | /> 48 | ))} 49 | 50 | ); 51 | }; 52 | 53 | export default PinnedTilesSection; 54 | -------------------------------------------------------------------------------- /assets/src/features/shared/hooks/useSmartphoneViewport.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { MOBILE_WIDTH_BREAKPOINT, MAX_MOBILE_WIDTH_BREAKPOINT } from "../consts"; 3 | 4 | type ScreenInfo = { 5 | isSmartphone?: boolean; 6 | isHorizontal?: boolean; 7 | }; 8 | 9 | const useSmartphoneViewport = (): ScreenInfo => { 10 | const [isSmartphone, setIsSmartphone] = useState(); 11 | const [isHorizontal, setIsHorizontal] = useState(); 12 | 13 | useEffect(() => { 14 | if (typeof window !== "undefined") { 15 | // The screen.orientation.type is widely available on most of the devices but it was recently added to mobile safari. 16 | // Source: https://developer.mozilla.org/en-US/docs/Web/API/Screen/orientation 17 | // At the day of writing this comment (May 8th 2023) some web apps like Chrome on iOS does NOT implement this. 18 | // The `windows.matchMedia` is used as a fallback for such devices. 19 | const isLandscape = 20 | screen.orientation?.type.includes("landscape") || window.matchMedia("(orientation: landscape)").matches; 21 | 22 | const updateIsSmartphoneState = () => { 23 | if (!window.visualViewport) return; 24 | 25 | const isCoarse = matchMedia("(pointer:coarse)").matches; 26 | const hasMobileWidth = window.visualViewport.width <= MOBILE_WIDTH_BREAKPOINT; 27 | 28 | const isLandscapedSmartphone = isLandscape && window.visualViewport.width <= MAX_MOBILE_WIDTH_BREAKPOINT; // iPhone 12 max viewpor width 29 | setIsSmartphone(isCoarse && (hasMobileWidth || isLandscapedSmartphone)); 30 | }; 31 | 32 | const updateIsHorizontalOrientationState = () => { 33 | if (!screen) return; 34 | 35 | setIsHorizontal(isLandscape); 36 | }; 37 | 38 | const updateState = () => { 39 | updateIsSmartphoneState(); 40 | updateIsHorizontalOrientationState(); 41 | }; 42 | 43 | updateState(); 44 | window.addEventListener("resize", updateState); 45 | return () => window.removeEventListener("resize", updateState); 46 | } 47 | }, []); 48 | 49 | return { isSmartphone, isHorizontal }; 50 | }; 51 | 52 | export default useSmartphoneViewport; 53 | -------------------------------------------------------------------------------- /assets/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fishjam-videoroom", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "license": "Apache-2.0", 7 | "scripts": { 8 | "dev": "vite", 9 | "build": "tsc && vite build", 10 | "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 11 | "preview": "vite preview" 12 | }, 13 | "dependencies": { 14 | "@fishjam-dev/react-client": "https://github.com/fishjam-cloud/web-client-sdk.git#workspace=@fishjam-dev/react-client&head=main", 15 | "@grafana/faro-web-sdk": "^1.8.1", 16 | "@mediapipe/tasks-vision": "^0.10.12", 17 | "axios": "^1.6.8", 18 | "chartist": "^1.3.0", 19 | "clsx": "^2.1.0", 20 | "date-fns": "^3.6.0", 21 | "framer-motion": "^11.0.15", 22 | "lodash.isequal": "^4.5.0", 23 | "query-string": "^9.0.0", 24 | "ramda": "^0.29.1", 25 | "react": "^18.2.0", 26 | "react-dom": "^18.2.0", 27 | "react-modal": "^3.16.1", 28 | "react-page-visibility": "^7.0.0", 29 | "react-resize-detector": "^10.0.1", 30 | "react-router-dom": "^6.22.3", 31 | "react-select": "^5.8.0", 32 | "ts-proto": "^1.169.1", 33 | "typed-emitter": "^2.1.0", 34 | "uuid": "^9.0.1", 35 | "zod": "^3.22.4" 36 | }, 37 | "resolutions": { 38 | "@fishjam-dev/ts-client": "https://github.com/fishjam-cloud/web-client-sdk.git#workspace=@fishjam-dev/ts-client&head=main" 39 | }, 40 | "overrides": { 41 | "@fishjam-dev/ts-client": "https://github.com/fishjam-cloud/web-client-sdk.git#workspace=@fishjam-dev/ts-client&head=main" 42 | }, 43 | "devDependencies": { 44 | "@types/ramda": "^0.29.11", 45 | "@types/react": "^18.2.67", 46 | "@types/react-dom": "^18.2.22", 47 | "@types/react-modal": "^3.16.3", 48 | "@typescript-eslint/eslint-plugin": "^7.3.1", 49 | "@typescript-eslint/parser": "^7.3.1", 50 | "@vitejs/plugin-react": "^4.2.1", 51 | "autoprefixer": "^10.4.18", 52 | "eslint": "^8.57.0", 53 | "eslint-plugin-react-hooks": "^4.6.0", 54 | "eslint-plugin-react-refresh": "^0.4.6", 55 | "postcss": "^8.4.37", 56 | "tailwindcss": "^3.4.1", 57 | "typescript": "^5.4.2", 58 | "vite": "^5.1.6", 59 | "vite-plugin-checker": "^0.6.4" 60 | }, 61 | "packageManager": "yarn@4.3.1" 62 | } 63 | -------------------------------------------------------------------------------- /.github/workflows/sandbox_build_and_deploy.yml: -------------------------------------------------------------------------------- 1 | name: Sandbox Build and Deploy 2 | on: 3 | push: 4 | branches: 5 | - "sandbox" 6 | paths: 7 | - "**" 8 | 9 | env: 10 | DOMAIN: room.fishjam.ovh 11 | BE_HOST: server.room.fishjam.ovh 12 | PROJECT: sandbox 13 | COMPOSE_FILE_NAME: docker-compose-deploy.yaml 14 | 15 | jobs: 16 | deploy1: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v2 20 | 21 | - name: Set versions 22 | id: versions 23 | run: | 24 | cat << EOF >> $GITHUB_OUTPUT 25 | fishjam_room=${GITHUB_SHA:0:7} 26 | fishjam=$(cat .fishjam-version) 27 | EOF 28 | 29 | - name: Prepare .env file for the deployment 30 | id: variables_population 31 | run: | 32 | echo "DOMAIN=${{env.DOMAIN}} 33 | GF_SECURITY_ADMIN_PASSWORD=${{secrets.GF_SECURITY_ADMIN_PASSWORD}} 34 | GF_SECURITY_ADMIN_USER=${{secrets.GF_SECURITY_ADMIN_USER}} 35 | ALLOY_API_KEY=${{secrets.ALLOY_API_KEY}} 36 | ALLOY_HOST=${{env.DOMAIN}} 37 | FISHJAM_ROOM_VERSION=${{ steps.versions.outputs.fishjam_room }} 38 | FISHJAM_VERSION=${{ steps.versions.outputs.fishjam }} 39 | JF_SERVER_API_TOKEN=${{secrets.SERVER_API_TOKEN_CLOUD_SANDBOX}} 40 | BE_HOST=${{env.BE_HOST}} 41 | BE_JF_SECURE_CONNECTION=true 42 | BE_JF_ADDRESS=${{vars.JF_HOST_CLOUD_SANDBOX}}" > .env 43 | 44 | - name: Remove old containers on first host 45 | uses: JimCronqvist/action-ssh@master 46 | with: 47 | hosts: ${{ secrets.SANDBOX_HOST }} 48 | privateKey: ${{ secrets.SSH_PRIV_KEY }} 49 | command: | 50 | docker ps -aq | xargs -r docker stop | xargs -r docker rm 51 | 52 | - name: Deploy docker compose to a pre-configured server on first host 53 | uses: TapTap21/docker-remote-deployment-action@v1.1 54 | with: 55 | remote_docker_host: ${{ secrets.SANDBOX_HOST }} 56 | ssh_private_key: ${{ secrets.SSH_PRIV_KEY }} 57 | ssh_public_key: ${{ secrets.SSH_PUB_KEY }} 58 | stack_file_name: ${{ env.COMPOSE_FILE_NAME }} 59 | args: -p ${{ env.PROJECT }} --env-file .env up -d --remove-orphans --build 60 | -------------------------------------------------------------------------------- /.github/workflows/production_build_and_deploy.yml: -------------------------------------------------------------------------------- 1 | name: Production Build and Deploy 2 | on: 3 | push: 4 | tags: 5 | - 'v*.*.*' 6 | 7 | env: 8 | DOMAIN: room.fishjam.stream 9 | BE_HOST: server.room.fishjam.stream 10 | PROJECT: production 11 | COMPOSE_FILE_NAME: docker-compose-deploy.yaml 12 | 13 | jobs: 14 | deploy1: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v2 18 | 19 | - name: Set versions 20 | id: versions 21 | run: | 22 | cat << EOF >> $GITHUB_OUTPUT 23 | fishjam_room=${GITHUB_REF#refs/*/} 24 | fishjam=$(cat .fishjam-version) 25 | EOF 26 | 27 | - name: Prepare .env file for the deployment 28 | id: variables_population 29 | run: | 30 | echo "DOMAIN=${{env.DOMAIN}} 31 | GF_SECURITY_ADMIN_PASSWORD=${{secrets.GF_SECURITY_ADMIN_PASSWORD}} 32 | GF_SECURITY_ADMIN_USER=${{secrets.GF_SECURITY_ADMIN_USER}} 33 | ALLOY_API_KEY=${{secrets.ALLOY_API_KEY}} 34 | FISHJAM_ROOM_VERSION=${{ steps.versions.outputs.fishjam_room }} 35 | FISHJAM_VERSION=${{ steps.versions.outputs.fishjam }} 36 | JF_SERVER_API_TOKEN=${{secrets.SERVER_API_TOKEN_CLOUD_PRODUCTION}} 37 | BE_HOST=${{env.BE_HOST}} 38 | BE_JF_SECURE_CONNECTION=true 39 | BE_JF_ADDRESS=${{vars.JF_HOST_CLOUD_PRODUCTION}}" > .env 40 | 41 | - name: Remove old containers on first host 42 | uses: JimCronqvist/action-ssh@master 43 | with: 44 | hosts: ${{ secrets.PRODUCTION_IP }}:${{ secrets.SSH_PROD_PORT }} 45 | privateKey: ${{ secrets.SSH_PRIV_KEY }} 46 | command: | 47 | docker ps -aq | xargs -r docker stop | xargs -r docker rm 48 | 49 | - name: Deploy docker compose to a pre-configured server on first host 50 | uses: TapTap21/docker-remote-deployment-action@v1.1 51 | with: 52 | remote_docker_host: ${{ secrets.PRODUCTION_IP }} 53 | ssh_private_key: ${{ secrets.SSH_PRIV_KEY }} 54 | ssh_public_key: ${{ secrets.SSH_PUB_KEY }} 55 | ssh_port: ${{ secrets.SSH_PROD_PORT }} 56 | stack_file_name: ${{ env.COMPOSE_FILE_NAME }} 57 | args: -p ${{ env.PROJECT }} --env-file .env up -d --remove-orphans --build 58 | -------------------------------------------------------------------------------- /assets/src/pages/room/components/StreamPlayer/MediaPlayer.tsx: -------------------------------------------------------------------------------- 1 | import { FC, RefObject, useEffect, useRef, useState } from "react"; 2 | import clsx from "clsx"; 3 | 4 | export interface Props { 5 | videoStream: MediaStream | null; 6 | audioStream: MediaStream | null; 7 | flipHorizontally?: boolean; 8 | blockFillContent?: boolean; 9 | } 10 | 11 | const MediaPlayer: FC = ({ videoStream, audioStream, flipHorizontally, blockFillContent }: Props) => { 12 | const videoRef: RefObject = useRef(null); 13 | const audioRef: RefObject = useRef(null); 14 | 15 | const [fillContent, shouldFillContent] = useState(true); 16 | 17 | useEffect(() => { 18 | if (!videoRef.current) return; 19 | videoRef.current.srcObject = videoStream || null; 20 | }, [videoStream]); 21 | 22 | useEffect(() => { 23 | if (!audioRef.current) return; 24 | audioRef.current.srcObject = audioStream || null; 25 | }, [audioStream]); 26 | 27 | useEffect(() => { 28 | const isVideoHorizontal = (video: HTMLVideoElement): boolean => { 29 | const width = video.videoWidth; 30 | const height = video.videoHeight; 31 | 32 | return width > height; 33 | }; 34 | 35 | const setFillContentState = (videoElement: HTMLVideoElement): void => { 36 | const newValue = !blockFillContent && isVideoHorizontal(videoElement); 37 | shouldFillContent(newValue); 38 | }; 39 | 40 | const video = videoRef.current; 41 | if (video) { 42 | video.onresize = () => { 43 | const videoElement = video; 44 | if (videoElement) setFillContentState(videoElement); 45 | }; 46 | } 47 | 48 | return () => { 49 | if (video) video.onresize = null; 50 | }; 51 | }, [videoStream, fillContent, blockFillContent]); 52 | 53 | return ( 54 | <> 55 |