├── .github └── workflows │ ├── __CD__broadcaster.yml │ ├── __CD__build-publish-image.yml │ ├── __CD__deploy-image.yml │ ├── __CD__nexus.yml │ ├── __CD__recognizer.yml │ ├── __CI__broadcaster.yml │ ├── __CI__build-check-app.yml │ ├── __CI__nexus.yml │ └── __CI__recognizer.yml ├── LICENSE ├── README.md ├── broadcaster ├── .credo.exs ├── .dockerignore ├── .formatter.exs ├── .gitignore ├── Dockerfile ├── README.md ├── assets │ ├── .prettierrc │ ├── css │ │ └── app.css │ ├── eslint.config.mjs │ ├── js │ │ ├── app.js │ │ ├── chat.js │ │ ├── home.js │ │ ├── panel.js │ │ └── whep-client.js │ ├── package-lock.json │ ├── package.json │ ├── tailwind.config.js │ └── vendor │ │ └── topbar.js ├── config │ ├── config.exs │ ├── dev.exs │ ├── prod.exs │ ├── runtime.exs │ └── test.exs ├── docker-compose-dist.yml ├── headless_client.js ├── kubernetes-sample-config.yaml ├── lib │ ├── broadcaster.ex │ ├── broadcaster │ │ ├── application.ex │ │ ├── chat_history.ex │ │ ├── forwarder.ex │ │ └── peer_supervisor.ex │ ├── broadcaster_web.ex │ └── broadcaster_web │ │ ├── channels │ │ ├── channel.ex │ │ ├── presence.ex │ │ └── user_socket.ex │ │ ├── components │ │ ├── core_components.ex │ │ ├── layouts.ex │ │ └── layouts │ │ │ ├── app.html.heex │ │ │ └── root.html.heex │ │ ├── controllers │ │ ├── error_html.ex │ │ ├── error_json.ex │ │ ├── media_controller.ex │ │ ├── page_controller.ex │ │ ├── page_html.ex │ │ └── page_html │ │ │ ├── home.html.heex │ │ │ └── panel.html.heex │ │ ├── endpoint.ex │ │ ├── router.ex │ │ └── telemetry.ex ├── mix.exs ├── mix.lock ├── priv │ └── static │ │ ├── favicon.ico │ │ ├── images │ │ └── logo.svg │ │ └── robots.txt ├── rel │ └── overlays │ │ └── bin │ │ ├── server │ │ └── server.bat └── test │ ├── broadcaster_web │ └── controllers │ │ ├── error_html_test.exs │ │ └── error_json_test.exs │ ├── support │ ├── channel_case.ex │ └── conn_case.ex │ └── test_helper.exs ├── nexus ├── .credo.exs ├── .dockerignore ├── .formatter.exs ├── .gitignore ├── Dockerfile ├── README.md ├── assets │ ├── .prettierrc │ ├── css │ │ └── app.css │ ├── eslint.config.mjs │ ├── js │ │ ├── app.js │ │ └── home.js │ ├── package-lock.json │ ├── package.json │ ├── tailwind.config.js │ └── vendor │ │ └── topbar.js ├── config │ ├── config.exs │ ├── dev.exs │ ├── prod.exs │ ├── runtime.exs │ └── test.exs ├── lib │ ├── nexus.ex │ ├── nexus │ │ ├── application.ex │ │ ├── peer.ex │ │ ├── peer_supervisor.ex │ │ └── room.ex │ ├── nexus_web.ex │ └── nexus_web │ │ ├── channels │ │ ├── peer_channel.ex │ │ ├── presence.ex │ │ └── user_socket.ex │ │ ├── components │ │ ├── core_components.ex │ │ ├── layouts.ex │ │ └── layouts │ │ │ ├── app.html.heex │ │ │ └── root.html.heex │ │ ├── controllers │ │ ├── error_html.ex │ │ ├── error_json.ex │ │ ├── page_controller.ex │ │ ├── page_html.ex │ │ └── page_html │ │ │ └── home.html.heex │ │ ├── endpoint.ex │ │ ├── router.ex │ │ └── telemetry.ex ├── mix.exs ├── mix.lock ├── priv │ └── static │ │ ├── favicon.ico │ │ ├── images │ │ └── logo.svg │ │ └── robots.txt ├── rel │ └── overlays │ │ └── bin │ │ ├── server │ │ └── server.bat └── test │ ├── nexus_web │ └── controllers │ │ ├── error_html_test.exs │ │ └── error_json_test.exs │ ├── support │ ├── channel_case.ex │ └── conn_case.ex │ └── test_helper.exs └── recognizer ├── .credo.exs ├── .dockerignore ├── .formatter.exs ├── .gitignore ├── Dockerfile ├── README.md ├── assets ├── .prettierrc ├── css │ └── app.css ├── eslint.config.mjs ├── js │ ├── app.js │ └── room.js ├── package-lock.json ├── package.json ├── tailwind.config.js └── vendor │ └── topbar.js ├── config ├── config.exs ├── dev.exs ├── prod.exs ├── runtime.exs └── test.exs ├── lib ├── recognizer.ex ├── recognizer │ ├── application.ex │ ├── lobby.ex │ └── room.ex ├── recognizer_web.ex └── recognizer_web │ ├── channels │ └── room_channel.ex │ ├── components │ ├── core_components.ex │ ├── layouts.ex │ └── layouts │ │ ├── app.html.heex │ │ └── root.html.heex │ ├── controllers │ ├── error_html.ex │ ├── error_json.ex │ ├── room_controller.ex │ ├── room_html.ex │ └── room_html │ │ └── room.html.heex │ ├── endpoint.ex │ ├── live │ ├── lobby_live.ex │ └── recognizer_live.ex │ ├── room_socket.ex │ ├── router.ex │ └── telemetry.ex ├── mix.exs ├── mix.lock ├── priv └── static │ ├── favicon.ico │ ├── images │ └── logo.svg │ └── robots.txt ├── rel └── overlays │ └── bin │ ├── server │ └── server.bat └── test ├── recognizer_web └── controllers │ ├── error_html_test.exs │ ├── error_json_test.exs │ └── page_controller_test.exs ├── support └── conn_case.ex └── test_helper.exs /.github/workflows/__CD__broadcaster.yml: -------------------------------------------------------------------------------- 1 | name: Broadcaster CD 2 | 3 | on: 4 | push: 5 | tags: 6 | - "broadcaster-v*.*.*" 7 | 8 | permissions: 9 | contents: read 10 | packages: write 11 | 12 | jobs: 13 | build-publish-broadcaster-image: 14 | name: "Build and publish Broadcaster image" 15 | uses: ./.github/workflows/__CD__build-publish-image.yml 16 | with: 17 | app-name: broadcaster 18 | deploy-broadcaster: 19 | name: "Deploy Broadcaster image" 20 | needs: build-publish-broadcaster-image 21 | runs-on: ubuntu-latest 22 | steps: 23 | - name: Run docker via remote SSH 24 | uses: appleboy/ssh-action@v1.0.3 25 | with: 26 | host: ${{ secrets.BROADCASTER_SSH_HOST }} 27 | username: ${{ secrets.BROADCASTER_SSH_USERNAME }} 28 | key: ${{ secrets.BROADCASTER_SSH_PRIV_KEY }} 29 | script: | 30 | # Exit if any command fails. 31 | set -e 32 | 33 | export APP_NAME=broadcaster 34 | export TAG=${{ github.ref_name }} 35 | export TAG=${TAG#*-v} 36 | 37 | echo "Cleaning previous broadcaster docker image" 38 | docker stop $APP_NAME 39 | docker rm $APP_NAME 40 | 41 | echo "Running a new broadcaster image - $TAG" 42 | docker run -d --restart unless-stopped \ 43 | --name $APP_NAME \ 44 | -e SECRET_KEY_BASE=${{ secrets.BROADCASTER_SECRET_KEY_BASE }} \ 45 | -e PHX_HOST=${{ secrets.BROADCASTER_PHX_HOST }} \ 46 | -e ICE_PORT_RANGE=${{ secrets.BROADCASTER_ICE_PORT_RANGE }} \ 47 | -e ADMIN_USERNAME=${{ secrets.BROADCASTER_ADMIN_USERNAME }} \ 48 | -e ADMIN_PASSWORD=${{ secrets.BROADCASTER_ADMIN_PASSWORD }} \ 49 | -e WHIP_TOKEN=${{ secrets.BROADCASTER_WHIP_TOKEN }} \ 50 | --network host \ 51 | ghcr.io/elixir-webrtc/apps/$APP_NAME:$TAG 52 | 53 | docker image prune --all --force 54 | 55 | echo "Waiting for broadcaster to be ready." 56 | attempts=10 57 | until curl localhost:4000 > /dev/null 2>&1 58 | do 59 | ((attempts--)) 60 | if [ $attempts -eq 0 ]; then 61 | exit 1 62 | fi 63 | sleep 1 64 | done 65 | 66 | echo "Cloning client script" 67 | rm -rf /tmp/apps 68 | git clone -b ${{ github.ref_name }} https://github.com/${{ github.repository }} /tmp/apps 69 | cd /tmp/apps/broadcaster 70 | 71 | echo "Terminating previously running client" 72 | # ignore non-zero exit status 73 | killall node || true 74 | 75 | # This is needed to find `node` and `npm` commands. 76 | # See difference between interactive and non-interactive shells. 77 | source ~/.nvm/nvm.sh 78 | 79 | echo "Installing puppeteer in current directory" 80 | npm install puppeteer 81 | 82 | # Run node in bg, disconnect it from terminal and redirect all output. 83 | # In other case action won't end. 84 | echo "Running client script" 85 | USERNAME=${{ secrets.BROADCASTER_ADMIN_USERNAME }} \ 86 | PASSWORD=${{ secrets.BROADCASTER_ADMIN_PASSWORD }} \ 87 | TOKEN=${{ secrets.BROADCASTER_WHIP_TOKEN }} \ 88 | nohup node headless_client.js > nohup.out 2> nohup.err < /dev/null & 89 | -------------------------------------------------------------------------------- /.github/workflows/__CD__build-publish-image.yml: -------------------------------------------------------------------------------- 1 | on: 2 | workflow_call: 3 | inputs: 4 | app-name: 5 | required: true 6 | type: string 7 | 8 | env: 9 | REGISTRY: ghcr.io 10 | 11 | jobs: 12 | build-publish-image: 13 | name: "Build and publish image" 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout the code 17 | uses: actions/checkout@v4 18 | with: 19 | sparse-checkout: ${{ inputs.app-name }} 20 | 21 | - name: Set up Docker Buildx 22 | uses: docker/setup-buildx-action@v2 23 | 24 | - name: Login to Container Registry 25 | uses: docker/login-action@v2 26 | with: 27 | registry: ${{ env.REGISTRY }} 28 | username: ${{ github.actor }} 29 | password: ${{ secrets.GITHUB_TOKEN }} 30 | 31 | - name: Extract metadata (tags, labels) for Docker 32 | id: meta 33 | uses: docker/metadata-action@v4 34 | with: 35 | images: ${{ env.REGISTRY }}/${{ github.repository }}/${{ inputs.app-name }} 36 | tags: type=match,pattern=${{ inputs.app-name }}-v(.*),group=1 37 | 38 | - name: Build and push Docker image 39 | uses: docker/build-push-action@v4 40 | with: 41 | context: ./${{ inputs.app-name }} 42 | platforms: linux/amd64 43 | push: true 44 | tags: ${{ steps.meta.outputs.tags }} 45 | cache-from: type=gha 46 | cache-to: type=gha,mode=max 47 | -------------------------------------------------------------------------------- /.github/workflows/__CD__deploy-image.yml: -------------------------------------------------------------------------------- 1 | on: 2 | workflow_call: 3 | inputs: 4 | app-name: 5 | required: true 6 | type: string 7 | secrets: 8 | ssh-host: 9 | required: true 10 | ssh-username: 11 | required: true 12 | ssh-priv-key: 13 | required: true 14 | secret-key-base: 15 | required: true 16 | phx-host: 17 | required: true 18 | ice-port-range: 19 | required: true 20 | admin-username: 21 | required: true 22 | admin-password: 23 | required: true 24 | 25 | jobs: 26 | deploy-image: 27 | name: Deploy image 28 | runs-on: ubuntu-latest 29 | steps: 30 | - name: Run docker via remote SSH 31 | uses: appleboy/ssh-action@v1.0.3 32 | with: 33 | host: ${{ secrets.ssh-host }} 34 | username: ${{ secrets.ssh-username }} 35 | key: ${{ secrets.ssh-priv-key }} 36 | script: | 37 | export TAG=${{ github.ref_name }} 38 | export TAG=${TAG#*-v} 39 | docker stop ${{ inputs.app-name }} 40 | docker rm ${{ inputs.app-name }} 41 | docker run -d \ 42 | --restart unless-stopped \ 43 | --name ${{ inputs.app-name }} \ 44 | -e SECRET_KEY_BASE=${{ secrets.secret-key-base }} \ 45 | -e PHX_HOST=${{ secrets.phx-host }} \ 46 | -e ICE_PORT_RANGE=${{ secrets.ice-port-range }} \ 47 | -e ADMIN_USERNAME=${{ secrets.admin-username }} \ 48 | -e ADMIN_PASSWORD=${{ secrets.admin-password }} \ 49 | --network host \ 50 | ghcr.io/elixir-webrtc/apps/${{ inputs.app-name }}:${TAG} 51 | docker image prune --all --force 52 | -------------------------------------------------------------------------------- /.github/workflows/__CD__nexus.yml: -------------------------------------------------------------------------------- 1 | name: Nexus CD 2 | 3 | on: 4 | push: 5 | tags: 6 | - "nexus-v*.*.*" 7 | 8 | permissions: 9 | contents: read 10 | packages: write 11 | 12 | jobs: 13 | build-publish-nexus-image: 14 | name: "Build and publish Nexus image" 15 | uses: ./.github/workflows/__CD__build-publish-image.yml 16 | with: 17 | app-name: nexus 18 | deploy-nexus: 19 | name: "Deploy Nexus image" 20 | needs: build-publish-nexus-image 21 | uses: ./.github/workflows/__CD__deploy-image.yml 22 | with: 23 | app-name: nexus 24 | secrets: 25 | ssh-host: ${{ secrets.NEXUS_SSH_HOST }} 26 | ssh-username: ${{ secrets.NEXUS_SSH_USERNAME }} 27 | ssh-priv-key: ${{ secrets.NEXUS_SSH_PRIV_KEY }} 28 | secret-key-base: ${{ secrets.NEXUS_SECRET_KEY_BASE }} 29 | phx-host: ${{ secrets.NEXUS_PHX_HOST }} 30 | ice-port-range: ${{ secrets.NEXUS_ICE_PORT_RANGE }} 31 | admin-username: ${{ secrets.NEXUS_ADMIN_USERNAME }} 32 | admin-password: ${{ secrets.NEXUS_ADMIN_PASSWORD }} 33 | -------------------------------------------------------------------------------- /.github/workflows/__CD__recognizer.yml: -------------------------------------------------------------------------------- 1 | name: Recognizer CD 2 | 3 | on: 4 | push: 5 | tags: 6 | - "recognizer-v*.*.*" 7 | 8 | permissions: 9 | contents: read 10 | packages: write 11 | 12 | jobs: 13 | build-publish-recognizer-image: 14 | name: "Build and publish Recognizer image" 15 | uses: ./.github/workflows/__CD__build-publish-image.yml 16 | with: 17 | app-name: recognizer 18 | deploy-recognizer: 19 | name: "Deploy Recognizer image" 20 | needs: build-publish-recognizer-image 21 | uses: ./.github/workflows/__CD__deploy-image.yml 22 | with: 23 | app-name: recognizer 24 | secrets: 25 | ssh-host: ${{ secrets.RECOGNIZER_SSH_HOST }} 26 | ssh-username: ${{ secrets.RECOGNIZER_SSH_USERNAME }} 27 | ssh-priv-key: ${{ secrets.RECOGNIZER_SSH_PRIV_KEY }} 28 | secret-key-base: ${{ secrets.RECOGNIZER_SECRET_KEY_BASE }} 29 | phx-host: ${{ secrets.RECOGNIZER_PHX_HOST }} 30 | ice-port-range: ${{ secrets.RECOGNIZER_ICE_PORT_RANGE }} 31 | admin-username: ${{ secrets.RECOGNIZER_ADMIN_USERNAME }} 32 | admin-password: ${{ secrets.RECOGNIZER_ADMIN_PASSWORD }} 33 | -------------------------------------------------------------------------------- /.github/workflows/__CI__broadcaster.yml: -------------------------------------------------------------------------------- 1 | name: Broadcaster CI 2 | 3 | on: 4 | push: 5 | paths: 6 | - 'broadcaster/**' 7 | - '.github/workflows/**' 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | build_check_broadcaster: 14 | name: Build and check Broadcaster 15 | uses: ./.github/workflows/__CI__build-check-app.yml 16 | with: 17 | workdir: broadcaster 18 | 19 | -------------------------------------------------------------------------------- /.github/workflows/__CI__build-check-app.yml: -------------------------------------------------------------------------------- 1 | name: Build and check app 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | workdir: 7 | required: true 8 | type: string 9 | with-ffmpeg: 10 | default: false 11 | type: boolean 12 | 13 | permissions: 14 | contents: read 15 | 16 | env: 17 | FFMPEG_URL: https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2024-06-30-12-49/ffmpeg-n7.0.1-11-g40ddddca45-linux64-gpl-shared-7.0.tar.xz 18 | 19 | jobs: 20 | build_check_app: 21 | runs-on: ubuntu-latest 22 | name: CI on OTP ${{ matrix.otp }} / Elixir ${{ matrix.elixir }} in directory ${{ inputs.workdir }} 23 | strategy: 24 | matrix: 25 | otp: ['26'] 26 | elixir: ['1.16'] 27 | defaults: 28 | run: 29 | working-directory: ${{ inputs.workdir }} 30 | steps: 31 | - name: Set up Elixir 32 | uses: erlef/setup-beam@v1 33 | with: 34 | otp-version: ${{ matrix.otp }} 35 | elixir-version: ${{ matrix.elixir }} 36 | 37 | - name: Set up Node.js 38 | uses: actions/setup-node@v4 39 | with: 40 | node-version: 22 41 | 42 | - name: Install FFmpeg development libraries 43 | if: ${{ inputs.with-ffmpeg }} 44 | working-directory: . 45 | run: | 46 | sudo apt-get update 47 | sudo apt-get install -y libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libavdevice-dev 48 | 49 | - name: Checkout the code 50 | uses: actions/checkout@v4 51 | with: 52 | sparse-checkout: ${{ inputs.workdir }} 53 | 54 | - name: Cache dependencies 55 | uses: actions/cache@v4 56 | with: 57 | path: ${{ inputs.workdir }}/deps 58 | key: ${{ inputs.workdir }}-${{ runner.os }}-mix-deps-${{ hashFiles('**/mix.lock') }} 59 | restore-keys: | 60 | ${{ inputs.workdir }}-${{ runner.os }}-mix-deps- 61 | 62 | - name: Cache compiled build 63 | uses: actions/cache@v4 64 | with: 65 | path: ${{ inputs.workdir }}/_build 66 | key: ${{ inputs.workdir }}-${{ runner.os }}-mix-build-${{ hashFiles('**/mix.lock') }} 67 | restore-keys: | 68 | ${{ inputs.workdir }}-${{ runner.os }}-mix-build- 69 | ${{ inputs.workdir }}-${{ runner.os }}-mix- 70 | 71 | - name: Cache dialyzer artifacts 72 | uses: actions/cache@v4 73 | with: 74 | path: ${{ inputs.workdir }}/_dialyzer 75 | key: ${{ inputs.workdir }}-${{ runner.os }}-dialyzer-${{ hashFiles('**/mix.lock') }} 76 | restore-keys: | 77 | ${{ inputs.workdir }}-${{ runner.os }}-dialyzer- 78 | 79 | - name: Install and setup dependencies 80 | run: mix setup 81 | 82 | - name: Compile without warnings 83 | id: compile 84 | run: mix compile --warnings-as-errors 85 | 86 | - name: Check formatting 87 | if: ${{ !cancelled() && steps.compile.outcome == 'success' }} 88 | run: mix format --check-formatted 89 | 90 | - name: Check with credo 91 | if: ${{ !cancelled() && steps.compile.outcome == 'success' }} 92 | run: mix credo 93 | 94 | - name: Check with dialyzer 95 | if: ${{ !cancelled() && steps.compile.outcome == 'success' }} 96 | run: mix dialyzer 97 | 98 | - name: Check assets formatting 99 | if: ${{ !cancelled() }} 100 | run: mix assets.check 101 | -------------------------------------------------------------------------------- /.github/workflows/__CI__nexus.yml: -------------------------------------------------------------------------------- 1 | name: Nexus CI 2 | 3 | on: 4 | push: 5 | paths: 6 | - 'nexus/**' 7 | - '.github/workflows/**' 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | build_check_nexus: 14 | name: Build and check Nexus 15 | uses: ./.github/workflows/__CI__build-check-app.yml 16 | with: 17 | workdir: nexus 18 | -------------------------------------------------------------------------------- /.github/workflows/__CI__recognizer.yml: -------------------------------------------------------------------------------- 1 | name: Recognizer CI 2 | 3 | on: 4 | push: 5 | paths: 6 | - 'recognizer/**' 7 | - '.github/workflows/**' 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | build_check_recognizer: 14 | name: Build and check Recognizer 15 | uses: ./.github/workflows/__CI__build-check-app.yml 16 | with: 17 | workdir: recognizer 18 | with-ffmpeg: true 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Apps 2 | 3 | This repo contains a bunch of applications built on top of the [Elixir WebRTC](https://github.com/elixir-webrtc/ex_webrtc) library: 4 | 5 | * [_Recognizer_](/recognizer) - a [Phoenix](https://www.phoenixframework.org/) app for real-time image recognition that uses Elixir WebRTC and [Elixir Nx](https://github.com/elixir-nx/) 6 | * [_Broadcaster_](/broadcaster) - a [WHIP](https://datatracker.ietf.org/doc/html/draft-ietf-wish-whip-13)/[WHEP](https://datatracker.ietf.org/doc/html/draft-ietf-wish-whep-01) broadcasting server with a simple browser front-end 7 | * [_Nexus_](/nexus) - a multimedia relay server (SFU) facilitating video conference calls 8 | -------------------------------------------------------------------------------- /broadcaster/.dockerignore: -------------------------------------------------------------------------------- 1 | # This file excludes paths from the Docker build context. 2 | # 3 | # By default, Docker's build context includes all files (and folders) in the 4 | # current directory. Even if a file isn't copied into the container it is still sent to 5 | # the Docker daemon. 6 | # 7 | # There are multiple reasons to exclude files from the build context: 8 | # 9 | # 1. Prevent nested folders from being copied into the container (ex: exclude 10 | # /assets/node_modules when copying /assets) 11 | # 2. Reduce the size of the build context and improve build time (ex. /build, /deps, /doc) 12 | # 3. Avoid sending files containing sensitive information 13 | # 14 | # More information on using .dockerignore is available here: 15 | # https://docs.docker.com/engine/reference/builder/#dockerignore-file 16 | 17 | .dockerignore 18 | 19 | # Ignore git, but keep git HEAD and refs to access current commit hash if needed: 20 | # 21 | # $ cat .git/HEAD | awk '{print ".git/"$2}' | xargs cat 22 | # d0b8727759e1e0e7aa3d41707d12376e373d5ecc 23 | .git 24 | !.git/HEAD 25 | !.git/refs 26 | 27 | # Common development/test artifacts 28 | /cover/ 29 | /doc/ 30 | /test/ 31 | /tmp/ 32 | .elixir_ls 33 | 34 | # Mix artifacts 35 | /_build/ 36 | /deps/ 37 | *.ez 38 | 39 | # Generated on crash by the VM 40 | erl_crash.dump 41 | 42 | # Static artifacts - These should be fetched and built inside the Docker image 43 | /assets/node_modules/ 44 | /priv/static/assets/ 45 | /priv/static/cache_manifest.json 46 | -------------------------------------------------------------------------------- /broadcaster/.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:phoenix], 3 | plugins: [Phoenix.LiveView.HTMLFormatter], 4 | inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}"] 5 | ] 6 | -------------------------------------------------------------------------------- /broadcaster/.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 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Temporary files, for example, from tests. 23 | /tmp/ 24 | 25 | # Ignore package tarball (built via "mix hex.build"). 26 | broadcaster-*.tar 27 | 28 | # Ignore assets that are produced by build tools. 29 | /priv/static/assets/ 30 | 31 | # Ignore digested assets cache. 32 | /priv/static/cache_manifest.json 33 | 34 | # In case you use Node.js/npm, you want to ignore these. 35 | npm-debug.log 36 | /assets/node_modules/ 37 | 38 | /_dialyzer/ 39 | 40 | # artifacts of the headless_client.js 41 | /node_modules 42 | package.json 43 | package-lock.json 44 | 45 | # default recordings & converter output path 46 | /recordings/ 47 | /converter_output/ 48 | -------------------------------------------------------------------------------- /broadcaster/Dockerfile: -------------------------------------------------------------------------------- 1 | # Find eligible builder and runner images on Docker Hub. We use Ubuntu/Debian 2 | # instead of Alpine to avoid DNS resolution issues in production. 3 | # 4 | # https://hub.docker.com/r/hexpm/elixir/tags?page=1&name=ubuntu 5 | # https://hub.docker.com/_/ubuntu?tab=tags 6 | # 7 | # This file is based on these images: 8 | # 9 | # - https://hub.docker.com/r/hexpm/elixir/tags - for the build image 10 | # - https://hub.docker.com/_/debian?tab=tags&page=1&name=bullseye-20231009-slim - for the release image 11 | # - https://pkgs.org/ - resource for finding needed packages 12 | # - Ex: hexpm/elixir:1.16.0-erlang-26.2.1-debian-bullseye-20231009-slim 13 | # 14 | ARG ELIXIR_VERSION=1.17.2 15 | ARG OTP_VERSION=27.0.1 16 | ARG DEBIAN_VERSION=bookworm-20240701-slim 17 | 18 | ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}" 19 | ARG RUNNER_IMAGE="debian:${DEBIAN_VERSION}" 20 | 21 | FROM ${BUILDER_IMAGE} AS builder 22 | 23 | ARG TARGETPLATFORM 24 | RUN echo "Building for $TARGETPLATFORM" 25 | 26 | # install build dependencies 27 | RUN apt-get update -y && apt-get install -y build-essential git pkg-config libssl-dev 28 | # ex_srtp doesn't include precompiled libsrtp2 for ARM64 linux 29 | RUN if [ "$TARGETPLATFORM" = "linux/arm64" ]; then apt-get install -y libsrtp2-dev; fi 30 | RUN apt-get clean && rm -f /var/lib/apt/lists/*_* 31 | 32 | # prepare build dir 33 | WORKDIR /app 34 | 35 | # install hex + rebar 36 | RUN mix local.hex --force && \ 37 | mix local.rebar --force 38 | 39 | # set build ENV 40 | ENV MIX_ENV="prod" 41 | 42 | # install mix dependencies 43 | COPY mix.exs mix.lock ./ 44 | RUN mix deps.get --only $MIX_ENV 45 | RUN mkdir config 46 | 47 | # copy compile-time config files before we compile dependencies 48 | # to ensure any relevant config change will trigger the dependencies 49 | # to be re-compiled. 50 | COPY config/config.exs config/${MIX_ENV}.exs config/ 51 | RUN mix deps.compile 52 | 53 | COPY priv priv 54 | 55 | COPY lib lib 56 | 57 | COPY assets assets 58 | 59 | # compile assets 60 | RUN mix assets.deploy 61 | 62 | # Compile the release 63 | RUN mix compile 64 | 65 | # Changes to config/runtime.exs don't require recompiling the code 66 | COPY config/runtime.exs config/ 67 | 68 | COPY rel rel 69 | RUN mix release 70 | 71 | # start a new build stage so that the final image will only contain 72 | # the compiled release and other runtime necessities 73 | FROM ${RUNNER_IMAGE} 74 | 75 | ARG TARGETPLATFORM 76 | RUN echo "Building for $TARGETPLATFORM" 77 | 78 | RUN apt-get update -y && apt-get install -y libstdc++6 openssl libncurses5 locales ca-certificates 79 | RUN if [ "$TARGETPLATFORM" = "linux/arm64" ]; then apt-get install -y libsrtp2-dev; fi 80 | RUN apt-get clean && rm -f /var/lib/apt/lists/*_* 81 | 82 | # Set the locale 83 | RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen 84 | 85 | ENV LANG=en_US.UTF-8 86 | ENV LANGUAGE=en_US:en 87 | ENV LC_ALL=en_US.UTF-8 88 | 89 | WORKDIR "/app" 90 | RUN chown nobody /app 91 | 92 | # set runner ENV 93 | ENV MIX_ENV="prod" 94 | 95 | # Only copy the final release from the build stage 96 | COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/broadcaster ./ 97 | 98 | USER nobody 99 | 100 | # If using an environment that doesn't automatically reap zombie processes, it is 101 | # advised to add an init process such as tini via `apt-get install` 102 | # above and adding an entrypoint. See https://github.com/krallin/tini for details 103 | # ENTRYPOINT ["/tini", "--"] 104 | 105 | CMD ["/app/bin/server"] 106 | -------------------------------------------------------------------------------- /broadcaster/README.md: -------------------------------------------------------------------------------- 1 | # Broadcaster 2 | 3 | A [WHIP](https://datatracker.ietf.org/doc/html/draft-ietf-wish-whip-13)/[WHEP](https://datatracker.ietf.org/doc/html/draft-ietf-wish-whep-01) broadcasting server with a simple browser front-end. 4 | 5 | ## Usage 6 | 7 | Clone this repo and change the working directory to `apps/broadcaster`. 8 | 9 | Fetch dependencies and run the app: 10 | 11 | ```shell 12 | mix setup 13 | mix phx.server 14 | ``` 15 | 16 | We will use [OBS](https://github.com/obsproject/obs-studio) as a media source. 17 | Open OBS an go to `settings > Stream` and change `Service` to `WHIP`. 18 | 19 | Pass `http://localhost:4000/api/whip` as the `Server` value and `example` as the `Bearer Token` value, using the environment 20 | variables values that have been set a moment ago. Press `Apply`. 21 | 22 | Close the settings, choose a source of you liking (e.g. a web-cam feed) and press `Start Streaming`. 23 | 24 | Now you can visit [`localhost:4000`](http://localhost:4000) from your browser. You should see the stream from OBS. 25 | 26 | ## Running with Docker 27 | 28 | You can also run Broadcaster using Docker. 29 | 30 | Build an image (or use `ghcr.io/elixir-webrtc/apps/broadcaster:latest`): 31 | 32 | ``` 33 | docker build -t broadcaster . 34 | ``` 35 | 36 | and run: 37 | 38 | ``` 39 | docker run \ 40 | -e SECRET_KEY_BASE="secret" \ 41 | -e PHX_HOST=localhost \ 42 | -e ADMIN_USERNAME=admin \ 43 | -e ADMIN_PASSWORD=admin \ 44 | -e WHIP_TOKEN=token \ 45 | --network host \ 46 | broadcaster 47 | ``` 48 | 49 | Note that secret has to be at least 64 bytes long. 50 | You can generate one with `mix phx.gen.secret` or `head -c64 /dev/urandom | base64`. 51 | 52 | If you are running on MacOS, instead of using `--network host` option, you have to explicitly publish ports: 53 | 54 | ``` 55 | docker run \ 56 | -e SECRET_KEY_BASE="secret" \ 57 | -e PHX_HOST=localhost \ 58 | -e ADMIN_USERNAME=admin \ 59 | -e ADMIN_PASSWORD=admin \ 60 | -e WHIP_TOKEN=token \ 61 | -p 4000:4000 \ 62 | -p 50000-50010/udp \ 63 | broadcaster 64 | ``` 65 | -------------------------------------------------------------------------------- /broadcaster/assets/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "es5", 4 | "tabWidth": 2, 5 | "useTabs": false, 6 | "semi": true, 7 | "printWidth": 80 8 | } 9 | -------------------------------------------------------------------------------- /broadcaster/assets/css/app.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss/base'; 2 | @import 'tailwindcss/components'; 3 | @import 'tailwindcss/utilities'; 4 | 5 | /* This file is for your main application CSS */ 6 | #stream-desc a { 7 | @apply underline text-brand hover:text-brand; 8 | } 9 | 10 | ul { 11 | @apply list-disc list-inside; 12 | } 13 | 14 | .invalid-input { 15 | border-color: #d52b4d; 16 | } 17 | 18 | .chat-message { 19 | display: flex; 20 | flex-direction: column; 21 | padding-top: 0.5rem; 22 | padding-bottom: 0.5rem; 23 | } 24 | 25 | .chat-nickname { 26 | font-weight: 600; 27 | } 28 | 29 | .chat-admin { 30 | color: #e63946; 31 | } 32 | 33 | .chat-bar { 34 | width: 100%; 35 | display: flex; 36 | } 37 | 38 | .chat-remove { 39 | font-weight: 800; 40 | margin-left: auto; 41 | } 42 | 43 | /* from https://stackoverflow.com/a/38994837/9620900 */ 44 | /* Hiding scrollbar for Chrome, Safari and Opera */ 45 | ::-webkit-scrollbar { 46 | display: none; 47 | } 48 | 49 | /* Hiding scrollbar for IE, Edge and Firefox */ 50 | #videoplayer-wrapper, 51 | #chat-messages { 52 | scrollbar-width: none; 53 | /* Firefox */ 54 | -ms-overflow-style: none; 55 | /* IE and Edge */ 56 | } 57 | 58 | ::-webkit-scrollbar { 59 | display: none; 60 | } 61 | 62 | /* Hiding scrollbar for IE, Edge and Firefox */ 63 | #chat-input { 64 | scrollbar-width: none; 65 | /* Firefox */ 66 | -ms-overflow-style: none; 67 | /* IE and Edge */ 68 | } 69 | 70 | .details { 71 | padding: 5px 0px; 72 | border-bottom: 0.5px solid #0d0d0d; 73 | } 74 | 75 | summary { 76 | color: #0d0d0d; 77 | align-items: center; 78 | padding-bottom: 10px; 79 | justify-content: space-between; 80 | } 81 | 82 | .summary-content { 83 | color: #606060; 84 | padding: 10px 0px; 85 | } 86 | -------------------------------------------------------------------------------- /broadcaster/assets/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import globals from "globals"; 2 | import pluginJs from "@eslint/js"; 3 | import prettierPlugin from 'eslint-plugin-prettier'; 4 | import prettierConfig from 'eslint-config-prettier'; 5 | 6 | export default [ 7 | { 8 | files: ['**/*.js'], 9 | languageOptions: { 10 | ecmaVersion: 2021, 11 | sourceType: "module", 12 | globals: globals.browser 13 | }, 14 | plugins: { 15 | prettier: prettierPlugin 16 | }, 17 | rules: { 18 | "prettier/prettier": "error", 19 | "no-unused-vars": [ 20 | "error", 21 | { 22 | "argsIgnorePattern": "^_", 23 | "varsIgnorePattern": "^_" 24 | } 25 | ] 26 | }, 27 | settings: { 28 | prettier: prettierConfig 29 | } 30 | }, 31 | pluginJs.configs.recommended, 32 | ]; 33 | -------------------------------------------------------------------------------- /broadcaster/assets/js/app.js: -------------------------------------------------------------------------------- 1 | // If you want to use Phoenix channels, run `mix help phx.gen.channel` 2 | // to get started and then uncomment the line below. 3 | // import "./user_socket.js" 4 | 5 | // You can include dependencies in two ways. 6 | // 7 | // The simplest option is to put them in assets/vendor and 8 | // import them using relative paths: 9 | // 10 | // import "../vendor/some-package.js" 11 | // 12 | // Alternatively, you can `npm install some-package --prefix assets` and import 13 | // them using a path starting with the package name: 14 | // 15 | // import "some-package" 16 | // 17 | 18 | // Include phoenix_html to handle method=PUT/DELETE in forms and buttons. 19 | import 'phoenix_html'; 20 | // Establish Phoenix Socket and LiveView configuration. 21 | import { Socket } from 'phoenix'; 22 | import { LiveSocket } from 'phoenix_live_view'; 23 | import topbar from '../vendor/topbar'; 24 | 25 | import { Home } from './home.js'; 26 | import { Panel } from './panel.js'; 27 | 28 | let Hooks = {}; 29 | Hooks.Home = Home; 30 | Hooks.Panel = Panel; 31 | 32 | let csrfToken = document 33 | .querySelector("meta[name='csrf-token']") 34 | .getAttribute('content'); 35 | let liveSocket = new LiveSocket('/live', Socket, { 36 | longPollFallbackMs: 2500, 37 | params: { _csrf_token: csrfToken }, 38 | hooks: Hooks, 39 | }); 40 | 41 | // Show progress bar on live navigation and form submits 42 | topbar.config({ barColors: { 0: '#29d' }, shadowColor: 'rgba(0, 0, 0, .3)' }); 43 | window.addEventListener('phx:page-loading-start', (_info) => topbar.show(300)); 44 | window.addEventListener('phx:page-loading-stop', (_info) => topbar.hide()); 45 | 46 | // connect if there are any LiveViews on the page 47 | liveSocket.connect(); 48 | 49 | // expose liveSocket on window for web console debug logs and latency simulation: 50 | // >> liveSocket.enableDebug() 51 | // >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session 52 | // >> liveSocket.disableLatencySim() 53 | window.liveSocket = liveSocket; 54 | -------------------------------------------------------------------------------- /broadcaster/assets/js/chat.js: -------------------------------------------------------------------------------- 1 | import { Presence } from 'phoenix'; 2 | 3 | export async function connectChat(socket, isAdmin) { 4 | const viewercount = document.getElementById('viewercount'); 5 | const chatMessages = document.getElementById('chat-messages'); 6 | const chatInput = document.getElementById('chat-input'); 7 | const chatNickname = document.getElementById('chat-nickname'); 8 | const chatButton = document.getElementById('chat-button'); 9 | 10 | const channel = socket.channel('broadcaster:chat'); 11 | channel.onError((reason) => console.log('Channel error. Reason: ', reason)); 12 | 13 | const presence = new Presence(channel); 14 | presence.onSync(() => (viewercount.innerText = presence.list().length)); 15 | 16 | const send = () => { 17 | const body = chatInput.value.trim(); 18 | if (body != '') { 19 | channel.push('chat_msg', { body: body }); 20 | chatInput.value = ''; 21 | } 22 | }; 23 | 24 | channel.on('join_chat_resp', async (resp) => { 25 | if (resp.result === 'success') { 26 | chatButton.innerText = 'Send'; 27 | chatButton.onclick = send; 28 | chatNickname.disabled = true; 29 | chatInput.disabled = false; 30 | chatInput.onkeydown = (ev) => { 31 | if (ev.key === 'Enter') { 32 | // prevent from adding a new line in our text area 33 | ev.preventDefault(); 34 | send(); 35 | } 36 | }; 37 | } else { 38 | console.log(`Couldn't join chat, reason: ${resp.reason}`); 39 | chatNickname.classList.add('invalid-input'); 40 | } 41 | }); 42 | 43 | chatButton.onclick = async () => { 44 | let adminChatToken = null; 45 | 46 | if (isAdmin) { 47 | const response = await fetch( 48 | `${window.location.origin}/api/admin/chat-token`, 49 | { method: 'GET' } 50 | ); 51 | 52 | const body = await response.json(); 53 | adminChatToken = body.token; 54 | 55 | if (response.status != 200) { 56 | console.warn('Could not get admin chat token'); 57 | } 58 | } 59 | 60 | channel.push('join_chat', { 61 | nickname: chatNickname.value, 62 | token: adminChatToken, 63 | }); 64 | }; 65 | 66 | chatNickname.onclick = () => { 67 | chatNickname.classList.remove('invalid-input'); 68 | }; 69 | 70 | channel.on('chat_msg', (msg) => { 71 | appendChatMessage(chatMessages, msg, isAdmin); 72 | }); 73 | channel.on('delete_chat_msg', (msg) => deleteChatMessage(chatMessages, msg)); 74 | 75 | channel 76 | .join() 77 | .receive('ok', (resp) => { 78 | console.log('Joined chat channel successfully', resp); 79 | }) 80 | .receive('error', (resp) => { 81 | console.error('Unable to join chat channel', resp); 82 | }); 83 | } 84 | 85 | function appendChatMessage(chatMessages, msg, isAdmin) { 86 | if (msg.nickname == undefined || msg.body == undefined) return; 87 | 88 | // Check whether we have already been at the bottom of the chat. 89 | // If not, we won't scroll down after appending a message. 90 | const wasAtBottom = 91 | chatMessages.scrollHeight - chatMessages.clientHeight <= 92 | chatMessages.scrollTop + 10; 93 | 94 | const chatMessage = document.createElement('div'); 95 | chatMessage.classList.add('chat-message'); 96 | chatMessage.setAttribute('data-id', msg.id); 97 | 98 | const bar = document.createElement('div'); 99 | bar.classList.add('chat-bar'); 100 | 101 | const nickname = document.createElement('div'); 102 | nickname.classList.add('chat-nickname'); 103 | nickname.innerText = msg.nickname; 104 | 105 | console.log(msg); 106 | 107 | if (msg.admin === true) { 108 | nickname.classList.add('chat-admin'); 109 | nickname.innerText = '📹 ' + nickname.innerText; 110 | } 111 | 112 | bar.appendChild(nickname); 113 | 114 | if (isAdmin) { 115 | const remove = document.createElement('button'); 116 | remove.innerText = 'remove'; 117 | remove.classList.add('chat-remove'); 118 | remove.onclick = async () => { 119 | const response = await fetch( 120 | `${window.location.origin}/api/admin/chat/${msg.id}`, 121 | { method: 'DELETE' } 122 | ); 123 | if (response.status != 200) { 124 | console.warn('Deleting message failed'); 125 | } 126 | }; 127 | bar.appendChild(remove); 128 | } 129 | 130 | chatMessage.appendChild(bar); 131 | 132 | const body = document.createElement('div'); 133 | body.innerText = msg.body; 134 | chatMessage.appendChild(body); 135 | 136 | chatMessages.appendChild(chatMessage); 137 | 138 | if (wasAtBottom == true) { 139 | chatMessages.scrollTop = chatMessages.scrollHeight; 140 | } 141 | 142 | // allow for 3-scroll history 143 | if (chatMessages.scrollHeight > 4 * chatMessages.clientHeight) { 144 | chatMessages.removeChild(chatMessages.children[0]); 145 | } 146 | } 147 | 148 | function deleteChatMessage(chatMessages, msg) { 149 | for (const child of chatMessages.children) { 150 | if (child.getAttribute('data-id') == msg.id) { 151 | child.lastChild.innerText = 'Removed by moderator'; 152 | child.lastChild.style.fontStyle = 'italic'; 153 | } 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /broadcaster/assets/js/whep-client.js: -------------------------------------------------------------------------------- 1 | let pcConfig; 2 | const pcConfigData = document.body.getAttribute('data-pcConfig'); 3 | if (pcConfigData) { 4 | pcConfig = JSON.parse(pcConfigData); 5 | } else { 6 | pcConfig = {}; 7 | } 8 | 9 | export class WHEPClient { 10 | constructor(url) { 11 | this.url = url; 12 | this.id = 'WHEP Client'; 13 | this.pc = undefined; 14 | this.patchEndpoint = undefined; 15 | this.onstream = undefined; 16 | this.onconnected = undefined; 17 | } 18 | 19 | async connect() { 20 | const candidates = []; 21 | const pc = new RTCPeerConnection(pcConfig); 22 | this.pc = pc; 23 | 24 | pc.ontrack = (event) => { 25 | if (event.track.kind == 'video') { 26 | console.log(`[${this.id}]: Video track added`); 27 | 28 | if (this.onstream) { 29 | this.onstream(event.streams[0]); 30 | } 31 | } else { 32 | // Audio tracks are associated with the stream (`event.streams[0]`) and require no separate actions 33 | console.log(`[${this.id}]: Audio track added`); 34 | } 35 | }; 36 | 37 | pc.onicegatheringstatechange = () => 38 | console.log( 39 | `[${this.id}]: Gathering state change:`, 40 | pc.iceGatheringState 41 | ); 42 | 43 | pc.onconnectionstatechange = () => { 44 | console.log(`[${this.id}]: Connection state change:`, pc.connectionState); 45 | if (pc.connectionState === 'connected' && this.onconnected) { 46 | this.onconnected(); 47 | } 48 | }; 49 | 50 | pc.onicecandidate = (event) => { 51 | if (event.candidate == null) { 52 | return; 53 | } 54 | 55 | const candidate = JSON.stringify(event.candidate); 56 | if (this.patchEndpoint === undefined) { 57 | candidates.push(candidate); 58 | } else { 59 | this.sendCandidate(candidate); 60 | } 61 | }; 62 | 63 | pc.addTransceiver('video', { direction: 'recvonly' }); 64 | pc.addTransceiver('audio', { direction: 'recvonly' }); 65 | 66 | const offer = await pc.createOffer(); 67 | await pc.setLocalDescription(offer); 68 | 69 | const response = await fetch(this.url, { 70 | method: 'POST', 71 | cache: 'no-cache', 72 | headers: { 73 | Accept: 'application/sdp', 74 | 'Content-Type': 'application/sdp', 75 | }, 76 | body: pc.localDescription.sdp, 77 | }); 78 | 79 | if (response.status !== 201) { 80 | console.error( 81 | `[${this.id}]: Failed to initialize WHEP connection, status: ${response.status}` 82 | ); 83 | return; 84 | } 85 | 86 | this.patchEndpoint = response.headers.get('location'); 87 | console.log(`[${this.id}]: Sucessfully initialized WHEP connection`); 88 | 89 | for (const candidate of candidates) { 90 | this.sendCandidate(candidate); 91 | } 92 | 93 | const sdp = await response.text(); 94 | await pc.setRemoteDescription({ type: 'answer', sdp: sdp }); 95 | } 96 | 97 | async disconnect() { 98 | this.pc.close(); 99 | } 100 | 101 | async changeLayer(layer) { 102 | // According to the spec, we should gather the info about available layers from the `layers` event 103 | // emitted in the SSE stream tied to *one* given WHEP session. 104 | // 105 | // However, to simplify the implementation and decrease resource usage, we're assuming each stream 106 | // has the layers with `encodingId` of `h`, `m` and `l`, corresponding to high, medium and low video quality. 107 | // If that's not the case (e.g. the stream doesn't use simulcast), the server returns an error response which we ignore. 108 | // 109 | // Nevertheless, the server supports the `Server Sent Events` and `Video Layer Selection` WHEP extensions, 110 | // and WHEP players other than this site are free to use them. 111 | // 112 | // For more info refer to https://www.ietf.org/archive/id/draft-ietf-wish-whep-01.html#section-4.6.2 113 | if (this.patchEndpoint) { 114 | const response = await fetch(`${this.patchEndpoint}/layer`, { 115 | method: 'POST', 116 | cache: 'no-cache', 117 | headers: { 'Content-Type': 'application/json' }, 118 | body: JSON.stringify({ encodingId: layer }), 119 | }); 120 | 121 | if (response.status != 200) { 122 | console.warn(`[${this.id}]: Changing layer failed`, response); 123 | } 124 | } 125 | } 126 | 127 | async sendCandidate(candidate) { 128 | const response = await fetch(this.patchEndpoint, { 129 | method: 'PATCH', 130 | cache: 'no-cache', 131 | headers: { 132 | 'Content-Type': 'application/trickle-ice-sdpfrag', 133 | }, 134 | body: candidate, 135 | }); 136 | 137 | if (response.status === 204) { 138 | console.log(`[${this.id}]: Successfully sent ICE candidate:`, candidate); 139 | } else { 140 | console.error( 141 | `[${this.id}]: Failed to send ICE, status: ${response.status}, candidate:`, 142 | candidate 143 | ); 144 | } 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /broadcaster/assets/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "assets", 3 | "version": "0.1.0", 4 | "description": "", 5 | "main": "", 6 | "scripts": { 7 | "lint": "eslint 'js/**/*.js' --fix", 8 | "format": "prettier --write 'js/**/*.js' 'css/**/*.css'", 9 | "check": "prettier --check 'js/**/*.js' 'css/**/*.css'" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "Apache-2.0", 14 | "devDependencies": { 15 | "@eslint/eslintrc": "^3.1.0", 16 | "@eslint/js": "^9.7.0", 17 | "eslint": "^9.7.0", 18 | "eslint-config-prettier": "^9.1.0", 19 | "eslint-plugin-prettier": "^5.1.3", 20 | "globals": "^15.8.0", 21 | "prettier": "^3.3.3" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /broadcaster/assets/tailwind.config.js: -------------------------------------------------------------------------------- 1 | // See the Tailwind configuration guide for advanced usage 2 | // https://tailwindcss.com/docs/configuration 3 | 4 | const plugin = require("tailwindcss/plugin") 5 | const fs = require("fs") 6 | const path = require("path") 7 | 8 | module.exports = { 9 | content: [ 10 | "./js/**/*.js", 11 | "../lib/broadcaster_web.ex", 12 | "../lib/broadcaster_web/**/*.*ex" 13 | ], 14 | theme: { 15 | extend: { 16 | colors: { 17 | brand: "#4339AC", 18 | b: "#606060", 19 | } 20 | }, 21 | }, 22 | plugins: [ 23 | require("@tailwindcss/forms"), 24 | // Allows prefixing tailwind classes with LiveView classes to add rules 25 | // only when LiveView classes are applied, for example: 26 | // 27 | //
28 | // 29 | plugin(({addVariant}) => addVariant("phx-no-feedback", [".phx-no-feedback&", ".phx-no-feedback &"])), 30 | plugin(({addVariant}) => addVariant("phx-click-loading", [".phx-click-loading&", ".phx-click-loading &"])), 31 | plugin(({addVariant}) => addVariant("phx-submit-loading", [".phx-submit-loading&", ".phx-submit-loading &"])), 32 | plugin(({addVariant}) => addVariant("phx-change-loading", [".phx-change-loading&", ".phx-change-loading &"])), 33 | 34 | // Embeds Heroicons (https://heroicons.com) into your app.css bundle 35 | // See your `CoreComponents.icon/1` for more information. 36 | // 37 | plugin(function({matchComponents, theme}) { 38 | let iconsDir = path.join(__dirname, "../deps/heroicons/optimized") 39 | let values = {} 40 | let icons = [ 41 | ["", "/24/outline"], 42 | ["-solid", "/24/solid"], 43 | ["-mini", "/20/solid"], 44 | ["-micro", "/16/solid"] 45 | ] 46 | icons.forEach(([suffix, dir]) => { 47 | fs.readdirSync(path.join(iconsDir, dir)).forEach(file => { 48 | let name = path.basename(file, ".svg") + suffix 49 | values[name] = {name, fullPath: path.join(iconsDir, dir, file)} 50 | }) 51 | }) 52 | matchComponents({ 53 | "hero": ({name, fullPath}) => { 54 | let content = fs.readFileSync(fullPath).toString().replace(/\r?\n|\r/g, "") 55 | let size = theme("spacing.6") 56 | if (name.endsWith("-mini")) { 57 | size = theme("spacing.5") 58 | } else if (name.endsWith("-micro")) { 59 | size = theme("spacing.4") 60 | } 61 | return { 62 | [`--hero-${name}`]: `url('data:image/svg+xml;utf8,${content}')`, 63 | "-webkit-mask": `var(--hero-${name})`, 64 | "mask": `var(--hero-${name})`, 65 | "mask-repeat": "no-repeat", 66 | "background-color": "currentColor", 67 | "vertical-align": "middle", 68 | "display": "inline-block", 69 | "width": size, 70 | "height": size 71 | } 72 | } 73 | }, {values}) 74 | }) 75 | ] 76 | } 77 | -------------------------------------------------------------------------------- /broadcaster/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 | config :broadcaster, 11 | generators: [timestamp_type: :utc_datetime] 12 | 13 | # Configures the endpoint 14 | config :broadcaster, BroadcasterWeb.Endpoint, 15 | url: [host: "localhost"], 16 | adapter: Bandit.PhoenixAdapter, 17 | render_errors: [ 18 | formats: [html: BroadcasterWeb.ErrorHTML, json: BroadcasterWeb.ErrorJSON], 19 | layout: false 20 | ], 21 | pubsub_server: Broadcaster.PubSub, 22 | live_view: [signing_salt: "/ONXsVON"] 23 | 24 | # Configure esbuild (the version is required) 25 | config :esbuild, 26 | version: "0.17.11", 27 | broadcaster: [ 28 | args: 29 | ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*), 30 | cd: Path.expand("../assets", __DIR__), 31 | env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} 32 | ] 33 | 34 | # Configure tailwind (the version is required) 35 | config :tailwind, 36 | version: "3.4.0", 37 | broadcaster: [ 38 | args: ~w( 39 | --config=tailwind.config.js 40 | --input=css/app.css 41 | --output=../priv/static/assets/app.css 42 | ), 43 | cd: Path.expand("../assets", __DIR__) 44 | ] 45 | 46 | # Configures Elixir's Logger 47 | config :logger, :console, 48 | format: "$time $metadata[$level] $message\n", 49 | metadata: [:request_id], 50 | level: :info 51 | 52 | # Use Jason for JSON parsing in Phoenix 53 | config :phoenix, :json_library, Jason 54 | 55 | config :mime, :types, %{ 56 | "application/sdp" => ["sdp"], 57 | "application/trickle-ice-sdpfrag" => ["trickle-ice-sdpfrag"] 58 | } 59 | 60 | config :broadcaster, 61 | whip_token: "example", 62 | admin_username: "admin", 63 | admin_password: "admin", 64 | chat_slow_mode_ms: 1000 65 | 66 | # Import environment specific config. This must remain at the bottom 67 | # of this file so it overrides the configuration defined above. 68 | import_config "#{config_env()}.exs" 69 | -------------------------------------------------------------------------------- /broadcaster/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 can use it 8 | # to bundle .js and .css sources. 9 | config :broadcaster, BroadcasterWeb.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: {0, 0, 0, 0}, port: 4000], 13 | check_origin: false, 14 | code_reloader: true, 15 | debug_errors: true, 16 | secret_key_base: "MC0EKvuSeGvebcMTYQh5nwte1ePB2u8xfuQCcv9FrPT2R4VA8Cyg9ADkI16v0uoR", 17 | watchers: [ 18 | esbuild: {Esbuild, :install_and_run, [:broadcaster, ~w(--sourcemap=inline --watch)]}, 19 | tailwind: {Tailwind, :install_and_run, [:broadcaster, ~w(--watch)]} 20 | ] 21 | 22 | # ## SSL Support 23 | # 24 | # In order to use HTTPS in development, a self-signed 25 | # certificate can be generated by running the following 26 | # Mix task: 27 | # 28 | # mix phx.gen.cert 29 | # 30 | # Run `mix help phx.gen.cert` for more information. 31 | # 32 | # The `http:` config above can be replaced with: 33 | # 34 | # https: [ 35 | # port: 4001, 36 | # cipher_suite: :strong, 37 | # keyfile: "priv/cert/selfsigned_key.pem", 38 | # certfile: "priv/cert/selfsigned.pem" 39 | # ], 40 | # 41 | # If desired, both `http:` and `https:` keys can be 42 | # configured to run both http and https servers on 43 | # different ports. 44 | 45 | # Watch static and templates for browser reloading. 46 | config :broadcaster, BroadcasterWeb.Endpoint, 47 | live_reload: [ 48 | patterns: [ 49 | ~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg)$", 50 | ~r"lib/broadcaster_web/(controllers|live|components)/.*(ex|heex)$" 51 | ] 52 | ] 53 | 54 | # Do not include metadata nor timestamps in development logs 55 | config :logger, :console, format: "[$level] $message\n" 56 | 57 | # Set a higher stacktrace during development. Avoid configuring such 58 | # in production as building large stacktraces may be expensive. 59 | config :phoenix, :stacktrace_depth, 20 60 | 61 | # Initialize plugs at runtime for faster development compilation 62 | config :phoenix, :plug_init_mode, :runtime 63 | 64 | config :phoenix_live_view, 65 | # Include HEEx debug annotations as HTML comments in rendered markup 66 | debug_heex_annotations: true, 67 | # Enable helpful, but potentially expensive runtime checks 68 | enable_expensive_runtime_checks: true 69 | -------------------------------------------------------------------------------- /broadcaster/config/prod.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # Note we also include the path to a cache manifest 4 | # containing the digested version of static files. This 5 | # manifest is generated by the `mix assets.deploy` task, 6 | # which you should run after static files are built and 7 | # before starting your production server. 8 | config :broadcaster, BroadcasterWeb.Endpoint, 9 | cache_static_manifest: "priv/static/cache_manifest.json" 10 | 11 | # Do not print debug messages in production 12 | config :logger, level: :info 13 | 14 | # Runtime production configuration, including reading 15 | # of environment variables, is done on config/runtime.exs. 16 | -------------------------------------------------------------------------------- /broadcaster/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 :broadcaster, BroadcasterWeb.Endpoint, 6 | http: [ip: {127, 0, 0, 1}, port: 4002], 7 | secret_key_base: "jnqiMUTku5atQODwJ2z4vMeU6kal0Av2djgp/I9f5jE6CGNUUZjxDBridCFC3xI2", 8 | server: false 9 | 10 | # Print only warnings and errors during test 11 | config :logger, level: :warning 12 | 13 | # Initialize plugs at runtime for faster test compilation 14 | config :phoenix, :plug_init_mode, :runtime 15 | 16 | config :phoenix_live_view, 17 | # Enable helpful, but potentially expensive runtime checks 18 | enable_expensive_runtime_checks: true 19 | -------------------------------------------------------------------------------- /broadcaster/docker-compose-dist.yml: -------------------------------------------------------------------------------- 1 | # Sample containerised cluster setup with two nodes 2 | 3 | x-broadcaster-template: &broadcaster-template 4 | build: 5 | context: . 6 | environment: &broadcaster-environment 7 | SECRET_KEY_BASE: "ijoI13mbV5eRw1/5ckBEBMSOdYSkY69mpuwLgooBYGWLx3bd2yldOBLP/46/mm9d" 8 | PHX_HOST: localhost 9 | WHIP_TOKEN: token 10 | ADMIN_USERNAME: admin 11 | ADMIN_PASSWORD: admin 12 | RELEASE_DISTRIBUTION: name 13 | RELEASE_COOKIE: super-secret-cookie 14 | DISTRIBUTION_MODE: dns 15 | DNS_CLUSTER_QUERY: app.dns-network 16 | restart: on-failure 17 | 18 | name: broadcaster-dist 19 | services: 20 | app1: 21 | <<: *broadcaster-template 22 | environment: 23 | <<: *broadcaster-environment 24 | PORT: 4001 25 | RELEASE_NODE: "broadcaster@10.0.0.11" 26 | ICE_PORT_RANGE: "51100-51199" 27 | ports: 28 | - 4001:4001 29 | - "51100-51199/udp" 30 | networks: 31 | net0: 32 | ipv4_address: 10.0.0.11 33 | aliases: 34 | - app.dns-network 35 | 36 | app2: 37 | <<: *broadcaster-template 38 | environment: 39 | <<: *broadcaster-environment 40 | PORT: 4002 41 | RELEASE_NODE: "broadcaster@10.0.0.12" 42 | ICE_PORT_RANGE: "51200-51299" 43 | ports: 44 | - 4002:4002 45 | - "51200-51299/udp" 46 | networks: 47 | net0: 48 | ipv4_address: 10.0.0.12 49 | aliases: 50 | - app.dns-network 51 | 52 | networks: 53 | net0: 54 | ipam: 55 | driver: default 56 | config: 57 | - subnet: 10.0.0.0/24 58 | -------------------------------------------------------------------------------- /broadcaster/headless_client.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const puppeteer = require("puppeteer"); 4 | 5 | const url = 6 | process.env.URL === undefined ? "http://localhost:4000" : process.env.URL; 7 | const token = process.env.TOKEN === undefined ? "example" : process.env.TOKEN; 8 | 9 | async function stream(url, token) { 10 | console.log("Starting new stream..."); 11 | 12 | const localStream = await navigator.mediaDevices.getUserMedia({ 13 | video: { 14 | width: { ideal: 1280 }, 15 | height: { ideal: 720 }, 16 | frameRate: { ideal: 24 }, 17 | }, 18 | audio: true, 19 | }); 20 | 21 | const pc = new RTCPeerConnection({ 22 | iceServers: [{ urls: "stun:stun.l.google.com:19302" }], 23 | }); 24 | pc.onconnectionstatechange = async (_) => { 25 | console.log("Connection state changed:", pc.connectionState); 26 | if (pc.connectionState === "failed") { 27 | stream(url, token); 28 | } 29 | }; 30 | 31 | pc.addTrack(localStream.getAudioTracks()[0], localStream); 32 | pc.addTransceiver(localStream.getVideoTracks()[0], { 33 | streams: [localStream], 34 | sendEncodings: [ 35 | { rid: "h", maxBitrate: 1500 * 1024 }, 36 | { rid: "m", scaleResolutionDownBy: 2, maxBitrate: 600 * 1024 }, 37 | { rid: "l", scaleResolutionDownBy: 4, maxBitrate: 300 * 1024 }, 38 | ], 39 | }); 40 | 41 | const offer = await pc.createOffer(); 42 | await pc.setLocalDescription(offer); 43 | 44 | const response = await fetch(`${url}/api/whip`, { 45 | method: "POST", 46 | cache: "no-cache", 47 | headers: { 48 | Accept: "application/sdp", 49 | "Content-Type": "application/sdp", 50 | Authorization: `Bearer ${token}`, 51 | }, 52 | body: offer.sdp, 53 | }); 54 | 55 | if (response.status !== 201) { 56 | throw Error("Unable to connect to the server"); 57 | } 58 | 59 | const sdp = await response.text(); 60 | await pc.setRemoteDescription({ type: "answer", sdp: sdp }); 61 | } 62 | 63 | async function start() { 64 | let browser; 65 | 66 | try { 67 | console.log("Initialising the browser..."); 68 | browser = await puppeteer.launch({ 69 | args: [ 70 | "--no-sandbox", 71 | "--use-fake-ui-for-media-stream", 72 | "--use-fake-device-for-media-stream", 73 | ], 74 | }); 75 | const page = await browser.newPage(); 76 | page.on("console", (msg) => console.log("Page log:", msg.text())); 77 | 78 | // we need a page with secure context in order to access userMedia 79 | await page.goto(`${url}/notfound`); 80 | 81 | await page.evaluate(stream, url, token); 82 | } catch (err) { 83 | console.error("Browser error occured:", err); 84 | if (browser) await browser.close(); 85 | } 86 | } 87 | 88 | start(); 89 | -------------------------------------------------------------------------------- /broadcaster/kubernetes-sample-config.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: broadcaster-headless 5 | spec: 6 | selector: 7 | app: broadcaster 8 | type: ClusterIP 9 | clusterIP: None 10 | --- 11 | apiVersion: v1 12 | kind: Service 13 | metadata: 14 | name: broadcaster-external 15 | spec: 16 | selector: 17 | app: broadcaster 18 | type: LoadBalancer 19 | ports: 20 | - port: 8080 21 | targetPort: 4000 22 | protocol: TCP 23 | name: http 24 | --- 25 | apiVersion: apps/v1 26 | kind: Deployment 27 | metadata: 28 | name: broadcaster 29 | namespace: default 30 | spec: 31 | replicas: 2 32 | selector: 33 | matchLabels: 34 | app: broadcaster 35 | template: 36 | metadata: 37 | labels: 38 | app: broadcaster 39 | spec: 40 | containers: 41 | - name: broadcaster 42 | image: ghcr.io/elixir-webrtc/apps/broadcaster:latest 43 | ports: 44 | - name: http 45 | containerPort: 4000 46 | protocol: TCP 47 | env: 48 | - name: POD_IP 49 | valueFrom: 50 | fieldRef: 51 | fieldPath: status.podIP 52 | - name: DISTRIBUTION_MODE 53 | value: k8s 54 | - name: K8S_SERVICE_NAME 55 | value: broadcaster-headless 56 | - name: ICE_SERVER_URL 57 | value: turn:turn.example.org:3478?transport=udp 58 | - name: ICE_SERVER_USERNAME 59 | value: user-1 60 | - name: ICE_SERVER_CREDENTIAL 61 | value: pass-1 62 | - name: ICE_TRANSPORT_POLICY 63 | value: relay 64 | - name: ICE_PORT_RANGE 65 | value: 51000-52000 66 | - name: SECRET_KEY_BASE 67 | value: u1gYGbDNgA5RwdKGFe9CdK+5qLCVROAHZAFPgUVlcmjTEGdvpXqgYW9qFjLQvxZO 68 | - name: PHX_HOST 69 | value: example.org 70 | - name: ADMIN_USERNAME 71 | value: admin 72 | - name: ADMIN_PASSWORD 73 | value: admin 74 | - name: WHIP_TOKEN 75 | value: token 76 | -------------------------------------------------------------------------------- /broadcaster/lib/broadcaster.ex: -------------------------------------------------------------------------------- 1 | defmodule Broadcaster do 2 | @moduledoc """ 3 | Broadcaster 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 | -------------------------------------------------------------------------------- /broadcaster/lib/broadcaster/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Broadcaster.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 | @version Mix.Project.config()[:version] 9 | 10 | @spec version() :: String.t() 11 | def version(), do: @version 12 | 13 | @impl true 14 | def start(_type, _args) do 15 | dist_config = 16 | case Application.fetch_env!(:broadcaster, :dist_config) do 17 | nil -> 18 | [] 19 | 20 | config -> 21 | [{Cluster.Supervisor, [[cluster: config], [name: Broadcaster.ClusterSupervisor]]}] 22 | end 23 | 24 | {recordings_enabled?, recordings_config} = 25 | case Application.fetch_env!(:broadcaster, :recordings_config) do 26 | nil -> 27 | {false, []} 28 | 29 | config -> 30 | config = Keyword.put(config, :on_start, &Broadcaster.Forwarder.on_recorder_start/0) 31 | {true, [{ExWebRTC.Recorder, [config, [name: Broadcaster.Recorder]]}]} 32 | end 33 | 34 | # Start dist_config before starting Forwarder, 35 | # as Forwarder asks other nodes for their inputs. 36 | children = 37 | [ 38 | BroadcasterWeb.Telemetry, 39 | {Phoenix.PubSub, name: Broadcaster.PubSub}, 40 | BroadcasterWeb.Endpoint, 41 | BroadcasterWeb.Presence 42 | ] ++ 43 | dist_config ++ 44 | [ 45 | Broadcaster.PeerSupervisor, 46 | {Broadcaster.Forwarder, [recordings_enabled?: recordings_enabled?]}, 47 | Broadcaster.ChatHistory, 48 | {Registry, name: Broadcaster.ChatNicknamesRegistry, keys: :unique} 49 | ] ++ recordings_config 50 | 51 | # See https://hexdocs.pm/elixir/Supervisor.html 52 | # for other strategies and supported options 53 | opts = [strategy: :one_for_one, name: Broadcaster.Supervisor] 54 | Supervisor.start_link(children, opts) 55 | end 56 | 57 | # Tell Phoenix to update the endpoint configuration 58 | # whenever the application is updated. 59 | @impl true 60 | def config_change(changed, _new, removed) do 61 | BroadcasterWeb.Endpoint.config_change(changed, removed) 62 | :ok 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /broadcaster/lib/broadcaster/chat_history.ex: -------------------------------------------------------------------------------- 1 | defmodule Broadcaster.ChatHistory do 2 | @moduledoc false 3 | use Agent 4 | 5 | @max_history_size 100 6 | 7 | @spec start_link(term()) :: Agent.on_start() 8 | def start_link(_) do 9 | Agent.start_link(fn -> %{size: 0, queue: :queue.new()} end, name: __MODULE__) 10 | end 11 | 12 | @spec put(map()) :: :ok 13 | def put(msg) do 14 | Agent.cast(__MODULE__, fn history -> 15 | queue = :queue.in(msg, history.queue) 16 | 17 | if history.size == @max_history_size do 18 | {_, queue} = :queue.out(history.queue) 19 | %{history | queue: queue} 20 | else 21 | %{history | size: history.size + 1, queue: queue} 22 | end 23 | end) 24 | end 25 | 26 | @spec get() :: [map()] 27 | def get() do 28 | try do 29 | Agent.get(__MODULE__, fn history -> :queue.to_list(history.queue) end, 1000) 30 | catch 31 | :exit, _ -> [] 32 | end 33 | end 34 | 35 | @spec delete(String.t()) :: :ok 36 | def delete(id) do 37 | Agent.update(__MODULE__, fn history -> 38 | queue = :queue.delete_with(fn msg -> msg.id == id end, history.queue) 39 | %{history | size: history.size - 1, queue: queue} 40 | end) 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /broadcaster/lib/broadcaster/peer_supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule Broadcaster.PeerSupervisor do 2 | @moduledoc false 3 | 4 | use DynamicSupervisor 5 | 6 | require Logger 7 | 8 | alias ExWebRTC.{MediaStreamTrack, PeerConnection, SessionDescription, RTPCodecParameters} 9 | 10 | @audio_codecs [ 11 | %RTPCodecParameters{ 12 | payload_type: 111, 13 | mime_type: "audio/opus", 14 | clock_rate: 48_000, 15 | channels: 2 16 | } 17 | ] 18 | 19 | @video_codecs [ 20 | %RTPCodecParameters{ 21 | payload_type: 96, 22 | mime_type: "video/VP8", 23 | clock_rate: 90_000 24 | } 25 | ] 26 | 27 | @spec client_pc_config() :: String.t() 28 | def client_pc_config() do 29 | pc_config = Application.fetch_env!(:broadcaster, :pc_config) 30 | 31 | %{ 32 | iceServers: pc_config[:ice_servers], 33 | iceTransportPolicy: pc_config[:ice_transport_policy] 34 | } 35 | |> Jason.encode!() 36 | end 37 | 38 | @spec start_link(any()) :: Supervisor.on_start() 39 | def start_link(arg) do 40 | :syn.add_node_to_scopes([Broadcaster.GlobalPeerRegistry]) 41 | 42 | DynamicSupervisor.start_link(__MODULE__, arg, name: __MODULE__) 43 | end 44 | 45 | @spec start_whip(String.t()) :: {:ok, pid(), String.t(), String.t()} | {:error, term()} 46 | def start_whip(offer_sdp), do: start_pc(offer_sdp, :recvonly) 47 | 48 | @spec start_whep(String.t()) :: {:ok, pid(), String.t(), String.t()} | {:error, term()} 49 | def start_whep(offer_sdp), do: start_pc(offer_sdp, :sendonly) 50 | 51 | @spec fetch_pid(String.t()) :: {:ok, pid()} | {:error, :peer_not_found} 52 | def fetch_pid(id) do 53 | case :syn.lookup(Broadcaster.GlobalPeerRegistry, id) do 54 | :undefined -> {:error, :peer_not_found} 55 | {pid, _val} -> {:ok, pid} 56 | end 57 | end 58 | 59 | @spec terminate_pc(pid()) :: :ok | {:error, :not_found} 60 | def terminate_pc(pc) do 61 | DynamicSupervisor.terminate_child(__MODULE__, pc) 62 | end 63 | 64 | @impl true 65 | def init(_arg) do 66 | DynamicSupervisor.init(strategy: :one_for_one) 67 | end 68 | 69 | defp start_pc(offer_sdp, direction) do 70 | offer = %SessionDescription{type: :offer, sdp: offer_sdp} 71 | pc_id = generate_pc_id() 72 | {:ok, pc} = spawn_peer_connection() 73 | :syn.register(Broadcaster.GlobalPeerRegistry, pc_id, pc) 74 | 75 | Logger.info("Received offer for #{inspect(pc)}") 76 | Logger.debug("Offer SDP for #{inspect(pc)}:\n#{offer.sdp}") 77 | 78 | with :ok <- PeerConnection.set_remote_description(pc, offer), 79 | :ok <- setup_transceivers(pc, direction), 80 | {:ok, answer} <- PeerConnection.create_answer(pc), 81 | :ok <- PeerConnection.set_local_description(pc, answer), 82 | :ok <- gather_candidates(pc), 83 | answer <- PeerConnection.get_local_description(pc) do 84 | Logger.info("Sent answer for #{inspect(pc)}") 85 | Logger.debug("Answer SDP for #{inspect(pc)}:\n#{answer.sdp}") 86 | 87 | {:ok, pc, pc_id, answer.sdp} 88 | else 89 | {:error, _res} = err -> 90 | Logger.info("Failed to complete negotiation for #{inspect(pc)}") 91 | terminate_pc(pc) 92 | err 93 | end 94 | end 95 | 96 | defp setup_transceivers(pc, direction) do 97 | if direction == :sendonly do 98 | stream_id = MediaStreamTrack.generate_stream_id() 99 | {:ok, _sender} = PeerConnection.add_track(pc, MediaStreamTrack.new(:audio, [stream_id])) 100 | {:ok, _sender} = PeerConnection.add_track(pc, MediaStreamTrack.new(:video, [stream_id])) 101 | end 102 | 103 | transceivers = PeerConnection.get_transceivers(pc) 104 | 105 | for %{id: id} <- transceivers do 106 | PeerConnection.set_transceiver_direction(pc, id, direction) 107 | end 108 | 109 | :ok 110 | end 111 | 112 | defp spawn_peer_connection() do 113 | pc_opts = 114 | (Application.fetch_env!(:broadcaster, :pc_config) ++ 115 | [ 116 | audio_codecs: @audio_codecs, 117 | video_codecs: @video_codecs, 118 | controlling_process: self() 119 | ]) 120 | |> Keyword.delete(:ice_transport_policy) 121 | 122 | child_spec = %{ 123 | id: PeerConnection, 124 | start: {PeerConnection, :start_link, [pc_opts, []]}, 125 | restart: :temporary 126 | } 127 | 128 | DynamicSupervisor.start_child(__MODULE__, child_spec) 129 | end 130 | 131 | defp gather_candidates(pc) do 132 | # we either wait for all of the candidates 133 | # or whatever we were able to gather in one second 134 | receive do 135 | {:ex_webrtc, ^pc, {:ice_gathering_state_change, :complete}} -> :ok 136 | after 137 | 1000 -> :ok 138 | end 139 | end 140 | 141 | defp generate_pc_id(), do: for(_ <- 1..10, into: "", do: <>) 142 | end 143 | -------------------------------------------------------------------------------- /broadcaster/lib/broadcaster_web.ex: -------------------------------------------------------------------------------- 1 | defmodule BroadcasterWeb 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 BroadcasterWeb, :controller 9 | use BroadcasterWeb, :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 | def static_paths, do: ~w(assets fonts images favicon.ico robots.txt) 21 | 22 | def router do 23 | quote do 24 | use Phoenix.Router, helpers: false 25 | 26 | # Import common connection and controller functions to use in pipelines 27 | import Plug.Conn 28 | import Phoenix.Controller 29 | import Phoenix.LiveView.Router 30 | end 31 | end 32 | 33 | def channel do 34 | quote do 35 | use Phoenix.Channel 36 | end 37 | end 38 | 39 | def controller do 40 | quote do 41 | use Phoenix.Controller, 42 | formats: [:html, :json], 43 | layouts: [html: BroadcasterWeb.Layouts] 44 | 45 | import Plug.Conn 46 | 47 | unquote(verified_routes()) 48 | end 49 | end 50 | 51 | def live_view do 52 | quote do 53 | use Phoenix.LiveView, 54 | layout: {BroadcasterWeb.Layouts, :app} 55 | 56 | unquote(html_helpers()) 57 | end 58 | end 59 | 60 | def live_component do 61 | quote do 62 | use Phoenix.LiveComponent 63 | 64 | unquote(html_helpers()) 65 | end 66 | end 67 | 68 | def html do 69 | quote do 70 | use Phoenix.Component 71 | 72 | # Import convenience functions from controllers 73 | import Phoenix.Controller, 74 | only: [get_csrf_token: 0, view_module: 1, view_template: 1] 75 | 76 | # Include general helpers for rendering HTML 77 | unquote(html_helpers()) 78 | end 79 | end 80 | 81 | defp html_helpers do 82 | quote do 83 | # HTML escaping functionality 84 | import Phoenix.HTML 85 | # Core UI components and translation 86 | import BroadcasterWeb.CoreComponents 87 | 88 | # Shortcut for generating JS commands 89 | alias Phoenix.LiveView.JS 90 | 91 | # Routes generation with the ~p sigil 92 | unquote(verified_routes()) 93 | end 94 | end 95 | 96 | def verified_routes do 97 | quote do 98 | use Phoenix.VerifiedRoutes, 99 | endpoint: BroadcasterWeb.Endpoint, 100 | router: BroadcasterWeb.Router, 101 | statics: BroadcasterWeb.static_paths() 102 | end 103 | end 104 | 105 | @doc """ 106 | When used, dispatch to the appropriate controller/live_view/etc. 107 | """ 108 | defmacro __using__(which) when is_atom(which) do 109 | apply(__MODULE__, which, []) 110 | end 111 | end 112 | -------------------------------------------------------------------------------- /broadcaster/lib/broadcaster_web/channels/channel.ex: -------------------------------------------------------------------------------- 1 | defmodule BroadcasterWeb.Channel do 2 | @moduledoc false 3 | 4 | use BroadcasterWeb, :channel 5 | 6 | alias BroadcasterWeb.{Endpoint, Presence} 7 | 8 | @chat_slow_mode_ms Application.compile_env!(:broadcaster, :chat_slow_mode_ms) 9 | @max_nickname_length 25 10 | @max_message_length 500 11 | 12 | @spec input_added(String.t()) :: :ok 13 | def input_added(id) do 14 | Endpoint.broadcast!("broadcaster:signaling", "input_added", %{id: id}) 15 | end 16 | 17 | @spec input_removed(String.t()) :: :ok 18 | def input_removed(id) do 19 | Endpoint.broadcast!("broadcaster:signaling", "input_removed", %{id: id}) 20 | end 21 | 22 | @impl true 23 | def join("broadcaster:signaling", _, socket) do 24 | msg = %{inputs: Broadcaster.Forwarder.input_ids()} 25 | {:ok, msg, socket} 26 | end 27 | 28 | @impl true 29 | def join("broadcaster:chat", _, socket) do 30 | send(self(), :after_join) 31 | {:ok, assign(socket, :nickname, nil)} 32 | end 33 | 34 | @impl true 35 | def handle_in("chat_msg", _, %{assigns: %{nickname: nil}} = socket) do 36 | {:noreply, socket} 37 | end 38 | 39 | @impl true 40 | def handle_in("chat_msg", %{"body" => body}, %{assigns: %{admin: true}} = socket) do 41 | broadcast_msg(body, socket) 42 | end 43 | 44 | @impl true 45 | def handle_in("chat_msg", %{"body" => body}, socket) do 46 | if System.monotonic_time(:millisecond) - socket.assigns.last_msg_time >= @chat_slow_mode_ms do 47 | broadcast_msg(body, socket) 48 | else 49 | {:noreply, socket} 50 | end 51 | end 52 | 53 | @impl true 54 | def handle_in("join_chat", payload, socket) do 55 | token = payload["token"] 56 | nickname = payload["nickname"] 57 | 58 | with {:token, true} <- {:token, validate_token(token)}, 59 | {:register, true} <- {:register, register(nickname)} do 60 | socket = 61 | socket 62 | |> assign(:nickname, nickname) 63 | |> assign(:msg_count, 0) 64 | |> assign(:last_msg_time, System.monotonic_time(:millisecond)) 65 | 66 | socket = 67 | if token != nil do 68 | assign(socket, :admin, true) 69 | else 70 | socket 71 | end 72 | 73 | :ok = push(socket, "join_chat_resp", %{"result" => "success"}) 74 | {:noreply, socket} 75 | else 76 | {:token, false} -> 77 | :ok = push(socket, "join_chat_resp", %{"result" => "error", "reason" => "unauthorized"}) 78 | {:noreply, socket} 79 | 80 | {:register, false} -> 81 | :ok = push(socket, "join_chat_resp", %{"result" => "error", "reason" => "name taken"}) 82 | {:noreply, socket} 83 | end 84 | end 85 | 86 | @impl true 87 | def handle_info(:after_join, socket) do 88 | {:ok, _} = Presence.track(socket, socket.assigns.user_id, %{}) 89 | push(socket, "presence_state", Presence.list(socket)) 90 | 91 | Broadcaster.ChatHistory.get() 92 | |> Enum.each(fn msg -> :ok = push(socket, "chat_msg", msg) end) 93 | 94 | {:noreply, socket} 95 | end 96 | 97 | defp register(nickname) do 98 | if String.length(nickname) <= @max_nickname_length do 99 | nickname 100 | |> String.trim() 101 | |> do_register() 102 | else 103 | :error 104 | end 105 | end 106 | 107 | defp do_register(""), do: :error 108 | 109 | defp do_register(nickname) do 110 | case Registry.register(Broadcaster.ChatNicknamesRegistry, nickname, nil) do 111 | {:ok, _} -> true 112 | {:error, _} -> false 113 | end 114 | end 115 | 116 | defp validate_token(nil), do: true 117 | 118 | defp validate_token(token) do 119 | case Phoenix.Token.verify(BroadcasterWeb.Endpoint, "admin", token, max_age: 86_400) do 120 | {:ok, _} -> true 121 | _ -> false 122 | end 123 | end 124 | 125 | defp broadcast_msg(body, socket) do 126 | body = String.slice(body, 0..(@max_message_length - 1)) 127 | 128 | msg = %{ 129 | body: body, 130 | nickname: socket.assigns.nickname, 131 | id: "#{socket.assigns.user_id}:#{socket.assigns.msg_count}", 132 | admin: Map.get(socket.assigns, :admin) 133 | } 134 | 135 | Broadcaster.ChatHistory.put(msg) 136 | broadcast!(socket, "chat_msg", msg) 137 | 138 | socket = 139 | assign(socket, 140 | msg_count: socket.assigns.msg_count + 1, 141 | last_msg_time: System.monotonic_time(:millisecond) 142 | ) 143 | 144 | {:noreply, socket} 145 | end 146 | end 147 | -------------------------------------------------------------------------------- /broadcaster/lib/broadcaster_web/channels/presence.ex: -------------------------------------------------------------------------------- 1 | defmodule BroadcasterWeb.Presence do 2 | @moduledoc """ 3 | Provides presence tracking to channels and processes. 4 | 5 | See the [`Phoenix.Presence`](https://hexdocs.pm/phoenix/Phoenix.Presence.html) 6 | docs for more details. 7 | """ 8 | use Phoenix.Presence, 9 | otp_app: :broadcaster, 10 | pubsub_server: Broadcaster.PubSub 11 | end 12 | -------------------------------------------------------------------------------- /broadcaster/lib/broadcaster_web/channels/user_socket.ex: -------------------------------------------------------------------------------- 1 | defmodule BroadcasterWeb.UserSocket do 2 | use Phoenix.Socket 3 | 4 | channel "broadcaster:*", BroadcasterWeb.Channel 5 | 6 | @impl true 7 | def connect(_params, socket, _connect_info) do 8 | {:ok, assign(socket, :user_id, generate_id())} 9 | end 10 | 11 | @impl true 12 | def id(socket), do: "user_socket:#{socket.assigns.user_id}" 13 | 14 | defp generate_id do 15 | 10 16 | |> :crypto.strong_rand_bytes() 17 | |> Base.url_encode64() 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /broadcaster/lib/broadcaster_web/components/layouts.ex: -------------------------------------------------------------------------------- 1 | defmodule BroadcasterWeb.Layouts do 2 | @moduledoc """ 3 | This module holds different layouts used by your application. 4 | 5 | See the `layouts` directory for all templates available. 6 | The "root" layout is a skeleton rendered as part of the 7 | application router. The "app" layout is set as the default 8 | layout on both `use BroadcasterWeb, :controller` and 9 | `use BroadcasterWeb, :live_view`. 10 | """ 11 | use BroadcasterWeb, :html 12 | 13 | embed_templates "layouts/*" 14 | end 15 | -------------------------------------------------------------------------------- /broadcaster/lib/broadcaster_web/components/layouts/app.html.heex: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 5 | 6 | 7 |
8 |
9 |
10 |
11 |

0

12 | <.icon name="hero-user-solid" /> 13 |
14 |
15 | 18 | 21 |
22 | 38 |
39 |
40 |
41 |
42 | 54 |
55 |
56 | <.flash_group flash={@flash} /> 57 | {@inner_content} 58 |
59 |
60 |
61 | {Broadcaster.Application.version()} 62 |
63 | -------------------------------------------------------------------------------- /broadcaster/lib/broadcaster_web/components/layouts/root.html.heex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | <.live_title suffix=" · Broadcaster"> 8 | {assigns[:page_title]} 9 | 10 | 11 | 13 | 14 | 18 | {@inner_content} 19 | 20 | 21 | -------------------------------------------------------------------------------- /broadcaster/lib/broadcaster_web/controllers/error_html.ex: -------------------------------------------------------------------------------- 1 | defmodule BroadcasterWeb.ErrorHTML do 2 | @moduledoc """ 3 | This module is invoked by your endpoint in case of errors on HTML requests. 4 | 5 | See config/config.exs. 6 | """ 7 | use BroadcasterWeb, :html 8 | 9 | # If you want to customize your error pages, 10 | # uncomment the embed_templates/1 call below 11 | # and add pages to the error directory: 12 | # 13 | # * lib/broadcaster_web/controllers/error_html/404.html.heex 14 | # * lib/broadcaster_web/controllers/error_html/500.html.heex 15 | # 16 | # embed_templates "error_html/*" 17 | 18 | # The default is to render a plain text page based on 19 | # the template name. For example, "404.html" becomes 20 | # "Not Found". 21 | def render(template, _assigns) do 22 | Phoenix.Controller.status_message_from_template(template) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /broadcaster/lib/broadcaster_web/controllers/error_json.ex: -------------------------------------------------------------------------------- 1 | defmodule BroadcasterWeb.ErrorJSON do 2 | @moduledoc """ 3 | This module is invoked by your endpoint in case of errors on JSON requests. 4 | 5 | See config/config.exs. 6 | """ 7 | 8 | # If you want to customize a particular status code, 9 | # you may add your own clauses, such as: 10 | # 11 | # def render("500.json", _assigns) do 12 | # %{errors: %{detail: "Internal Server Error"}} 13 | # end 14 | 15 | # By default, Phoenix returns the status message from 16 | # the template name. For example, "404.json" becomes 17 | # "Not Found". 18 | def render(template, _assigns) do 19 | %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}} 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /broadcaster/lib/broadcaster_web/controllers/page_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule BroadcasterWeb.PageController do 2 | use BroadcasterWeb, :controller 3 | 4 | def home(conn, _params) do 5 | title = Application.get_env(:broadcaster, :title, "") |> to_html() 6 | description = Application.get_env(:broadcaster, :description, "") |> to_html() 7 | 8 | render(conn, :home, 9 | page_title: "Home", 10 | title: title, 11 | description: description 12 | ) 13 | end 14 | 15 | def panel(conn, _params) do 16 | render(conn, :panel, page_title: "Panel") 17 | end 18 | 19 | def delete_chat_message(conn, %{"id" => id}) do 20 | Broadcaster.ChatHistory.delete(id) 21 | BroadcasterWeb.Endpoint.broadcast!("broadcaster:chat", "delete_chat_msg", %{id: id}) 22 | send_resp(conn, 200, "") 23 | end 24 | 25 | def config_stream(conn, _params) do 26 | {:ok, body, conn} = Plug.Conn.read_body(conn) 27 | %{"title" => title, "description" => description} = Jason.decode!(body) 28 | Application.put_env(:broadcaster, :title, title) 29 | Application.put_env(:broadcaster, :description, description) 30 | send_resp(conn, 200, "") 31 | end 32 | 33 | def get_admin_chat_token(conn, _params) do 34 | token = Phoenix.Token.sign(BroadcasterWeb.Endpoint, "admin", <<>>) 35 | send_resp(conn, 200, Jason.encode!(%{"token" => token})) 36 | end 37 | 38 | defp to_html(markdown) do 39 | markdown 40 | |> String.trim() 41 | |> Earmark.as_html!() 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /broadcaster/lib/broadcaster_web/controllers/page_html.ex: -------------------------------------------------------------------------------- 1 | defmodule BroadcasterWeb.PageHTML do 2 | @moduledoc """ 3 | This module contains pages rendered by PageController. 4 | 5 | See the `page_html` directory for all templates available. 6 | """ 7 | use BroadcasterWeb, :html 8 | 9 | embed_templates "page_html/*" 10 | end 11 | -------------------------------------------------------------------------------- /broadcaster/lib/broadcaster_web/controllers/page_html/home.html.heex: -------------------------------------------------------------------------------- 1 |
2 |
3 | 6 |
7 |
8 |
9 | {raw(@title)} 10 | 11 | {raw(@description)} 12 | 13 |
14 |
15 | 54 | 71 |
72 | -------------------------------------------------------------------------------- /broadcaster/lib/broadcaster_web/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule BroadcasterWeb.Endpoint do 2 | use Phoenix.Endpoint, otp_app: :broadcaster 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: "_broadcaster_key", 10 | signing_salt: "BnOeka5n", 11 | same_site: "Lax" 12 | ] 13 | 14 | socket "/socket", BroadcasterWeb.UserSocket, 15 | websocket: true, 16 | longpoll: false 17 | 18 | socket "/live", Phoenix.LiveView.Socket, 19 | websocket: [connect_info: [session: @session_options]], 20 | longpoll: [connect_info: [session: @session_options]] 21 | 22 | plug Corsica, 23 | origins: "*", 24 | allow_headers: :all, 25 | allow_methods: :all, 26 | expose_headers: BroadcasterWeb.Router.cors_expose_headers() 27 | 28 | # Serve at "/" the static files from "priv/static" directory. 29 | # 30 | # You should set gzip to true if you are running phx.digest 31 | # when deploying your static files in production. 32 | plug Plug.Static, 33 | at: "/", 34 | from: :broadcaster, 35 | gzip: false, 36 | only: BroadcasterWeb.static_paths() 37 | 38 | # Code reloading can be explicitly enabled under the 39 | # :code_reloader configuration of your endpoint. 40 | if code_reloading? do 41 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket 42 | plug Phoenix.LiveReloader 43 | plug Phoenix.CodeReloader 44 | end 45 | 46 | plug Phoenix.LiveDashboard.RequestLogger, 47 | param_key: "request_logger", 48 | cookie_key: "request_logger" 49 | 50 | plug Plug.RequestId 51 | plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] 52 | 53 | plug Plug.Parsers, 54 | parsers: [:urlencoded, :multipart, :json], 55 | pass: ["*/*"], 56 | json_decoder: Phoenix.json_library() 57 | 58 | plug Plug.MethodOverride 59 | plug Plug.Head 60 | plug Plug.Session, @session_options 61 | plug BroadcasterWeb.Router 62 | end 63 | -------------------------------------------------------------------------------- /broadcaster/lib/broadcaster_web/router.ex: -------------------------------------------------------------------------------- 1 | defmodule BroadcasterWeb.Router do 2 | use BroadcasterWeb, :router 3 | 4 | import Phoenix.LiveDashboard.Router 5 | 6 | pipeline :browser do 7 | plug :accepts, ["html"] 8 | plug :fetch_session 9 | plug :fetch_live_flash 10 | plug :put_root_layout, html: {BroadcasterWeb.Layouts, :root} 11 | plug :protect_from_forgery 12 | plug :put_secure_browser_headers 13 | end 14 | 15 | pipeline :auth do 16 | plug :admin_auth 17 | end 18 | 19 | scope "/", BroadcasterWeb do 20 | pipe_through :browser 21 | 22 | get "/", PageController, :home 23 | end 24 | 25 | scope "/admin", BroadcasterWeb do 26 | pipe_through :auth 27 | pipe_through :browser 28 | 29 | get "/panel", PageController, :panel 30 | 31 | live_dashboard "/dashboard", 32 | metrics: BroadcasterWeb.Telemetry, 33 | additional_pages: [exwebrtc: ExWebRTCDashboard] 34 | end 35 | 36 | scope "/api", BroadcasterWeb do 37 | post "/whip", MediaController, :whip 38 | post "/whep", MediaController, :whep 39 | 40 | scope "/resource/:resource_id" do 41 | patch "/", MediaController, :ice_candidate 42 | delete "/", MediaController, :remove_pc 43 | get "/sse/event-stream", MediaController, :event_stream 44 | post "/sse", MediaController, :sse 45 | post "/layer", MediaController, :layer 46 | end 47 | 48 | scope "/admin" do 49 | pipe_through :auth 50 | 51 | delete "/chat/:id", PageController, :delete_chat_message 52 | post "/stream", PageController, :config_stream 53 | get "/chat-token", PageController, :get_admin_chat_token 54 | end 55 | end 56 | 57 | def cors_expose_headers, do: BroadcasterWeb.MediaController.cors_expose_headers() 58 | 59 | defp admin_auth(conn, _opts) do 60 | username = Application.fetch_env!(:broadcaster, :admin_username) 61 | password = Application.fetch_env!(:broadcaster, :admin_password) 62 | Plug.BasicAuth.basic_auth(conn, username: username, password: password) 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /broadcaster/lib/broadcaster_web/telemetry.ex: -------------------------------------------------------------------------------- 1 | defmodule BroadcasterWeb.Telemetry do 2 | use Supervisor 3 | import Telemetry.Metrics 4 | 5 | def start_link(arg) do 6 | Supervisor.start_link(__MODULE__, arg, name: __MODULE__) 7 | end 8 | 9 | @impl true 10 | def init(_arg) do 11 | children = [ 12 | # Telemetry poller will execute the given period measurements 13 | # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics 14 | {:telemetry_poller, measurements: periodic_measurements(), period: 10_000} 15 | # Add reporters as children of your supervision tree. 16 | # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()} 17 | ] 18 | 19 | Supervisor.init(children, strategy: :one_for_one) 20 | end 21 | 22 | def metrics do 23 | [ 24 | # Phoenix Metrics 25 | summary("phoenix.endpoint.start.system_time", 26 | unit: {:native, :millisecond} 27 | ), 28 | summary("phoenix.endpoint.stop.duration", 29 | unit: {:native, :millisecond} 30 | ), 31 | summary("phoenix.router_dispatch.start.system_time", 32 | tags: [:route], 33 | unit: {:native, :millisecond} 34 | ), 35 | summary("phoenix.router_dispatch.exception.duration", 36 | tags: [:route], 37 | unit: {:native, :millisecond} 38 | ), 39 | summary("phoenix.router_dispatch.stop.duration", 40 | tags: [:route], 41 | unit: {:native, :millisecond} 42 | ), 43 | summary("phoenix.socket_connected.duration", 44 | unit: {:native, :millisecond} 45 | ), 46 | summary("phoenix.channel_joined.duration", 47 | unit: {:native, :millisecond} 48 | ), 49 | summary("phoenix.channel_handled_in.duration", 50 | tags: [:event], 51 | unit: {:native, :millisecond} 52 | ), 53 | 54 | # VM Metrics 55 | summary("vm.memory.total", unit: {:byte, :kilobyte}), 56 | summary("vm.total_run_queue_lengths.total"), 57 | summary("vm.total_run_queue_lengths.cpu"), 58 | summary("vm.total_run_queue_lengths.io") 59 | ] 60 | end 61 | 62 | defp periodic_measurements do 63 | [ 64 | # A module, function and arguments to be invoked periodically. 65 | # This function must call :telemetry.execute/3 and a metric must be added above. 66 | # {BroadcasterWeb, :count_users, []} 67 | ] 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /broadcaster/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Broadcaster.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :broadcaster, 7 | version: "0.9.0", 8 | elixir: "~> 1.14", 9 | elixirc_paths: elixirc_paths(Mix.env()), 10 | start_permanent: Mix.env() == :prod, 11 | aliases: aliases(), 12 | deps: deps(), 13 | 14 | # dialyzer 15 | dialyzer: [ 16 | plt_local_path: "_dialyzer", 17 | plt_core_path: "_dialyzer" 18 | ] 19 | ] 20 | end 21 | 22 | # Configuration for the OTP application. 23 | # 24 | # Type `mix help compile.app` for more information. 25 | def application do 26 | [ 27 | mod: {Broadcaster.Application, []}, 28 | extra_applications: [:logger, :runtime_tools] 29 | ] 30 | end 31 | 32 | # Specifies which paths to compile per environment. 33 | defp elixirc_paths(:test), do: ["lib", "test/support"] 34 | defp elixirc_paths(_), do: ["lib"] 35 | 36 | # Specifies your project dependencies. 37 | # 38 | # Type `mix help deps` for examples and options. 39 | defp deps do 40 | [ 41 | {:phoenix, "~> 1.7.12"}, 42 | {:phoenix_html, "~> 4.0"}, 43 | {:phoenix_live_reload, "~> 1.2", only: :dev}, 44 | {:phoenix_live_view, "~> 1.0"}, 45 | {:floki, ">= 0.30.0", only: :test}, 46 | {:phoenix_live_dashboard, "~> 0.8.3"}, 47 | {:esbuild, "~> 0.8", runtime: Mix.env() == :dev}, 48 | {:tailwind, "~> 0.2", runtime: Mix.env() == :dev}, 49 | {:heroicons, 50 | github: "tailwindlabs/heroicons", 51 | tag: "v2.1.1", 52 | sparse: "optimized", 53 | app: false, 54 | compile: false, 55 | depth: 1}, 56 | {:telemetry_metrics, "~> 1.0"}, 57 | {:telemetry_poller, "~> 1.0"}, 58 | {:jason, "~> 1.2"}, 59 | {:bandit, "~> 1.2"}, 60 | {:corsica, "~> 2.1.3"}, 61 | {:ex_webrtc, "~> 0.8.0"}, 62 | {:ex_webrtc_dashboard, "~> 0.8.0"}, 63 | {:earmark, "~> 1.4"}, 64 | {:libcluster, "~> 3.4"}, 65 | {:syn, "~> 3.3"}, 66 | 67 | # Dialyzer and credo 68 | {:dialyxir, ">= 0.0.0", only: :dev, runtime: false}, 69 | {:credo, ">= 0.0.0", only: :dev, runtime: false} 70 | ] 71 | end 72 | 73 | # Aliases are shortcuts or tasks specific to the current project. 74 | # For example, to install project dependencies and perform other setup tasks, run: 75 | # 76 | # $ mix setup 77 | # 78 | # See the documentation for `Mix` for more info on aliases. 79 | defp aliases do 80 | [ 81 | setup: ["deps.get", "assets.setup", "assets.build"], 82 | "assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"], 83 | "assets.build": ["tailwind broadcaster", "esbuild broadcaster"], 84 | "assets.deploy": [ 85 | "tailwind broadcaster --minify", 86 | "esbuild broadcaster --minify", 87 | "phx.digest" 88 | ], 89 | "assets.format": &lint_and_format_assets/1, 90 | "assets.check": &check_assets/1 91 | ] 92 | end 93 | 94 | defp lint_and_format_assets(_args) do 95 | with {_, 0} <- execute_npm_command(["ci"]), 96 | {_, 0} <- execute_npm_command(["run", "lint"]), 97 | {_, 0} <- execute_npm_command(["run", "format"]) do 98 | :ok 99 | else 100 | {cmd, rc} -> 101 | Mix.shell().error("npm command `#{Enum.join(cmd, " ")}` failed with code #{rc}") 102 | exit({:shutdown, rc}) 103 | end 104 | end 105 | 106 | defp check_assets(_args) do 107 | with {_, 0} <- execute_npm_command(["ci"]), 108 | {_, 0} <- execute_npm_command(["run", "check"]) do 109 | :ok 110 | else 111 | {cmd, rc} -> 112 | Mix.shell().error("npm command `#{Enum.join(cmd, " ")}` failed with code #{rc}") 113 | exit({:shutdown, rc}) 114 | end 115 | end 116 | 117 | defp execute_npm_command(command) do 118 | {_stream, rc} = System.cmd("npm", ["--prefix=assets"] ++ command, into: IO.stream()) 119 | {command, rc} 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /broadcaster/priv/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elixir-webrtc/apps/4502fee12b35b1e9e0b64d613dae70b63a07e78d/broadcaster/priv/static/favicon.ico -------------------------------------------------------------------------------- /broadcaster/priv/static/robots.txt: -------------------------------------------------------------------------------- 1 | # See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | # 3 | # To ban all spiders from the entire site uncomment the next two lines: 4 | # User-agent: * 5 | # Disallow: / 6 | -------------------------------------------------------------------------------- /broadcaster/rel/overlays/bin/server: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -eu 3 | 4 | cd -P -- "$(dirname -- "$0")" 5 | 6 | if [ "${DISTRIBUTION_MODE-}" = "k8s" ]; then 7 | export RELEASE_DISTRIBUTION=name 8 | export RELEASE_NODE=broadcaster@${POD_IP} 9 | fi 10 | 11 | PHX_SERVER=true exec ./broadcaster start 12 | -------------------------------------------------------------------------------- /broadcaster/rel/overlays/bin/server.bat: -------------------------------------------------------------------------------- 1 | if "%DISTRIBUTION_MODE%"=="k8s" ( 2 | set RELEASE_DISTRIBUTION=name 3 | set RELEASE_NODE="broadcaster@%POD_IP%" 4 | ) 5 | 6 | set PHX_SERVER=true 7 | call "%~dp0\broadcaster" start 8 | -------------------------------------------------------------------------------- /broadcaster/test/broadcaster_web/controllers/error_html_test.exs: -------------------------------------------------------------------------------- 1 | defmodule BroadcasterWeb.ErrorHTMLTest do 2 | use BroadcasterWeb.ConnCase, async: true 3 | 4 | # Bring render_to_string/4 for testing custom views 5 | import Phoenix.Template 6 | 7 | test "renders 404.html" do 8 | assert render_to_string(BroadcasterWeb.ErrorHTML, "404", "html", []) == "Not Found" 9 | end 10 | 11 | test "renders 500.html" do 12 | assert render_to_string(BroadcasterWeb.ErrorHTML, "500", "html", []) == 13 | "Internal Server Error" 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /broadcaster/test/broadcaster_web/controllers/error_json_test.exs: -------------------------------------------------------------------------------- 1 | defmodule BroadcasterWeb.ErrorJSONTest do 2 | use BroadcasterWeb.ConnCase, async: true 3 | 4 | test "renders 404" do 5 | assert BroadcasterWeb.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}} 6 | end 7 | 8 | test "renders 500" do 9 | assert BroadcasterWeb.ErrorJSON.render("500.json", %{}) == 10 | %{errors: %{detail: "Internal Server Error"}} 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /broadcaster/test/support/channel_case.ex: -------------------------------------------------------------------------------- 1 | defmodule BroadcasterWeb.ChannelCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | channel tests. 5 | 6 | Such tests rely on `Phoenix.ChannelTest` 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 BroadcasterWeb.ChannelCase, 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 | # Import conveniences for testing with channels 23 | import Phoenix.ChannelTest 24 | import BroadcasterWeb.ChannelCase 25 | 26 | # The default endpoint for testing 27 | @endpoint BroadcasterWeb.Endpoint 28 | end 29 | end 30 | 31 | setup _tags do 32 | :ok 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /broadcaster/test/support/conn_case.ex: -------------------------------------------------------------------------------- 1 | defmodule BroadcasterWeb.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 BroadcasterWeb.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 BroadcasterWeb.Endpoint 24 | 25 | use BroadcasterWeb, :verified_routes 26 | 27 | # Import conveniences for testing with connections 28 | import Plug.Conn 29 | import Phoenix.ConnTest 30 | import BroadcasterWeb.ConnCase 31 | end 32 | end 33 | 34 | setup _tags do 35 | {:ok, conn: Phoenix.ConnTest.build_conn()} 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /broadcaster/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /nexus/.dockerignore: -------------------------------------------------------------------------------- 1 | # This file excludes paths from the Docker build context. 2 | # 3 | # By default, Docker's build context includes all files (and folders) in the 4 | # current directory. Even if a file isn't copied into the container it is still sent to 5 | # the Docker daemon. 6 | # 7 | # There are multiple reasons to exclude files from the build context: 8 | # 9 | # 1. Prevent nested folders from being copied into the container (ex: exclude 10 | # /assets/node_modules when copying /assets) 11 | # 2. Reduce the size of the build context and improve build time (ex. /build, /deps, /doc) 12 | # 3. Avoid sending files containing sensitive information 13 | # 14 | # More information on using .dockerignore is available here: 15 | # https://docs.docker.com/engine/reference/builder/#dockerignore-file 16 | 17 | .dockerignore 18 | 19 | # Ignore git, but keep git HEAD and refs to access current commit hash if needed: 20 | # 21 | # $ cat .git/HEAD | awk '{print ".git/"$2}' | xargs cat 22 | # d0b8727759e1e0e7aa3d41707d12376e373d5ecc 23 | .git 24 | !.git/HEAD 25 | !.git/refs 26 | 27 | # Common development/test artifacts 28 | /cover/ 29 | /doc/ 30 | /test/ 31 | /tmp/ 32 | .elixir_ls 33 | 34 | # Mix artifacts 35 | /_build/ 36 | /deps/ 37 | *.ez 38 | 39 | # Generated on crash by the VM 40 | erl_crash.dump 41 | 42 | # Static artifacts - These should be fetched and built inside the Docker image 43 | /assets/node_modules/ 44 | /priv/static/assets/ 45 | /priv/static/cache_manifest.json 46 | -------------------------------------------------------------------------------- /nexus/.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:phoenix], 3 | plugins: [Phoenix.LiveView.HTMLFormatter], 4 | inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}"] 5 | ] 6 | -------------------------------------------------------------------------------- /nexus/.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 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Temporary files, for example, from tests. 23 | /tmp/ 24 | 25 | # Ignore package tarball (built via "mix hex.build"). 26 | broadcaster-*.tar 27 | 28 | # Ignore assets that are produced by build tools. 29 | /priv/static/assets/ 30 | 31 | # Ignore digested assets cache. 32 | /priv/static/cache_manifest.json 33 | 34 | # In case you use Node.js/npm, you want to ignore these. 35 | npm-debug.log 36 | /assets/node_modules/ 37 | 38 | /_dialyzer/ 39 | -------------------------------------------------------------------------------- /nexus/Dockerfile: -------------------------------------------------------------------------------- 1 | # Find eligible builder and runner images on Docker Hub. We use Ubuntu/Debian 2 | # instead of Alpine to avoid DNS resolution issues in production. 3 | # 4 | # https://hub.docker.com/r/hexpm/elixir/tags?page=1&name=ubuntu 5 | # https://hub.docker.com/_/ubuntu?tab=tags 6 | # 7 | # This file is based on these images: 8 | # 9 | # - https://hub.docker.com/r/hexpm/elixir/tags - for the build image 10 | # - https://hub.docker.com/_/debian?tab=tags&page=1&name=bullseye-20231009-slim - for the release image 11 | # - https://pkgs.org/ - resource for finding needed packages 12 | # - Ex: hexpm/elixir:1.16.0-erlang-26.2.1-debian-bullseye-20231009-slim 13 | # 14 | ARG ELIXIR_VERSION=1.17.2 15 | ARG OTP_VERSION=27.0.1 16 | ARG DEBIAN_VERSION=bookworm-20240701-slim 17 | 18 | ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}" 19 | ARG RUNNER_IMAGE="debian:${DEBIAN_VERSION}" 20 | 21 | FROM ${BUILDER_IMAGE} AS builder 22 | 23 | # install build dependencies 24 | RUN apt-get update -y && apt-get install -y build-essential git pkg-config libssl-dev \ 25 | && apt-get clean && rm -f /var/lib/apt/lists/*_* 26 | 27 | # prepare build dir 28 | WORKDIR /app 29 | 30 | # install hex + rebar 31 | RUN mix local.hex --force && \ 32 | mix local.rebar --force 33 | 34 | # set build ENV 35 | ENV MIX_ENV="prod" 36 | 37 | # install mix dependencies 38 | COPY mix.exs mix.lock ./ 39 | RUN mix deps.get --only $MIX_ENV 40 | RUN mkdir config 41 | 42 | # copy compile-time config files before we compile dependencies 43 | # to ensure any relevant config change will trigger the dependencies 44 | # to be re-compiled. 45 | COPY config/config.exs config/${MIX_ENV}.exs config/ 46 | RUN mix deps.compile 47 | 48 | COPY priv priv 49 | 50 | COPY lib lib 51 | 52 | COPY assets assets 53 | 54 | # compile assets 55 | RUN mix assets.deploy 56 | 57 | # Compile the release 58 | RUN mix compile 59 | 60 | # Changes to config/runtime.exs don't require recompiling the code 61 | COPY config/runtime.exs config/ 62 | 63 | COPY rel rel 64 | RUN mix release 65 | 66 | # start a new build stage so that the final image will only contain 67 | # the compiled release and other runtime necessities 68 | FROM ${RUNNER_IMAGE} 69 | 70 | RUN apt-get update -y && \ 71 | apt-get install -y libstdc++6 openssl libncurses5 locales ca-certificates \ 72 | && apt-get clean && rm -f /var/lib/apt/lists/*_* 73 | 74 | # Set the locale 75 | RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen 76 | 77 | ENV LANG=en_US.UTF-8 78 | ENV LANGUAGE=en_US:en 79 | ENV LC_ALL=en_US.UTF-8 80 | 81 | WORKDIR "/app" 82 | RUN chown nobody /app 83 | 84 | # set runner ENV 85 | ENV MIX_ENV="prod" 86 | 87 | # Only copy the final release from the build stage 88 | COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/nexus ./ 89 | 90 | USER nobody 91 | 92 | # If using an environment that doesn't automatically reap zombie processes, it is 93 | # advised to add an init process such as tini via `apt-get install` 94 | # above and adding an entrypoint. See https://github.com/krallin/tini for details 95 | # ENTRYPOINT ["/tini", "--"] 96 | 97 | CMD ["/app/bin/server"] 98 | -------------------------------------------------------------------------------- /nexus/README.md: -------------------------------------------------------------------------------- 1 | # Nexus 2 | 3 | A multimedia relay server (SFU) facilitating video conference calls with a simple browser front-end. 4 | 5 | ## Usage 6 | 7 | Clone this repo and change the working directory to `apps/nexus`. 8 | 9 | Fetch dependencies and run the app: 10 | 11 | ```shell 12 | mix setup 13 | mix phx.server 14 | ``` 15 | 16 | Now you can visit [`localhost:4000`](http://localhost:4000) from your browser. 17 | If you join from another tab/browser on the same device, you should see two streams. 18 | 19 | ## Running with Docker 20 | 21 | You can also run Nexus using Docker. 22 | 23 | Build an image (or use `ghcr.io/elixir-webrtc/apps/nexus:latest`): 24 | 25 | ``` 26 | docker build -t nexus . 27 | ``` 28 | 29 | and run: 30 | 31 | ``` 32 | docker run \ 33 | -e SECRET_KEY_BASE="secret" \ 34 | -e PHX_HOST=localhost \ 35 | -e ADMIN_USERNAME=admin \ 36 | -e ADMIN_PASSWORD=admin \ 37 | --network host \ 38 | nexus 39 | ``` 40 | 41 | Note that secret has to be at least 64 bytes long. 42 | You can generate one with `mix phx.gen.secret` or `head -c64 /dev/urandom | base64`. 43 | 44 | If you are running on MacOS, instead of using `--network host` option, you have to explicitly publish ports: 45 | 46 | ``` 47 | docker run \ 48 | -e SECRET_KEY_BASE="secret" \ 49 | -e PHX_HOST=localhost \ 50 | -e ADMIN_USERNAME=admin \ 51 | -e ADMIN_PASSWORD=admin \ 52 | -p 4000:4000 \ 53 | -p 50000-50010/udp \ 54 | nexus 55 | ``` 56 | 57 | ## Caveats 58 | 59 | Seeing as access to video and audio devices requires the browser to be 60 | in a [secure context](https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts), 61 | if you want to connect from another device on your network, you have to set up HTTPS access to the server. 62 | Refer to the comments in `config/dev.exs` for more info. 63 | 64 | At the moment, there is no way to choose the devices to be used or join without sharing media. 65 | -------------------------------------------------------------------------------- /nexus/assets/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "es5", 4 | "tabWidth": 2, 5 | "useTabs": false, 6 | "semi": true, 7 | "printWidth": 80 8 | } 9 | -------------------------------------------------------------------------------- /nexus/assets/css/app.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss/base'; 2 | @import 'tailwindcss/components'; 3 | @import 'tailwindcss/utilities'; 4 | 5 | /* flip the preview horizontally */ 6 | #videoplayer-local { 7 | -moz-transform: scale(-1, 1); 8 | -webkit-transform: scale(-1, 1); 9 | -o-transform: scale(-1, 1); 10 | transform: scale(-1, 1); 11 | filter: FlipH; 12 | } 13 | -------------------------------------------------------------------------------- /nexus/assets/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import globals from "globals"; 2 | import pluginJs from "@eslint/js"; 3 | import prettierPlugin from 'eslint-plugin-prettier'; 4 | import prettierConfig from 'eslint-config-prettier'; 5 | 6 | export default [ 7 | { 8 | files: ['**/*.js'], 9 | languageOptions: { 10 | ecmaVersion: 2021, 11 | sourceType: "module", 12 | globals: globals.browser 13 | }, 14 | plugins: { 15 | prettier: prettierPlugin 16 | }, 17 | rules: { 18 | "prettier/prettier": "error", 19 | "no-unused-vars": [ 20 | "error", 21 | { 22 | "argsIgnorePattern": "^_", 23 | "varsIgnorePattern": "^_" 24 | } 25 | ] 26 | }, 27 | settings: { 28 | prettier: prettierConfig 29 | } 30 | }, 31 | pluginJs.configs.recommended, 32 | ]; 33 | -------------------------------------------------------------------------------- /nexus/assets/js/app.js: -------------------------------------------------------------------------------- 1 | // If you want to use Phoenix channels, run `mix help phx.gen.channel` 2 | // to get started and then uncomment the line below. 3 | // import "./user_socket.js" 4 | 5 | // You can include dependencies in two ways. 6 | // 7 | // The simplest option is to put them in assets/vendor and 8 | // import them using relative paths: 9 | // 10 | // import "../vendor/some-package.js" 11 | // 12 | // Alternatively, you can `npm install some-package --prefix assets` and import 13 | // them using a path starting with the package name: 14 | // 15 | // import "some-package" 16 | // 17 | 18 | // Include phoenix_html to handle method=PUT/DELETE in forms and buttons. 19 | import 'phoenix_html'; 20 | // Establish Phoenix Socket and LiveView configuration. 21 | import { Socket } from 'phoenix'; 22 | import { LiveSocket } from 'phoenix_live_view'; 23 | import topbar from '../vendor/topbar'; 24 | 25 | import { Home } from './home.js'; 26 | 27 | let Hooks = {}; 28 | Hooks.Home = Home; 29 | 30 | let csrfToken = document 31 | .querySelector("meta[name='csrf-token']") 32 | .getAttribute('content'); 33 | let liveSocket = new LiveSocket('/live', Socket, { 34 | longPollFallbackMs: 2500, 35 | params: { _csrf_token: csrfToken }, 36 | hooks: Hooks, 37 | }); 38 | 39 | // Show progress bar on live navigation and form submits 40 | topbar.config({ barColors: { 0: '#29d' }, shadowColor: 'rgba(0, 0, 0, .3)' }); 41 | window.addEventListener('phx:page-loading-start', (_) => topbar.show(300)); 42 | window.addEventListener('phx:page-loading-stop', (_) => topbar.hide()); 43 | 44 | // connect if there are any LiveViews on the page 45 | liveSocket.connect(); 46 | 47 | // expose liveSocket on window for web console debug logs and latency simulation: 48 | // >> liveSocket.enableDebug() 49 | // >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session 50 | // >> liveSocket.disableLatencySim() 51 | window.liveSocket = liveSocket; 52 | -------------------------------------------------------------------------------- /nexus/assets/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "assets", 3 | "version": "0.1.0", 4 | "description": "", 5 | "main": "", 6 | "scripts": { 7 | "lint": "eslint 'js/**/*.js' --fix", 8 | "format": "prettier --write 'js/**/*.js' 'css/**/*.css'", 9 | "check": "prettier --check 'js/**/*.js' 'css/**/*.css'" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "Apache-2.0", 14 | "devDependencies": { 15 | "@eslint/eslintrc": "^3.1.0", 16 | "@eslint/js": "^9.7.0", 17 | "eslint": "^9.7.0", 18 | "eslint-config-prettier": "^9.1.0", 19 | "eslint-plugin-prettier": "^5.1.3", 20 | "globals": "^15.8.0", 21 | "prettier": "^3.3.3" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /nexus/assets/tailwind.config.js: -------------------------------------------------------------------------------- 1 | // See the Tailwind configuration guide for advanced usage 2 | // https://tailwindcss.com/docs/configuration 3 | 4 | const plugin = require("tailwindcss/plugin") 5 | const fs = require("fs") 6 | const path = require("path") 7 | 8 | module.exports = { 9 | content: [ 10 | "./js/**/*.js", 11 | "../lib/nexus_web.ex", 12 | "../lib/nexus_web/**/*.*ex" 13 | ], 14 | theme: { 15 | extend: { 16 | colors: { 17 | brand: "#4339AC", 18 | } 19 | }, 20 | }, 21 | plugins: [ 22 | require("@tailwindcss/forms"), 23 | // Allows prefixing tailwind classes with LiveView classes to add rules 24 | // only when LiveView classes are applied, for example: 25 | // 26 | //
27 | // 28 | plugin(({addVariant}) => addVariant("phx-no-feedback", [".phx-no-feedback&", ".phx-no-feedback &"])), 29 | plugin(({addVariant}) => addVariant("phx-click-loading", [".phx-click-loading&", ".phx-click-loading &"])), 30 | plugin(({addVariant}) => addVariant("phx-submit-loading", [".phx-submit-loading&", ".phx-submit-loading &"])), 31 | plugin(({addVariant}) => addVariant("phx-change-loading", [".phx-change-loading&", ".phx-change-loading &"])), 32 | 33 | // Embeds Heroicons (https://heroicons.com) into your app.css bundle 34 | // See your `CoreComponents.icon/1` for more information. 35 | // 36 | plugin(function({matchComponents, theme}) { 37 | let iconsDir = path.join(__dirname, "../deps/heroicons/optimized") 38 | let values = {} 39 | let icons = [ 40 | ["", "/24/outline"], 41 | ["-solid", "/24/solid"], 42 | ["-mini", "/20/solid"], 43 | ["-micro", "/16/solid"] 44 | ] 45 | icons.forEach(([suffix, dir]) => { 46 | fs.readdirSync(path.join(iconsDir, dir)).forEach(file => { 47 | let name = path.basename(file, ".svg") + suffix 48 | values[name] = {name, fullPath: path.join(iconsDir, dir, file)} 49 | }) 50 | }) 51 | matchComponents({ 52 | "hero": ({name, fullPath}) => { 53 | let content = fs.readFileSync(fullPath).toString().replace(/\r?\n|\r/g, "") 54 | let size = theme("spacing.6") 55 | if (name.endsWith("-mini")) { 56 | size = theme("spacing.5") 57 | } else if (name.endsWith("-micro")) { 58 | size = theme("spacing.4") 59 | } 60 | return { 61 | [`--hero-${name}`]: `url('data:image/svg+xml;utf8,${content}')`, 62 | "-webkit-mask": `var(--hero-${name})`, 63 | "mask": `var(--hero-${name})`, 64 | "mask-repeat": "no-repeat", 65 | "background-color": "currentColor", 66 | "vertical-align": "middle", 67 | "display": "inline-block", 68 | "width": size, 69 | "height": size 70 | } 71 | } 72 | }, {values}) 73 | }) 74 | ] 75 | } 76 | -------------------------------------------------------------------------------- /nexus/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 | config :nexus, 11 | generators: [timestamp_type: :utc_datetime] 12 | 13 | # Configures the endpoint 14 | config :nexus, NexusWeb.Endpoint, 15 | url: [host: "localhost"], 16 | adapter: Bandit.PhoenixAdapter, 17 | render_errors: [ 18 | formats: [html: NexusWeb.ErrorHTML, json: NexusWeb.ErrorJSON], 19 | layout: false 20 | ], 21 | pubsub_server: Nexus.PubSub, 22 | live_view: [signing_salt: "/ONXsVON"] 23 | 24 | # Configure esbuild (the version is required) 25 | config :esbuild, 26 | version: "0.17.11", 27 | nexus: [ 28 | args: 29 | ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*), 30 | cd: Path.expand("../assets", __DIR__), 31 | env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} 32 | ] 33 | 34 | # Configure tailwind (the version is required) 35 | config :tailwind, 36 | version: "3.4.0", 37 | nexus: [ 38 | args: ~w( 39 | --config=tailwind.config.js 40 | --input=css/app.css 41 | --output=../priv/static/assets/app.css 42 | ), 43 | cd: Path.expand("../assets", __DIR__) 44 | ] 45 | 46 | # Configures Elixir's Logger 47 | config :logger, :console, 48 | format: "$time $metadata[$level] $message\n", 49 | metadata: [:request_id], 50 | level: :debug 51 | 52 | # Use Jason for JSON parsing in Phoenix 53 | config :phoenix, :json_library, Jason 54 | 55 | config :nexus, 56 | admin_username: "admin", 57 | admin_password: "admin" 58 | 59 | # Import environment specific config. This must remain at the bottom 60 | # of this file so it overrides the configuration defined above. 61 | import_config "#{config_env()}.exs" 62 | -------------------------------------------------------------------------------- /nexus/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 can use it 8 | # to bundle .js and .css sources. 9 | config :nexus, NexusWeb.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: {0, 0, 0, 0}, port: System.get_env("PORT", "4000") |> String.to_integer()], 13 | check_origin: false, 14 | code_reloader: true, 15 | debug_errors: true, 16 | secret_key_base: "MC0EKvuSeGvebcMTYQh5nwte1ePB2u8xfuQCcv9FrPT2R4VA8Cyg9ADkI16v0uoR", 17 | watchers: [ 18 | esbuild: {Esbuild, :install_and_run, [:nexus, ~w(--sourcemap=inline --watch)]}, 19 | tailwind: {Tailwind, :install_and_run, [:nexus, ~w(--watch)]} 20 | ] 21 | 22 | # ## SSL Support 23 | # 24 | # In order to use HTTPS in development, a self-signed 25 | # certificate can be generated by running the following 26 | # Mix task: 27 | # 28 | # mix phx.gen.cert 29 | # 30 | # Run `mix help phx.gen.cert` for more information. 31 | # 32 | # The `http:` config above can be replaced with: 33 | # 34 | # https: [ 35 | # port: 4001, 36 | # cipher_suite: :strong, 37 | # keyfile: "priv/cert/selfsigned_key.pem", 38 | # certfile: "priv/cert/selfsigned.pem" 39 | # ], 40 | # 41 | # If desired, both `http:` and `https:` keys can be 42 | # configured to run both http and https servers on 43 | # different ports. 44 | 45 | # Watch static and templates for browser reloading. 46 | config :nexus, NexusWeb.Endpoint, 47 | live_reload: [ 48 | patterns: [ 49 | ~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg)$", 50 | ~r"lib/nexus_web/(controllers|live|components)/.*(ex|heex)$" 51 | ] 52 | ] 53 | 54 | # Do not include metadata nor timestamps in development logs 55 | config :logger, :console, format: "[$level] $message\n" 56 | 57 | # Set a higher stacktrace during development. Avoid configuring such 58 | # in production as building large stacktraces may be expensive. 59 | config :phoenix, :stacktrace_depth, 20 60 | 61 | # Initialize plugs at runtime for faster development compilation 62 | config :phoenix, :plug_init_mode, :runtime 63 | 64 | config :phoenix_live_view, 65 | # Include HEEx debug annotations as HTML comments in rendered markup 66 | debug_heex_annotations: true, 67 | # Enable helpful, but potentially expensive runtime checks 68 | enable_expensive_runtime_checks: true 69 | -------------------------------------------------------------------------------- /nexus/config/prod.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # Note we also include the path to a cache manifest 4 | # containing the digested version of static files. This 5 | # manifest is generated by the `mix assets.deploy` task, 6 | # which you should run after static files are built and 7 | # before starting your production server. 8 | config :nexus, NexusWeb.Endpoint, cache_static_manifest: "priv/static/cache_manifest.json" 9 | 10 | # Do not print debug messages in production 11 | config :logger, level: :info 12 | 13 | # Runtime production configuration, including reading 14 | # of environment variables, is done on config/runtime.exs. 15 | -------------------------------------------------------------------------------- /nexus/config/runtime.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # config/runtime.exs is executed for all environments, including 4 | # during releases. It is executed after compilation and before the 5 | # system starts, so it is typically used to load production configuration 6 | # and secrets from environment variables or elsewhere. Do not define 7 | # any compile-time configuration in here, as it won't be applied. 8 | # The block below contains prod specific runtime configuration. 9 | 10 | # ## Using releases 11 | # 12 | # If you use `mix release`, you need to explicitly enable the server 13 | # by passing the PHX_SERVER=true when you start it: 14 | # 15 | # PHX_SERVER=true bin/nexus start 16 | # 17 | # Alternatively, you can use `mix phx.gen.release` to generate a `bin/server` 18 | # script that automatically sets the env var above. 19 | read_ice_port_range! = fn -> 20 | case System.get_env("ICE_PORT_RANGE") do 21 | nil -> 22 | [0] 23 | 24 | raw_port_range -> 25 | case String.split(raw_port_range, "-", parts: 2) do 26 | [from, to] -> String.to_integer(from)..String.to_integer(to) 27 | _other -> raise "ICE_PORT_RANGE has to be in form of FROM-TO, passed: #{raw_port_range}" 28 | end 29 | end 30 | end 31 | 32 | if System.get_env("PHX_SERVER") do 33 | config :nexus, NexusWeb.Endpoint, server: true 34 | end 35 | 36 | config :nexus, ice_port_range: read_ice_port_range!.() 37 | 38 | if config_env() == :prod do 39 | # The secret key base is used to sign/encrypt cookies and other secrets. 40 | # A default value is used in config/dev.exs and config/test.exs but you 41 | # want to use a different value for prod and you most likely don't want 42 | # to check this value into version control, so we use an environment 43 | # variable instead. 44 | secret_key_base = 45 | System.get_env("SECRET_KEY_BASE") || 46 | raise """ 47 | environment variable SECRET_KEY_BASE is missing. 48 | You can generate one by calling: mix phx.gen.secret 49 | """ 50 | 51 | host = System.get_env("PHX_HOST") || "example.com" 52 | port = String.to_integer(System.get_env("PORT") || "4000") 53 | 54 | config :nexus, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY") 55 | 56 | config :nexus, NexusWeb.Endpoint, 57 | url: [host: host, port: 443, scheme: "https"], 58 | http: [ 59 | # Enable IPv6 and bind on all interfaces. 60 | # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access. 61 | # See the documentation on https://hexdocs.pm/bandit/Bandit.html#t:options/0 62 | # for details about using IPv6 vs IPv4 and loopback vs public addresses. 63 | ip: {0, 0, 0, 0, 0, 0, 0, 0}, 64 | port: port 65 | ], 66 | secret_key_base: secret_key_base 67 | 68 | admin_username = 69 | System.get_env("ADMIN_USERNAME") || raise "Environment variable ADMIN_USERNAME is missing." 70 | 71 | admin_password = 72 | System.get_env("ADMIN_PASSWORD") || raise "Environment variable ADMIN_PASSWORD is missing." 73 | 74 | config :nexus, 75 | admin_username: admin_username, 76 | admin_password: admin_password 77 | 78 | # ## SSL Support 79 | # 80 | # To get SSL working, you will need to add the `https` key 81 | # to your endpoint configuration: 82 | # 83 | # config :nexus, NexusWeb.Endpoint, 84 | # https: [ 85 | # ..., 86 | # port: 443, 87 | # cipher_suite: :strong, 88 | # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), 89 | # certfile: System.get_env("SOME_APP_SSL_CERT_PATH") 90 | # ] 91 | # 92 | # The `cipher_suite` is set to `:strong` to support only the 93 | # latest and more secure SSL ciphers. This means old browsers 94 | # and clients may not be supported. You can set it to 95 | # `:compatible` for wider support. 96 | # 97 | # `:keyfile` and `:certfile` expect an absolute path to the key 98 | # and cert in disk or a relative path inside priv, for example 99 | # "priv/ssl/server.key". For all supported SSL configuration 100 | # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1 101 | # 102 | # We also recommend setting `force_ssl` in your config/prod.exs, 103 | # ensuring no data is ever sent via http, always redirecting to https: 104 | # 105 | # config :nexus, NexusWeb.Endpoint, 106 | # force_ssl: [hsts: true] 107 | # 108 | # Check `Plug.SSL` for all available options in `force_ssl`. 109 | end 110 | -------------------------------------------------------------------------------- /nexus/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 :nexus, NexusWeb.Endpoint, 6 | http: [ip: {127, 0, 0, 1}, port: 4002], 7 | secret_key_base: "jnqiMUTku5atQODwJ2z4vMeU6kal0Av2djgp/I9f5jE6CGNUUZjxDBridCFC3xI2", 8 | server: false 9 | 10 | # Print only warnings and errors during test 11 | config :logger, level: :warning 12 | 13 | # Initialize plugs at runtime for faster test compilation 14 | config :phoenix, :plug_init_mode, :runtime 15 | 16 | config :phoenix_live_view, 17 | # Enable helpful, but potentially expensive runtime checks 18 | enable_expensive_runtime_checks: true 19 | -------------------------------------------------------------------------------- /nexus/lib/nexus.ex: -------------------------------------------------------------------------------- 1 | defmodule Nexus do 2 | @moduledoc """ 3 | Nexus 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 | -------------------------------------------------------------------------------- /nexus/lib/nexus/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Nexus.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 | @version Mix.Project.config()[:version] 9 | 10 | @spec version() :: String.t() 11 | def version(), do: @version 12 | 13 | @impl true 14 | def start(_type, _args) do 15 | children = [ 16 | NexusWeb.Telemetry, 17 | {DNSCluster, query: Application.get_env(:nexus, :dns_cluster_query) || :ignore}, 18 | {Phoenix.PubSub, name: Nexus.PubSub}, 19 | # Start a worker by calling: Nexus.Worker.start_link(arg) 20 | # {Nexus.Worker, arg}, 21 | # Start to serve requests, typically the last entry 22 | NexusWeb.Endpoint, 23 | NexusWeb.Presence, 24 | Nexus.PeerSupervisor, 25 | Nexus.Room, 26 | {Registry, name: Nexus.PeerRegistry, keys: :unique} 27 | ] 28 | 29 | # See https://hexdocs.pm/elixir/Supervisor.html 30 | # for other strategies and supported options 31 | opts = [strategy: :one_for_one, name: Nexus.Supervisor] 32 | Supervisor.start_link(children, opts) 33 | end 34 | 35 | # Tell Phoenix to update the endpoint configuration 36 | # whenever the application is updated. 37 | @impl true 38 | def config_change(changed, _new, removed) do 39 | NexusWeb.Endpoint.config_change(changed, removed) 40 | :ok 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /nexus/lib/nexus/peer_supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule Nexus.PeerSupervisor do 2 | @moduledoc false 3 | 4 | use DynamicSupervisor 5 | 6 | require Logger 7 | 8 | alias Nexus.Peer 9 | 10 | @spec start_link(any()) :: DynamicSupervisor.on_start_child() 11 | def start_link(arg) do 12 | DynamicSupervisor.start_link(__MODULE__, arg, name: __MODULE__) 13 | end 14 | 15 | @spec add_peer(String.t(), pid(), [String.t()]) :: {:ok, pid()} 16 | def add_peer(id, channel_pid, peer_ids) do 17 | peer_opts = [id, channel_pid, peer_ids] 18 | gen_server_opts = [name: Peer.registry_id(id)] 19 | 20 | child_spec = %{ 21 | id: Peer, 22 | start: {Peer, :start_link, [peer_opts, gen_server_opts]}, 23 | restart: :temporary 24 | } 25 | 26 | DynamicSupervisor.start_child(__MODULE__, child_spec) 27 | end 28 | 29 | @spec terminate_peer(Peer.id()) :: :ok 30 | def terminate_peer(peer) do 31 | try do 32 | peer |> Peer.registry_id() |> GenServer.stop(:shutdown) 33 | catch 34 | _exit_or_error, _e -> :ok 35 | end 36 | 37 | :ok 38 | end 39 | 40 | @impl true 41 | def init(_arg) do 42 | DynamicSupervisor.init(strategy: :one_for_one) 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /nexus/lib/nexus/room.ex: -------------------------------------------------------------------------------- 1 | defmodule Nexus.Room do 2 | @moduledoc false 3 | 4 | use GenServer 5 | 6 | require Logger 7 | 8 | alias Nexus.{Peer, PeerSupervisor} 9 | alias NexusWeb.PeerChannel 10 | 11 | @peer_ready_timeout_s 10 12 | @peer_limit 32 13 | 14 | @spec start_link(term()) :: GenServer.on_start() 15 | def start_link(args) do 16 | GenServer.start_link(__MODULE__, args, name: __MODULE__) 17 | end 18 | 19 | @spec add_peer(pid()) :: {:ok, Peer.id()} | {:error, :peer_limit_reached} 20 | def add_peer(channel_pid) do 21 | GenServer.call(__MODULE__, {:add_peer, channel_pid}) 22 | end 23 | 24 | @spec mark_ready(Peer.id()) :: :ok 25 | def mark_ready(peer) do 26 | GenServer.call(__MODULE__, {:mark_ready, peer}) 27 | end 28 | 29 | @impl true 30 | def init(_opts) do 31 | state = %{ 32 | peers: %{}, 33 | pending_peers: %{}, 34 | peer_pid_to_id: %{} 35 | } 36 | 37 | {:ok, state} 38 | end 39 | 40 | @impl true 41 | def handle_call({:add_peer, _channel_pid}, _from, state) 42 | when map_size(state.pending_peers) + map_size(state.peers) == @peer_limit do 43 | Logger.warning("Unable to add new peer: reached peer limit (#{@peer_limit})") 44 | {:reply, {:error, :peer_limit_reached}, state} 45 | end 46 | 47 | @impl true 48 | def handle_call({:add_peer, channel_pid}, _from, state) do 49 | id = generate_id() 50 | Logger.info("New peer #{id} added") 51 | peer_ids = Map.keys(state.peers) 52 | 53 | {:ok, pid} = PeerSupervisor.add_peer(id, channel_pid, peer_ids) 54 | Process.monitor(pid) 55 | 56 | peer_data = %{pid: pid, channel: channel_pid} 57 | 58 | state = 59 | state 60 | |> put_in([:pending_peers, id], peer_data) 61 | |> put_in([:peer_pid_to_id, pid], id) 62 | 63 | Process.send_after(self(), {:peer_ready_timeout, id}, @peer_ready_timeout_s * 1000) 64 | 65 | {:reply, {:ok, id}, state} 66 | end 67 | 68 | @impl true 69 | def handle_call({:mark_ready, id}, _from, state) 70 | when is_map_key(state.pending_peers, id) do 71 | Logger.info("Peer #{id} ready") 72 | broadcast({:peer_added, id}, state) 73 | 74 | {peer_data, state} = pop_in(state, [:pending_peers, id]) 75 | state = put_in(state, [:peers, id], peer_data) 76 | 77 | {:reply, :ok, state} 78 | end 79 | 80 | @impl true 81 | def handle_call({:mark_ready, id, _peer_ids}, _from, state) do 82 | Logger.debug("Peer #{id} was already marked as ready, ignoring") 83 | 84 | {:reply, :ok, state} 85 | end 86 | 87 | @impl true 88 | def handle_info({:peer_ready_timeout, peer}, state) do 89 | if is_map_key(state.pending_peers, peer) do 90 | Logger.warning( 91 | "Removing peer #{peer} which failed to mark itself as ready for #{@peer_ready_timeout_s} s" 92 | ) 93 | 94 | :ok = PeerSupervisor.terminate_peer(peer) 95 | end 96 | 97 | {:noreply, state} 98 | end 99 | 100 | @impl true 101 | def handle_info({:DOWN, _ref, :process, pid, reason}, state) do 102 | {id, state} = pop_in(state, [:peer_pid_to_id, pid]) 103 | Logger.info("Peer #{id} down with reason #{inspect(reason)}") 104 | 105 | state = 106 | cond do 107 | is_map_key(state.pending_peers, id) -> 108 | {peer_data, state} = pop_in(state, [:pending_peers, id]) 109 | :ok = PeerChannel.close(peer_data.channel) 110 | 111 | state 112 | 113 | is_map_key(state.peers, id) -> 114 | {peer_data, state} = pop_in(state, [:peers, id]) 115 | :ok = PeerChannel.close(peer_data.channel) 116 | broadcast({:peer_removed, id}, state) 117 | 118 | state 119 | end 120 | 121 | {:noreply, state} 122 | end 123 | 124 | defp generate_id, do: 5 |> :crypto.strong_rand_bytes() |> Base.encode16(case: :lower) 125 | 126 | defp broadcast(msg, state) do 127 | Map.keys(state.peers) 128 | |> Stream.concat(Map.keys(state.pending_peers)) 129 | |> Enum.each(&Peer.notify(&1, msg)) 130 | end 131 | end 132 | -------------------------------------------------------------------------------- /nexus/lib/nexus_web.ex: -------------------------------------------------------------------------------- 1 | defmodule NexusWeb 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 NexusWeb, :controller 9 | use NexusWeb, :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 | def static_paths, do: ~w(assets fonts images favicon.ico robots.txt) 21 | 22 | def router do 23 | quote do 24 | use Phoenix.Router, helpers: false 25 | 26 | # Import common connection and controller functions to use in pipelines 27 | import Plug.Conn 28 | import Phoenix.Controller 29 | import Phoenix.LiveView.Router 30 | end 31 | end 32 | 33 | def channel do 34 | quote do 35 | use Phoenix.Channel 36 | end 37 | end 38 | 39 | def controller do 40 | quote do 41 | use Phoenix.Controller, 42 | formats: [:html, :json], 43 | layouts: [html: NexusWeb.Layouts] 44 | 45 | import Plug.Conn 46 | 47 | unquote(verified_routes()) 48 | end 49 | end 50 | 51 | def live_view do 52 | quote do 53 | use Phoenix.LiveView, 54 | layout: {NexusWeb.Layouts, :app} 55 | 56 | unquote(html_helpers()) 57 | end 58 | end 59 | 60 | def live_component do 61 | quote do 62 | use Phoenix.LiveComponent 63 | 64 | unquote(html_helpers()) 65 | end 66 | end 67 | 68 | def html do 69 | quote do 70 | use Phoenix.Component 71 | 72 | # Import convenience functions from controllers 73 | import Phoenix.Controller, 74 | only: [get_csrf_token: 0, view_module: 1, view_template: 1] 75 | 76 | # Include general helpers for rendering HTML 77 | unquote(html_helpers()) 78 | end 79 | end 80 | 81 | defp html_helpers do 82 | quote do 83 | # HTML escaping functionality 84 | import Phoenix.HTML 85 | # Core UI components and translation 86 | import NexusWeb.CoreComponents 87 | 88 | # Shortcut for generating JS commands 89 | alias Phoenix.LiveView.JS 90 | 91 | # Routes generation with the ~p sigil 92 | unquote(verified_routes()) 93 | end 94 | end 95 | 96 | def verified_routes do 97 | quote do 98 | use Phoenix.VerifiedRoutes, 99 | endpoint: NexusWeb.Endpoint, 100 | router: NexusWeb.Router, 101 | statics: NexusWeb.static_paths() 102 | end 103 | end 104 | 105 | @doc """ 106 | When used, dispatch to the appropriate controller/live_view/etc. 107 | """ 108 | defmacro __using__(which) when is_atom(which) do 109 | apply(__MODULE__, which, []) 110 | end 111 | end 112 | -------------------------------------------------------------------------------- /nexus/lib/nexus_web/channels/peer_channel.ex: -------------------------------------------------------------------------------- 1 | defmodule NexusWeb.PeerChannel do 2 | @moduledoc false 3 | 4 | use NexusWeb, :channel 5 | 6 | require Logger 7 | 8 | alias Nexus.{Peer, Room} 9 | alias NexusWeb.Presence 10 | 11 | @spec send_offer(GenServer.server(), String.t()) :: :ok 12 | def send_offer(channel, offer) do 13 | GenServer.cast(channel, {:offer, offer}) 14 | end 15 | 16 | @spec send_candidate(GenServer.server(), String.t()) :: :ok 17 | def send_candidate(channel, candidate) do 18 | GenServer.cast(channel, {:candidate, candidate}) 19 | end 20 | 21 | @spec close(GenServer.server()) :: :ok 22 | def close(channel) do 23 | try do 24 | GenServer.stop(channel, :shutdown) 25 | catch 26 | _exit_or_error, _e -> :ok 27 | end 28 | 29 | :ok 30 | end 31 | 32 | @impl true 33 | def join("peer:signalling", _payload, socket) do 34 | pid = self() 35 | send(pid, :after_join) 36 | 37 | case Room.add_peer(pid) do 38 | {:ok, id} -> {:ok, assign(socket, :peer, id)} 39 | {:error, _reason} = error -> error 40 | end 41 | end 42 | 43 | @impl true 44 | def handle_in("sdp_answer", %{"body" => body}, socket) do 45 | :ok = Peer.apply_sdp_answer(socket.assigns.peer, body) 46 | {:noreply, socket} 47 | end 48 | 49 | @impl true 50 | def handle_in("sdp_offer", %{"body" => _body}, socket) do 51 | # TODO: renegotiate 52 | Logger.warning("Ignoring SDP offer sent by peer #{socket.assigns.peer}") 53 | {:noreply, socket} 54 | end 55 | 56 | @impl true 57 | def handle_in("ice_candidate", %{"body" => body}, socket) do 58 | Peer.add_ice_candidate(socket.assigns.peer, body) 59 | {:noreply, socket} 60 | end 61 | 62 | @impl true 63 | def handle_cast({:offer, sdp_offer}, socket) do 64 | push(socket, "sdp_offer", %{"body" => sdp_offer}) 65 | {:noreply, socket} 66 | end 67 | 68 | @impl true 69 | def handle_cast({:candidate, candidate}, socket) do 70 | push(socket, "ice_candidate", %{"body" => candidate}) 71 | {:noreply, socket} 72 | end 73 | 74 | @impl true 75 | def handle_info(:after_join, socket) do 76 | {:ok, _ref} = Presence.track(socket, socket.assigns.peer, %{}) 77 | push(socket, "presence_state", Presence.list(socket)) 78 | {:noreply, socket} 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /nexus/lib/nexus_web/channels/presence.ex: -------------------------------------------------------------------------------- 1 | defmodule NexusWeb.Presence do 2 | @moduledoc """ 3 | Provides presence tracking to channels and processes. 4 | 5 | See the [`Phoenix.Presence`](https://hexdocs.pm/phoenix/Phoenix.Presence.html) 6 | docs for more details. 7 | """ 8 | use Phoenix.Presence, 9 | otp_app: :nexus, 10 | pubsub_server: Nexus.PubSub 11 | end 12 | -------------------------------------------------------------------------------- /nexus/lib/nexus_web/channels/user_socket.ex: -------------------------------------------------------------------------------- 1 | defmodule NexusWeb.UserSocket do 2 | use Phoenix.Socket 3 | 4 | channel "stream:*", NexusWeb.StreamChannel 5 | channel "peer:*", NexusWeb.PeerChannel 6 | 7 | @impl true 8 | def connect(_params, socket, _connect_info) do 9 | {:ok, assign(socket, :user_id, generate_id())} 10 | end 11 | 12 | @impl true 13 | def id(socket), do: "user_socket:#{socket.assigns.user_id}" 14 | 15 | defp generate_id do 16 | 10 17 | |> :crypto.strong_rand_bytes() 18 | |> Base.url_encode64() 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /nexus/lib/nexus_web/components/layouts.ex: -------------------------------------------------------------------------------- 1 | defmodule NexusWeb.Layouts do 2 | @moduledoc """ 3 | This module holds different layouts used by your application. 4 | 5 | See the `layouts` directory for all templates available. 6 | The "root" layout is a skeleton rendered as part of the 7 | application router. The "app" layout is set as the default 8 | layout on both `use NexusWeb, :controller` and 9 | `use NexusWeb, :live_view`. 10 | """ 11 | use NexusWeb, :html 12 | 13 | embed_templates "layouts/*" 14 | end 15 | -------------------------------------------------------------------------------- /nexus/lib/nexus_web/components/layouts/app.html.heex: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 5 | 6 | 7 |
8 |
9 |
10 |
11 |

0

12 | <.icon name="hero-user-solid" /> 13 |
14 | 15 | GitHub 16 | 17 | 21 | Docs 22 | 23 |
24 |
25 |
26 |
27 | 39 |
40 |
41 | <.flash_group flash={@flash} /> 42 | {@inner_content} 43 |
44 |
45 |
46 | {Nexus.Application.version()} 47 |
48 | -------------------------------------------------------------------------------- /nexus/lib/nexus_web/components/layouts/root.html.heex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | <.live_title suffix=" · Nexus"> 8 | {assigns[:page_title]} 9 | 10 | 11 | 13 | 14 | 15 | {@inner_content} 16 | 17 | 18 | -------------------------------------------------------------------------------- /nexus/lib/nexus_web/controllers/error_html.ex: -------------------------------------------------------------------------------- 1 | defmodule NexusWeb.ErrorHTML do 2 | @moduledoc """ 3 | This module is invoked by your endpoint in case of errors on HTML requests. 4 | 5 | See config/config.exs. 6 | """ 7 | use NexusWeb, :html 8 | 9 | # If you want to customize your error pages, 10 | # uncomment the embed_templates/1 call below 11 | # and add pages to the error directory: 12 | # 13 | # * lib/nexus_web/controllers/error_html/404.html.heex 14 | # * lib/nexus_web/controllers/error_html/500.html.heex 15 | # 16 | # embed_templates "error_html/*" 17 | 18 | # The default is to render a plain text page based on 19 | # the template name. For example, "404.html" becomes 20 | # "Not Found". 21 | def render(template, _assigns) do 22 | Phoenix.Controller.status_message_from_template(template) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /nexus/lib/nexus_web/controllers/error_json.ex: -------------------------------------------------------------------------------- 1 | defmodule NexusWeb.ErrorJSON do 2 | @moduledoc """ 3 | This module is invoked by your endpoint in case of errors on JSON requests. 4 | 5 | See config/config.exs. 6 | """ 7 | 8 | # If you want to customize a particular status code, 9 | # you may add your own clauses, such as: 10 | # 11 | # def render("500.json", _assigns) do 12 | # %{errors: %{detail: "Internal Server Error"}} 13 | # end 14 | 15 | # By default, Phoenix returns the status message from 16 | # the template name. For example, "404.json" becomes 17 | # "Not Found". 18 | def render(template, _assigns) do 19 | %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}} 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /nexus/lib/nexus_web/controllers/page_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule NexusWeb.PageController do 2 | use NexusWeb, :controller 3 | 4 | def home(conn, _params) do 5 | render(conn, :home, page_title: "Home") 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /nexus/lib/nexus_web/controllers/page_html.ex: -------------------------------------------------------------------------------- 1 | defmodule NexusWeb.PageHTML do 2 | @moduledoc """ 3 | This module contains pages rendered by PageController. 4 | 5 | See the `page_html` directory for all templates available. 6 | """ 7 | use NexusWeb, :html 8 | 9 | embed_templates "page_html/*" 10 | end 11 | -------------------------------------------------------------------------------- /nexus/lib/nexus_web/controllers/page_html/home.html.heex: -------------------------------------------------------------------------------- 1 |
2 | 5 |
6 | 8 |
9 |
10 | -------------------------------------------------------------------------------- /nexus/lib/nexus_web/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule NexusWeb.Endpoint do 2 | use Phoenix.Endpoint, otp_app: :nexus 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: "_nexus_key", 10 | signing_salt: "BnOeka5n", 11 | same_site: "Lax" 12 | ] 13 | 14 | socket "/socket", NexusWeb.UserSocket, 15 | websocket: true, 16 | longpoll: false 17 | 18 | socket "/live", Phoenix.LiveView.Socket, 19 | websocket: [connect_info: [session: @session_options]], 20 | longpoll: [connect_info: [session: @session_options]] 21 | 22 | # Serve at "/" the static files from "priv/static" directory. 23 | # 24 | # You should set gzip to true if you are running phx.digest 25 | # when deploying your static files in production. 26 | plug Plug.Static, 27 | at: "/", 28 | from: :nexus, 29 | gzip: false, 30 | only: NexusWeb.static_paths() 31 | 32 | # Code reloading can be explicitly enabled under the 33 | # :code_reloader configuration of your endpoint. 34 | if code_reloading? do 35 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket 36 | plug Phoenix.LiveReloader 37 | plug Phoenix.CodeReloader 38 | end 39 | 40 | plug Phoenix.LiveDashboard.RequestLogger, 41 | param_key: "request_logger", 42 | cookie_key: "request_logger" 43 | 44 | plug Plug.RequestId 45 | plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] 46 | 47 | plug Plug.Parsers, 48 | parsers: [:urlencoded, :multipart, :json], 49 | pass: ["*/*"], 50 | json_decoder: Phoenix.json_library() 51 | 52 | plug Plug.MethodOverride 53 | plug Plug.Head 54 | plug Plug.Session, @session_options 55 | plug NexusWeb.Router 56 | end 57 | -------------------------------------------------------------------------------- /nexus/lib/nexus_web/router.ex: -------------------------------------------------------------------------------- 1 | defmodule NexusWeb.Router do 2 | use NexusWeb, :router 3 | 4 | import Phoenix.LiveDashboard.Router 5 | 6 | pipeline :browser do 7 | plug :accepts, ["html"] 8 | plug :fetch_session 9 | plug :fetch_live_flash 10 | plug :put_root_layout, html: {NexusWeb.Layouts, :root} 11 | plug :protect_from_forgery 12 | plug :put_secure_browser_headers 13 | end 14 | 15 | pipeline :auth do 16 | plug :admin_auth 17 | end 18 | 19 | scope "/", NexusWeb do 20 | pipe_through :browser 21 | 22 | get "/", PageController, :home 23 | end 24 | 25 | scope "/admin", NexusWeb do 26 | pipe_through :auth 27 | pipe_through :browser 28 | 29 | live_dashboard "/dashboard", 30 | metrics: NexusWeb.Telemetry, 31 | additional_pages: [exwebrtc: ExWebRTCDashboard] 32 | end 33 | 34 | defp admin_auth(conn, _opts) do 35 | username = Application.fetch_env!(:nexus, :admin_username) 36 | password = Application.fetch_env!(:nexus, :admin_password) 37 | Plug.BasicAuth.basic_auth(conn, username: username, password: password) 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /nexus/lib/nexus_web/telemetry.ex: -------------------------------------------------------------------------------- 1 | defmodule NexusWeb.Telemetry do 2 | use Supervisor 3 | import Telemetry.Metrics 4 | 5 | def start_link(arg) do 6 | Supervisor.start_link(__MODULE__, arg, name: __MODULE__) 7 | end 8 | 9 | @impl true 10 | def init(_arg) do 11 | children = [ 12 | # Telemetry poller will execute the given period measurements 13 | # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics 14 | {:telemetry_poller, measurements: periodic_measurements(), period: 10_000} 15 | # Add reporters as children of your supervision tree. 16 | # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()} 17 | ] 18 | 19 | Supervisor.init(children, strategy: :one_for_one) 20 | end 21 | 22 | def metrics do 23 | [ 24 | # Phoenix Metrics 25 | summary("phoenix.endpoint.start.system_time", 26 | unit: {:native, :millisecond} 27 | ), 28 | summary("phoenix.endpoint.stop.duration", 29 | unit: {:native, :millisecond} 30 | ), 31 | summary("phoenix.router_dispatch.start.system_time", 32 | tags: [:route], 33 | unit: {:native, :millisecond} 34 | ), 35 | summary("phoenix.router_dispatch.exception.duration", 36 | tags: [:route], 37 | unit: {:native, :millisecond} 38 | ), 39 | summary("phoenix.router_dispatch.stop.duration", 40 | tags: [:route], 41 | unit: {:native, :millisecond} 42 | ), 43 | summary("phoenix.socket_connected.duration", 44 | unit: {:native, :millisecond} 45 | ), 46 | summary("phoenix.channel_joined.duration", 47 | unit: {:native, :millisecond} 48 | ), 49 | summary("phoenix.channel_handled_in.duration", 50 | tags: [:event], 51 | unit: {:native, :millisecond} 52 | ), 53 | 54 | # VM Metrics 55 | summary("vm.memory.total", unit: {:byte, :kilobyte}), 56 | summary("vm.total_run_queue_lengths.total"), 57 | summary("vm.total_run_queue_lengths.cpu"), 58 | summary("vm.total_run_queue_lengths.io") 59 | ] 60 | end 61 | 62 | defp periodic_measurements do 63 | [ 64 | # A module, function and arguments to be invoked periodically. 65 | # This function must call :telemetry.execute/3 and a metric must be added above. 66 | # {NexusWeb, :count_users, []} 67 | ] 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /nexus/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Nexus.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :nexus, 7 | version: "0.4.0", 8 | elixir: "~> 1.14", 9 | elixirc_paths: elixirc_paths(Mix.env()), 10 | start_permanent: Mix.env() == :prod, 11 | aliases: aliases(), 12 | deps: deps(), 13 | 14 | # dialyzer 15 | dialyzer: [ 16 | plt_local_path: "_dialyzer", 17 | plt_core_path: "_dialyzer" 18 | ] 19 | ] 20 | end 21 | 22 | def application do 23 | [ 24 | mod: {Nexus.Application, []}, 25 | extra_applications: [:logger, :runtime_tools] 26 | ] 27 | end 28 | 29 | defp elixirc_paths(:test), do: ["lib", "test/support"] 30 | defp elixirc_paths(_), do: ["lib"] 31 | 32 | defp deps do 33 | [ 34 | {:phoenix, "~> 1.7.12"}, 35 | {:phoenix_html, "~> 4.0"}, 36 | {:phoenix_live_reload, "~> 1.2", only: :dev}, 37 | {:phoenix_live_view, "~> 1.0"}, 38 | {:floki, ">= 0.30.0", only: :test}, 39 | {:phoenix_live_dashboard, "~> 0.8.3"}, 40 | {:esbuild, "~> 0.8", runtime: Mix.env() == :dev}, 41 | {:tailwind, "~> 0.2", runtime: Mix.env() == :dev}, 42 | {:heroicons, 43 | github: "tailwindlabs/heroicons", 44 | tag: "v2.1.1", 45 | sparse: "optimized", 46 | app: false, 47 | compile: false, 48 | depth: 1}, 49 | {:telemetry_metrics, "~> 1.0"}, 50 | {:telemetry_poller, "~> 1.0"}, 51 | {:jason, "~> 1.2"}, 52 | {:dns_cluster, "~> 0.1.1"}, 53 | {:bandit, "~> 1.2"}, 54 | {:ex_webrtc, "~> 0.8.0"}, 55 | {:ex_webrtc_dashboard, "~> 0.8.0"}, 56 | 57 | # Dialyzer and credo 58 | {:dialyxir, ">= 0.0.0", only: :dev, runtime: false}, 59 | {:credo, ">= 0.0.0", only: :dev, runtime: false} 60 | ] 61 | end 62 | 63 | defp aliases do 64 | [ 65 | setup: ["deps.get", "assets.setup", "assets.build"], 66 | "assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"], 67 | "assets.build": ["tailwind nexus", "esbuild nexus"], 68 | "assets.deploy": [ 69 | "tailwind nexus --minify", 70 | "esbuild nexus --minify", 71 | "phx.digest" 72 | ], 73 | "assets.format": &lint_and_format_assets/1, 74 | "assets.check": &check_assets/1 75 | ] 76 | end 77 | 78 | defp lint_and_format_assets(_args) do 79 | with {_, 0} <- execute_npm_command(["ci"]), 80 | {_, 0} <- execute_npm_command(["run", "lint"]), 81 | {_, 0} <- execute_npm_command(["run", "format"]) do 82 | :ok 83 | else 84 | {cmd, rc} -> 85 | Mix.shell().error("npm command `#{Enum.join(cmd, " ")}` failed with code #{rc}") 86 | exit({:shutdown, rc}) 87 | end 88 | end 89 | 90 | defp check_assets(_args) do 91 | with {_, 0} <- execute_npm_command(["ci"]), 92 | {_, 0} <- execute_npm_command(["run", "check"]) do 93 | :ok 94 | else 95 | {cmd, rc} -> 96 | Mix.shell().error("npm command `#{Enum.join(cmd, " ")}` failed with code #{rc}") 97 | exit({:shutdown, rc}) 98 | end 99 | end 100 | 101 | defp execute_npm_command(command) do 102 | {_stream, rc} = System.cmd("npm", ["--prefix=assets"] ++ command, into: IO.stream()) 103 | {command, rc} 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /nexus/priv/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elixir-webrtc/apps/4502fee12b35b1e9e0b64d613dae70b63a07e78d/nexus/priv/static/favicon.ico -------------------------------------------------------------------------------- /nexus/priv/static/robots.txt: -------------------------------------------------------------------------------- 1 | # See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | # 3 | # To ban all spiders from the entire site uncomment the next two lines: 4 | # User-agent: * 5 | # Disallow: / 6 | -------------------------------------------------------------------------------- /nexus/rel/overlays/bin/server: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -eu 3 | 4 | cd -P -- "$(dirname -- "$0")" 5 | PHX_SERVER=true exec ./nexus start 6 | -------------------------------------------------------------------------------- /nexus/rel/overlays/bin/server.bat: -------------------------------------------------------------------------------- 1 | set PHX_SERVER=true 2 | call "%~dp0\nexus" start 3 | -------------------------------------------------------------------------------- /nexus/test/nexus_web/controllers/error_html_test.exs: -------------------------------------------------------------------------------- 1 | defmodule NexusWeb.ErrorHTMLTest do 2 | use NexusWeb.ConnCase, async: true 3 | 4 | # Bring render_to_string/4 for testing custom views 5 | import Phoenix.Template 6 | 7 | test "renders 404.html" do 8 | assert render_to_string(NexusWeb.ErrorHTML, "404", "html", []) == "Not Found" 9 | end 10 | 11 | test "renders 500.html" do 12 | assert render_to_string(NexusWeb.ErrorHTML, "500", "html", []) == 13 | "Internal Server Error" 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /nexus/test/nexus_web/controllers/error_json_test.exs: -------------------------------------------------------------------------------- 1 | defmodule NexusWeb.ErrorJSONTest do 2 | use NexusWeb.ConnCase, async: true 3 | 4 | test "renders 404" do 5 | assert NexusWeb.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}} 6 | end 7 | 8 | test "renders 500" do 9 | assert NexusWeb.ErrorJSON.render("500.json", %{}) == 10 | %{errors: %{detail: "Internal Server Error"}} 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /nexus/test/support/channel_case.ex: -------------------------------------------------------------------------------- 1 | defmodule NexusWeb.ChannelCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | channel tests. 5 | 6 | Such tests rely on `Phoenix.ChannelTest` 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 NexusWeb.ChannelCase, 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 | # Import conveniences for testing with channels 23 | import Phoenix.ChannelTest 24 | import NexusWeb.ChannelCase 25 | 26 | # The default endpoint for testing 27 | @endpoint NexusWeb.Endpoint 28 | end 29 | end 30 | 31 | setup _tags do 32 | :ok 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /nexus/test/support/conn_case.ex: -------------------------------------------------------------------------------- 1 | defmodule NexusWeb.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 NexusWeb.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 NexusWeb.Endpoint 24 | 25 | use NexusWeb, :verified_routes 26 | 27 | # Import conveniences for testing with connections 28 | import Plug.Conn 29 | import Phoenix.ConnTest 30 | import NexusWeb.ConnCase 31 | end 32 | end 33 | 34 | setup _tags do 35 | {:ok, conn: Phoenix.ConnTest.build_conn()} 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /nexus/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /recognizer/.dockerignore: -------------------------------------------------------------------------------- 1 | # This file excludes paths from the Docker build context. 2 | # 3 | # By default, Docker's build context includes all files (and folders) in the 4 | # current directory. Even if a file isn't copied into the container it is still sent to 5 | # the Docker daemon. 6 | # 7 | # There are multiple reasons to exclude files from the build context: 8 | # 9 | # 1. Prevent nested folders from being copied into the container (ex: exclude 10 | # /assets/node_modules when copying /assets) 11 | # 2. Reduce the size of the build context and improve build time (ex. /build, /deps, /doc) 12 | # 3. Avoid sending files containing sensitive information 13 | # 14 | # More information on using .dockerignore is available here: 15 | # https://docs.docker.com/engine/reference/builder/#dockerignore-file 16 | 17 | .dockerignore 18 | 19 | # Ignore git, but keep git HEAD and refs to access current commit hash if needed: 20 | # 21 | # $ cat .git/HEAD | awk '{print ".git/"$2}' | xargs cat 22 | # d0b8727759e1e0e7aa3d41707d12376e373d5ecc 23 | .git 24 | !.git/HEAD 25 | !.git/refs 26 | 27 | # Common development/test artifacts 28 | /cover/ 29 | /doc/ 30 | /test/ 31 | /tmp/ 32 | .elixir_ls 33 | 34 | # Mix artifacts 35 | /_build/ 36 | /deps/ 37 | *.ez 38 | 39 | # Generated on crash by the VM 40 | erl_crash.dump 41 | 42 | # Static artifacts - These should be fetched and built inside the Docker image 43 | /assets/node_modules/ 44 | /priv/static/assets/ 45 | /priv/static/cache_manifest.json 46 | -------------------------------------------------------------------------------- /recognizer/.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:phoenix], 3 | plugins: [Phoenix.LiveView.HTMLFormatter], 4 | inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}"] 5 | ] 6 | -------------------------------------------------------------------------------- /recognizer/.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 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Temporary files, for example, from tests. 23 | /tmp/ 24 | 25 | # Ignore package tarball (built via "mix hex.build"). 26 | recognizer-*.tar 27 | 28 | # Ignore assets that are produced by build tools. 29 | /priv/static/assets/ 30 | 31 | # Ignore digested assets cache. 32 | /priv/static/cache_manifest.json 33 | 34 | # In case you use Node.js/npm, you want to ignore these. 35 | npm-debug.log 36 | /assets/node_modules/ 37 | 38 | /_dialyzer/ 39 | -------------------------------------------------------------------------------- /recognizer/Dockerfile: -------------------------------------------------------------------------------- 1 | # Find eligible builder and runner images on Docker Hub. We use Ubuntu/Debian 2 | # instead of Alpine to avoid DNS resolution issues in production. 3 | # 4 | # https://hub.docker.com/r/hexpm/elixir/tags?page=1&name=ubuntu 5 | # https://hub.docker.com/_/ubuntu?tab=tags 6 | # 7 | # This file is based on these images: 8 | # 9 | # - https://hub.docker.com/r/hexpm/elixir/tags - for the build image 10 | # - https://hub.docker.com/_/debian?tab=tags&page=1&name=bullseye-20231009-slim - for the release image 11 | # - https://pkgs.org/ - resource for finding needed packages 12 | # - Ex: hexpm/elixir:1.16.0-erlang-26.2.1-debian-bullseye-20231009-slim 13 | ARG ELIXIR_VERSION=1.17.2 14 | ARG OTP_VERSION=27.0.1 15 | ARG DEBIAN_VERSION=bookworm-20240701-slim 16 | 17 | ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}" 18 | ARG RUNNER_IMAGE="debian:${DEBIAN_VERSION}" 19 | 20 | FROM ${BUILDER_IMAGE} AS builder 21 | 22 | # install build dependencies 23 | RUN apt-get update -y && apt-get install -y \ 24 | build-essential \ 25 | libssl-dev \ 26 | curl \ 27 | pkg-config \ 28 | git \ 29 | libsrtp2-dev \ 30 | libavcodec-dev \ 31 | libavformat-dev \ 32 | libavutil-dev \ 33 | libswscale-dev \ 34 | libavdevice-dev && \ 35 | apt-get clean && \ 36 | rm -f /var/lib/apt/lists/*_* 37 | 38 | # prepare build dir 39 | WORKDIR /app 40 | 41 | # install hex + rebar 42 | RUN mix local.hex --force && \ 43 | mix local.rebar --force 44 | 45 | # set build ENV 46 | ENV MIX_ENV="prod" 47 | 48 | # install mix dependencies 49 | COPY mix.exs mix.lock ./ 50 | RUN mix deps.get --only $MIX_ENV 51 | RUN mkdir config 52 | 53 | # copy compile-time config files before we compile dependencies 54 | # to ensure any relevant config change will trigger the dependencies 55 | # to be re-compiled. 56 | COPY config/config.exs config/${MIX_ENV}.exs config/ 57 | RUN mix deps.compile 58 | 59 | COPY priv priv 60 | 61 | COPY lib lib 62 | 63 | COPY assets assets 64 | 65 | # compile assets 66 | RUN mix assets.deploy 67 | 68 | # Compile the release 69 | RUN mix compile 70 | 71 | # Changes to config/runtime.exs don't require recompiling the code 72 | COPY config/runtime.exs config/ 73 | 74 | COPY rel rel 75 | RUN mix release 76 | 77 | # start a new build stage so that the final image will only contain 78 | # the compiled release and other runtime necessities 79 | FROM ${RUNNER_IMAGE} 80 | 81 | RUN apt-get update -y && \ 82 | apt-get install -y libstdc++6 \ 83 | openssl \ 84 | libncurses5 \ 85 | locales \ 86 | ca-certificates \ 87 | libsrtp2-dev \ 88 | libavcodec-dev \ 89 | libavformat-dev \ 90 | libavutil-dev \ 91 | libswscale-dev \ 92 | libavdevice-dev \ 93 | && apt-get clean \ 94 | && rm -f /var/lib/apt/lists/*_* 95 | 96 | # Set the locale 97 | RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen 98 | 99 | ENV LANG=en_US.UTF-8 100 | ENV LANGUAGE=en_US:en 101 | ENV LC_ALL=en_US.UTF-8 102 | 103 | WORKDIR "/app" 104 | RUN chown nobody /app 105 | 106 | # set runner ENV 107 | ENV MIX_ENV="prod" 108 | 109 | # without setting this, bumblebee tries to use /nonexistent directory, 110 | # which does not exist and cannot be created 111 | ENV BUMBLEBEE_CACHE_DIR=/app/bin 112 | 113 | # Only copy the final release from the build stage 114 | COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/recognizer ./ 115 | 116 | USER nobody 117 | 118 | # If using an environment that doesn't automatically reap zombie processes, it is 119 | # advised to add an init process such as tini via `apt-get install` 120 | # above and adding an entrypoint. See https://github.com/krallin/tini for details 121 | # ENTRYPOINT ["/tini", "--"] 122 | 123 | CMD ["/app/bin/server"] 124 | -------------------------------------------------------------------------------- /recognizer/README.md: -------------------------------------------------------------------------------- 1 | # Recognizer 2 | 3 | Phoenix app for real-time image recognition using [Elixir WebRTC](https://github.com/elixir-webrtc) and [Elixir Nx](https://github.com/elixir-nx/nx). 4 | 5 | To start your Phoenix server: 6 | 7 | * Run `mix setup` to install and setup dependencies 8 | * Start Phoenix endpoint with `mix phx.server` or inside IEx with `iex -S mix phx.server` 9 | 10 | Now you can visit [`localhost:4000`](http://localhost:4000) from your browser. 11 | 12 | ## Running with Docker 13 | 14 | You can also run Recognizer using Docker. 15 | 16 | Build an image (or use `ghcr.io/elixir-webrtc/apps/recognizer:latest`): 17 | 18 | ``` 19 | docker build -t recognizer . 20 | ``` 21 | 22 | and run: 23 | 24 | ``` 25 | docker run -e SECRET_KEY_BASE="secret" -e PHX_HOST=localhost --network host recognizer 26 | ``` 27 | 28 | Note that secret has to be at least 64 bytes long. 29 | You can generate one with `mix phx.gen.secret` or `head -c64 /dev/urandom | base64`. 30 | 31 | If you are running on MacOS, instead of using `--network host` option, you have to explicitly publish ports: 32 | 33 | ``` 34 | docker run -e SECRET_KEY_BASE="secert" -e PHX_HOST=localhost -p 4000:4000 -p 50000-50010/udp recognizer 35 | ``` 36 | 37 | -------------------------------------------------------------------------------- /recognizer/assets/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "es5", 4 | "tabWidth": 2, 5 | "useTabs": false, 6 | "semi": true, 7 | "printWidth": 80 8 | } 9 | -------------------------------------------------------------------------------- /recognizer/assets/css/app.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss/base'; 2 | @import 'tailwindcss/components'; 3 | @import 'tailwindcss/utilities'; 4 | 5 | /* This file is for your main application CSS */ 6 | 7 | /* Hiding scrollbar for IE, Edge and Firefox */ 8 | main { 9 | scrollbar-width: none; /* Firefox */ 10 | -ms-overflow-style: none; /* IE and Edge */ 11 | } 12 | 13 | main::-webkit-scrollbar { 14 | display: none; 15 | } 16 | -------------------------------------------------------------------------------- /recognizer/assets/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import globals from "globals"; 2 | import pluginJs from "@eslint/js"; 3 | import prettierPlugin from 'eslint-plugin-prettier'; 4 | import prettierConfig from 'eslint-config-prettier'; 5 | 6 | export default [ 7 | { 8 | files: ['**/*.js'], 9 | languageOptions: { 10 | ecmaVersion: 2021, 11 | sourceType: "module", 12 | globals: globals.browser 13 | }, 14 | plugins: { 15 | prettier: prettierPlugin 16 | }, 17 | rules: { 18 | "prettier/prettier": "error", 19 | "no-unused-vars": [ 20 | "error", 21 | { 22 | "argsIgnorePattern": "^_", 23 | "varsIgnorePattern": "^_" 24 | } 25 | ] 26 | }, 27 | settings: { 28 | prettier: prettierConfig 29 | } 30 | }, 31 | pluginJs.configs.recommended, 32 | ]; 33 | -------------------------------------------------------------------------------- /recognizer/assets/js/app.js: -------------------------------------------------------------------------------- 1 | // If you want to use Phoenix channels, run `mix help phx.gen.channel` 2 | // to get started and then uncomment the line below. 3 | // import "./user_socket.js" 4 | 5 | // You can include dependencies in two ways. 6 | // 7 | // The simplest option is to put them in assets/vendor and 8 | // import them using relative paths: 9 | // 10 | // import "../vendor/some-package.js" 11 | // 12 | // Alternatively, you can `npm install some-package --prefix assets` and import 13 | // them using a path starting with the package name: 14 | // 15 | // import "some-package" 16 | // 17 | 18 | // Include phoenix_html to handle method=PUT/DELETE in forms and buttons. 19 | import 'phoenix_html'; 20 | // Establish Phoenix Socket and LiveView configuration. 21 | import { Socket } from 'phoenix'; 22 | import { LiveSocket } from 'phoenix_live_view'; 23 | import topbar from '../vendor/topbar'; 24 | 25 | import { Room } from './room.js'; 26 | 27 | let Hooks = {}; 28 | Hooks.Room = Room; 29 | 30 | let csrfToken = document 31 | .querySelector("meta[name='csrf-token']") 32 | .getAttribute('content'); 33 | let liveSocket = new LiveSocket('/live', Socket, { 34 | params: { _csrf_token: csrfToken }, 35 | hooks: Hooks, 36 | }); 37 | 38 | // Show progress bar on live navigation and form submits 39 | topbar.config({ barColors: { 0: '#29d' }, shadowColor: 'rgba(0, 0, 0, .3)' }); 40 | window.addEventListener('phx:page-loading-start', (_info) => topbar.show(300)); 41 | window.addEventListener('phx:page-loading-stop', (_info) => topbar.hide()); 42 | 43 | // connect if there are any LiveViews on the page 44 | liveSocket.connect(); 45 | 46 | // expose liveSocket on window for web console debug logs and latency simulation: 47 | // >> liveSocket.enableDebug() 48 | // >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session 49 | // >> liveSocket.disableLatencySim() 50 | // window.liveSocket = liveSocket 51 | -------------------------------------------------------------------------------- /recognizer/assets/js/room.js: -------------------------------------------------------------------------------- 1 | import { Socket } from 'phoenix'; 2 | 3 | const locArray = window.location.pathname.split('/'); 4 | const roomId = locArray[locArray.length - 1]; 5 | 6 | const pcConfig = { iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] }; 7 | 8 | const videoPlayer = document.getElementById('videoPlayer'); 9 | const button = document.getElementById('leaveButton'); 10 | const imgpred = document.getElementById('imgpred'); 11 | const imgscore = document.getElementById('imgscore'); 12 | const time = document.getElementById('time'); 13 | 14 | let localStream; 15 | let socket; 16 | let channel; 17 | let pc; 18 | 19 | async function connect() { 20 | console.log('Connecting'); 21 | button.onclick = disconnect; 22 | 23 | localStream = await navigator.mediaDevices.getUserMedia({ 24 | audio: true, 25 | video: { 26 | width: { ideal: 320 }, 27 | height: { ideal: 160 }, 28 | frameRate: { ideal: 15 }, 29 | }, 30 | }); 31 | 32 | videoPlayer.srcObject = localStream; 33 | 34 | socket = new Socket('/socket', {}); 35 | socket.connect(); 36 | 37 | channel = socket.channel('room:' + roomId, {}); 38 | channel.onClose((_) => { 39 | window.location.href = '/'; 40 | }); 41 | 42 | channel 43 | .join() 44 | .receive('ok', (resp) => { 45 | console.log('Joined successfully', resp); 46 | }) 47 | .receive('error', (resp) => { 48 | console.log('Unable to join', resp); 49 | window.location.href = '/'; 50 | }); 51 | 52 | channel.on('signaling', (msg) => { 53 | if (msg.type == 'answer') { 54 | console.log('Setting remote answer'); 55 | pc.setRemoteDescription(msg); 56 | } else if (msg.type == 'ice') { 57 | console.log('Adding ICE candidate'); 58 | pc.addIceCandidate(msg.data); 59 | } 60 | }); 61 | 62 | channel.on('imgReco', (msg) => { 63 | const pred = msg['predictions'][0]; 64 | imgpred.innerText = pred['label']; 65 | imgscore.innerText = pred['score'].toFixed(3); 66 | }); 67 | 68 | channel.on('sessionTime', (msg) => { 69 | time.innerText = msg['time']; 70 | }); 71 | 72 | pc = new RTCPeerConnection(pcConfig); 73 | pc.onicecandidate = (ev) => { 74 | channel.push( 75 | 'signaling', 76 | JSON.stringify({ type: 'ice', data: ev.candidate }) 77 | ); 78 | }; 79 | pc.addTrack(localStream.getAudioTracks()[0]); 80 | pc.addTrack(localStream.getVideoTracks()[0]); 81 | 82 | const offer = await pc.createOffer(); 83 | await pc.setLocalDescription(offer); 84 | channel.push('signaling', JSON.stringify(offer)); 85 | } 86 | 87 | function disconnect() { 88 | console.log('Disconnecting'); 89 | localStream.getTracks().forEach((track) => track.stop()); 90 | videoPlayer.srcObject = null; 91 | 92 | if (typeof channel !== 'undefined') { 93 | channel.leave(); 94 | } 95 | 96 | if (typeof socket !== 'undefined') { 97 | socket.disconnect(); 98 | } 99 | 100 | if (typeof pc !== 'undefined') { 101 | pc.close(); 102 | } 103 | } 104 | 105 | export const Room = { 106 | mounted() { 107 | connect(); 108 | }, 109 | }; 110 | -------------------------------------------------------------------------------- /recognizer/assets/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "assets", 3 | "version": "0.1.0", 4 | "description": "", 5 | "main": "", 6 | "scripts": { 7 | "lint": "eslint 'js/**/*.js' --fix", 8 | "format": "prettier --write 'js/**/*.js' 'css/**/*.css'", 9 | "check": "prettier --check 'js/**/*.js' 'css/**/*.css'" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "Apache-2.0", 14 | "devDependencies": { 15 | "@eslint/eslintrc": "^3.1.0", 16 | "@eslint/js": "^9.7.0", 17 | "eslint": "^9.7.0", 18 | "eslint-config-prettier": "^9.1.0", 19 | "eslint-plugin-prettier": "^5.1.3", 20 | "globals": "^15.8.0", 21 | "prettier": "^3.3.3" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /recognizer/assets/tailwind.config.js: -------------------------------------------------------------------------------- 1 | // See the Tailwind configuration guide for advanced usage 2 | // https://tailwindcss.com/docs/configuration 3 | 4 | const plugin = require("tailwindcss/plugin") 5 | const fs = require("fs") 6 | const path = require("path") 7 | 8 | module.exports = { 9 | content: [ 10 | "./js/**/*.js", 11 | "../lib/recognizer_web.ex", 12 | "../lib/recognizer_web/**/*.*ex" 13 | ], 14 | theme: { 15 | extend: { 16 | colors: { 17 | brand: "#4339AC", 18 | } 19 | }, 20 | }, 21 | plugins: [ 22 | require("@tailwindcss/forms"), 23 | // Allows prefixing tailwind classes with LiveView classes to add rules 24 | // only when LiveView classes are applied, for example: 25 | // 26 | //
27 | // 28 | plugin(({addVariant}) => addVariant("phx-no-feedback", [".phx-no-feedback&", ".phx-no-feedback &"])), 29 | plugin(({addVariant}) => addVariant("phx-click-loading", [".phx-click-loading&", ".phx-click-loading &"])), 30 | plugin(({addVariant}) => addVariant("phx-submit-loading", [".phx-submit-loading&", ".phx-submit-loading &"])), 31 | plugin(({addVariant}) => addVariant("phx-change-loading", [".phx-change-loading&", ".phx-change-loading &"])), 32 | 33 | // Embeds Heroicons (https://heroicons.com) into your app.css bundle 34 | // See your `CoreComponents.icon/1` for more information. 35 | // 36 | plugin(function({matchComponents, theme}) { 37 | let iconsDir = path.join(__dirname, "../deps/heroicons/optimized") 38 | let values = {} 39 | let icons = [ 40 | ["", "/24/outline"], 41 | ["-solid", "/24/solid"], 42 | ["-mini", "/20/solid"] 43 | ] 44 | icons.forEach(([suffix, dir]) => { 45 | fs.readdirSync(path.join(iconsDir, dir)).forEach(file => { 46 | let name = path.basename(file, ".svg") + suffix 47 | values[name] = {name, fullPath: path.join(iconsDir, dir, file)} 48 | }) 49 | }) 50 | matchComponents({ 51 | "hero": ({name, fullPath}) => { 52 | let content = fs.readFileSync(fullPath).toString().replace(/\r?\n|\r/g, "") 53 | return { 54 | [`--hero-${name}`]: `url('data:image/svg+xml;utf8,${content}')`, 55 | "-webkit-mask": `var(--hero-${name})`, 56 | "mask": `var(--hero-${name})`, 57 | "mask-repeat": "no-repeat", 58 | "background-color": "currentColor", 59 | "vertical-align": "middle", 60 | "display": "inline-block", 61 | "width": theme("spacing.5"), 62 | "height": theme("spacing.5") 63 | } 64 | } 65 | }, {values}) 66 | }) 67 | ] 68 | } 69 | -------------------------------------------------------------------------------- /recognizer/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 | config :recognizer, 11 | generators: [timestamp_type: :utc_datetime] 12 | 13 | # Configures the endpoint 14 | config :recognizer, RecognizerWeb.Endpoint, 15 | url: [host: "localhost"], 16 | adapter: Phoenix.Endpoint.Cowboy2Adapter, 17 | render_errors: [ 18 | formats: [html: RecognizerWeb.ErrorHTML, json: RecognizerWeb.ErrorJSON], 19 | layout: false 20 | ], 21 | pubsub_server: Recognizer.PubSub, 22 | live_view: [signing_salt: "S8H9IjcD"] 23 | 24 | # Configure esbuild (the version is required) 25 | config :esbuild, 26 | version: "0.17.11", 27 | default: [ 28 | args: 29 | ~w(js/app.js js/room.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*), 30 | cd: Path.expand("../assets", __DIR__), 31 | env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} 32 | ] 33 | 34 | # Configure tailwind (the version is required) 35 | config :tailwind, 36 | version: "3.4.4", 37 | default: [ 38 | args: ~w( 39 | --config=tailwind.config.js 40 | --input=css/app.css 41 | --output=../priv/static/assets/app.css 42 | ), 43 | cd: Path.expand("../assets", __DIR__) 44 | ] 45 | 46 | # Configures Elixir's Logger 47 | config :logger, :console, 48 | format: "$time $metadata[$level] $message\n", 49 | metadata: [:request_id] 50 | 51 | # Use Jason for JSON parsing in Phoenix 52 | config :phoenix, :json_library, Jason 53 | 54 | config :nx, default_backend: EXLA.Backend 55 | 56 | config :recognizer, max_rooms: 5, max_session_time_s: 200 57 | 58 | # Import environment specific config. This must remain at the bottom 59 | # of this file so it overrides the configuration defined above. 60 | import_config "#{config_env()}.exs" 61 | -------------------------------------------------------------------------------- /recognizer/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 can use it 8 | # to bundle .js and .css sources. 9 | config :recognizer, RecognizerWeb.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: 4000], 13 | check_origin: false, 14 | code_reloader: true, 15 | debug_errors: true, 16 | secret_key_base: "ep2F4amvF/PSJgRn86LbwcxOOY841sKkNePN6O7zAOFtgCHKtIag091I7qqHxtaN", 17 | watchers: [ 18 | esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]}, 19 | tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]} 20 | ] 21 | 22 | # ## SSL Support 23 | # 24 | # In order to use HTTPS in development, a self-signed 25 | # certificate can be generated by running the following 26 | # Mix task: 27 | # 28 | # mix phx.gen.cert 29 | # 30 | # Run `mix help phx.gen.cert` for more information. 31 | # 32 | # The `http:` config above can be replaced with: 33 | # 34 | # https: [ 35 | # port: 4001, 36 | # cipher_suite: :strong, 37 | # keyfile: "priv/cert/selfsigned_key.pem", 38 | # certfile: "priv/cert/selfsigned.pem" 39 | # ], 40 | # 41 | # If desired, both `http:` and `https:` keys can be 42 | # configured to run both http and https servers on 43 | # different ports. 44 | 45 | # Watch static and templates for browser reloading. 46 | config :recognizer, RecognizerWeb.Endpoint, 47 | live_reload: [ 48 | patterns: [ 49 | ~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$", 50 | ~r"lib/recognizer_web/(controllers|live|components)/.*(ex|heex)$" 51 | ] 52 | ] 53 | 54 | config :recognizer, 55 | admin_username: "admin", 56 | admin_password: "admin" 57 | 58 | # Do not include metadata nor timestamps in development logs 59 | config :logger, :console, level: :info, format: "[$level] $message\n" 60 | 61 | # Set a higher stacktrace during development. Avoid configuring such 62 | # in production as building large stacktraces may be expensive. 63 | config :phoenix, :stacktrace_depth, 20 64 | 65 | # Initialize plugs at runtime for faster development compilation 66 | config :phoenix, :plug_init_mode, :runtime 67 | 68 | # Include HEEx debug annotations as HTML comments in rendered markup 69 | config :phoenix_live_view, :debug_heex_annotations, true 70 | -------------------------------------------------------------------------------- /recognizer/config/prod.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # Note we also include the path to a cache manifest 4 | # containing the digested version of static files. This 5 | # manifest is generated by the `mix assets.deploy` task, 6 | # which you should run after static files are built and 7 | # before starting your production server. 8 | config :recognizer, RecognizerWeb.Endpoint, 9 | cache_static_manifest: "priv/static/cache_manifest.json" 10 | 11 | # Do not print debug messages in production 12 | config :logger, level: :info 13 | 14 | # Runtime production configuration, including reading 15 | # of environment variables, is done on config/runtime.exs. 16 | -------------------------------------------------------------------------------- /recognizer/config/runtime.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # config/runtime.exs is executed for all environments, including 4 | # during releases. It is executed after compilation and before the 5 | # system starts, so it is typically used to load production configuration 6 | # and secrets from environment variables or elsewhere. Do not define 7 | # any compile-time configuration in here, as it won't be applied. 8 | # The block below contains prod specific runtime configuration. 9 | 10 | # ## Using releases 11 | # 12 | # If you use `mix release`, you need to explicitly enable the server 13 | # by passing the PHX_SERVER=true when you start it: 14 | # 15 | # PHX_SERVER=true bin/recognizer start 16 | # 17 | # Alternatively, you can use `mix phx.gen.release` to generate a `bin/server` 18 | # script that automatically sets the env var above. 19 | 20 | read_ice_port_range! = fn -> 21 | case System.get_env("ICE_PORT_RANGE") do 22 | nil -> 23 | [0] 24 | 25 | raw_port_range -> 26 | case String.split(raw_port_range, "-", parts: 2) do 27 | [from, to] -> String.to_integer(from)..String.to_integer(to) 28 | _other -> raise "ICE_PORT_RANGE has to be in form of FROM-TO, passed: #{raw_port_range}" 29 | end 30 | end 31 | end 32 | 33 | if System.get_env("PHX_SERVER") do 34 | config :recognizer, RecognizerWeb.Endpoint, server: true 35 | end 36 | 37 | config :recognizer, ice_port_range: read_ice_port_range!.() 38 | 39 | if config_env() == :prod do 40 | # The secret key base is used to sign/encrypt cookies and other secrets. 41 | # A default value is used in config/dev.exs and config/test.exs but you 42 | # want to use a different value for prod and you most likely don't want 43 | # to check this value into version control, so we use an environment 44 | # variable instead. 45 | secret_key_base = 46 | System.get_env("SECRET_KEY_BASE") || 47 | raise """ 48 | environment variable SECRET_KEY_BASE is missing. 49 | You can generate one by calling: mix phx.gen.secret 50 | """ 51 | 52 | host = System.get_env("PHX_HOST") || "example.com" 53 | port = String.to_integer(System.get_env("PORT") || "4000") 54 | 55 | config :recognizer, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY") 56 | 57 | config :recognizer, RecognizerWeb.Endpoint, 58 | url: [host: host, port: 443, scheme: "https"], 59 | http: [ 60 | # Enable IPv6 and bind on all interfaces. 61 | # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access. 62 | # See the documentation on https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html 63 | # for details about using IPv6 vs IPv4 and loopback vs public addresses. 64 | ip: {0, 0, 0, 0, 0, 0, 0, 0}, 65 | port: port 66 | ], 67 | secret_key_base: secret_key_base 68 | 69 | admin_username = 70 | System.get_env("ADMIN_USERNAME") || raise "Environment variable ADMIN_USERNAME is missing." 71 | 72 | admin_password = 73 | System.get_env("ADMIN_PASSWORD") || raise "Environment variable ADMIN_PASSWORD is missing." 74 | 75 | config :recognizer, 76 | admin_username: admin_username, 77 | admin_password: admin_password 78 | 79 | # ## SSL Support 80 | # 81 | # To get SSL working, you will need to add the `https` key 82 | # to your endpoint configuration: 83 | # 84 | # config :recognizer, RecognizerWeb.Endpoint, 85 | # https: [ 86 | # ..., 87 | # port: 443, 88 | # cipher_suite: :strong, 89 | # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), 90 | # certfile: System.get_env("SOME_APP_SSL_CERT_PATH") 91 | # ] 92 | # 93 | # The `cipher_suite` is set to `:strong` to support only the 94 | # latest and more secure SSL ciphers. This means old browsers 95 | # and clients may not be supported. You can set it to 96 | # `:compatible` for wider support. 97 | # 98 | # `:keyfile` and `:certfile` expect an absolute path to the key 99 | # and cert in disk or a relative path inside priv, for example 100 | # "priv/ssl/server.key". For all supported SSL configuration 101 | # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1 102 | # 103 | # We also recommend setting `force_ssl` in your endpoint, ensuring 104 | # no data is ever sent via http, always redirecting to https: 105 | # 106 | # config :recognizer, RecognizerWeb.Endpoint, 107 | # force_ssl: [hsts: true] 108 | # 109 | # Check `Plug.SSL` for all available options in `force_ssl`. 110 | end 111 | -------------------------------------------------------------------------------- /recognizer/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 :recognizer, RecognizerWeb.Endpoint, 6 | http: [ip: {127, 0, 0, 1}, port: 4002], 7 | secret_key_base: "+3zS/OtReHyGngv8VDR/RTkLA5k4+MJMAGZa4PAQYMedOzfT9h/HkwEbXtlyw/g3", 8 | server: false 9 | 10 | # Print only warnings and errors during test 11 | config :logger, level: :warning 12 | 13 | # Initialize plugs at runtime for faster test compilation 14 | config :phoenix, :plug_init_mode, :runtime 15 | -------------------------------------------------------------------------------- /recognizer/lib/recognizer.ex: -------------------------------------------------------------------------------- 1 | defmodule Recognizer do 2 | @moduledoc """ 3 | Recognizer 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 | -------------------------------------------------------------------------------- /recognizer/lib/recognizer/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Recognizer.Application do 2 | # See https://hexdocs.pm/elixir/Application.html 3 | # for more information on OTP Applications 4 | @moduledoc false 5 | alias Recognizer.Lobby 6 | 7 | use Application 8 | 9 | @max_rooms Application.compile_env!(:recognizer, :max_rooms) 10 | @version Mix.Project.config()[:version] 11 | 12 | @spec version() :: String.t() 13 | def version(), do: @version 14 | 15 | @impl true 16 | def start(_type, _args) do 17 | children = [ 18 | RecognizerWeb.Telemetry, 19 | {DNSCluster, query: Application.get_env(:recognizer, :dns_cluster_query) || :ignore}, 20 | {Phoenix.PubSub, name: Recognizer.PubSub}, 21 | # Start a worker by calling: Recognizer.Worker.start_link(arg) 22 | # {Recognizer.Worker, arg}, 23 | # Start to serve requests, typically the last entry 24 | RecognizerWeb.Endpoint, 25 | {Registry, keys: :unique, name: Recognizer.RoomRegistry}, 26 | {DynamicSupervisor, name: Recognizer.RoomSupervisor, strategy: :one_for_one}, 27 | {Nx.Serving, 28 | serving: create_video_serving(), 29 | name: Recognizer.VideoServing, 30 | batch_size: 4, 31 | batch_timeout: 100}, 32 | {Lobby, @max_rooms} 33 | ] 34 | 35 | # See https://hexdocs.pm/elixir/Supervisor.html 36 | # for other strategies and supported options 37 | opts = [strategy: :one_for_one, name: Recognizer.Supervisor] 38 | Supervisor.start_link(children, opts) 39 | end 40 | 41 | # Tell Phoenix to update the endpoint configuration 42 | # whenever the application is updated. 43 | @impl true 44 | def config_change(changed, _new, removed) do 45 | RecognizerWeb.Endpoint.config_change(changed, removed) 46 | :ok 47 | end 48 | 49 | defp create_video_serving() do 50 | {:ok, model} = Bumblebee.load_model({:hf, "microsoft/resnet-50"}) 51 | {:ok, featurizer} = Bumblebee.load_featurizer({:hf, "microsoft/resnet-50"}) 52 | 53 | Bumblebee.Vision.image_classification(model, featurizer, 54 | top_k: 1, 55 | compile: [batch_size: 4], 56 | defn_options: [compiler: EXLA] 57 | ) 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /recognizer/lib/recognizer/lobby.ex: -------------------------------------------------------------------------------- 1 | defmodule Recognizer.Lobby do 2 | @moduledoc false 3 | 4 | use GenServer 5 | 6 | require Logger 7 | 8 | alias Recognizer.Room 9 | 10 | def start_link(max_rooms) do 11 | GenServer.start_link(__MODULE__, max_rooms, name: __MODULE__) 12 | end 13 | 14 | def get_room() do 15 | GenServer.call(__MODULE__, :get_room) 16 | end 17 | 18 | @impl true 19 | def init(max_rooms) do 20 | {:ok, %{queue: :queue.new(), rooms: MapSet.new(), max_rooms: max_rooms}} 21 | end 22 | 23 | @impl true 24 | def handle_call(:get_room, {from, _tag}, state) do 25 | _ref = Process.monitor(from) 26 | 27 | if MapSet.size(state.rooms) >= state.max_rooms do 28 | queue = :queue.in(from, state.queue) 29 | state = %{state | queue: queue} 30 | position = :queue.len(queue) 31 | {:reply, {:error, :max_rooms, position}, state} 32 | else 33 | {id, state} = create_room(state) 34 | {:reply, {:ok, id}, state} 35 | end 36 | end 37 | 38 | @impl true 39 | def handle_info({:DOWN, _ref, :process, pid, _reason}, state) do 40 | cond do 41 | MapSet.member?(state.rooms, pid) -> 42 | rooms = MapSet.delete(state.rooms, pid) 43 | state = %{state | rooms: rooms} 44 | 45 | case :queue.out(state.queue) do 46 | {{:value, pid}, queue} -> 47 | state = %{state | queue: queue} 48 | {id, state} = create_room(state) 49 | send(pid, {:room, id}) 50 | send_positions(state) 51 | {:noreply, state} 52 | 53 | {:empty, queue} -> 54 | state = %{state | queue: queue} 55 | {:noreply, state} 56 | end 57 | 58 | :queue.member(pid, state.queue) == true -> 59 | queue = :queue.delete(pid, state.queue) 60 | state = %{state | queue: queue} 61 | send_positions(state) 62 | {:noreply, state} 63 | 64 | true -> 65 | Logger.warning("Unexpected DOWN message from pid: #{inspect(pid)}") 66 | {:noreply, state} 67 | end 68 | end 69 | 70 | defp create_room(state) do 71 | Logger.info("Creating a new room") 72 | <> = :crypto.strong_rand_bytes(12) 73 | {:ok, pid} = DynamicSupervisor.start_child(Recognizer.RoomSupervisor, {Room, id}) 74 | Process.monitor(pid) 75 | rooms = MapSet.put(state.rooms, pid) 76 | state = %{state | rooms: rooms} 77 | {id, state} 78 | end 79 | 80 | defp send_positions(state) do 81 | :queue.to_list(state.queue) 82 | |> Stream.with_index() 83 | |> Enum.each(fn {pid, idx} -> send(pid, {:position, idx + 1}) end) 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /recognizer/lib/recognizer_web.ex: -------------------------------------------------------------------------------- 1 | defmodule RecognizerWeb 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 RecognizerWeb, :controller 9 | use RecognizerWeb, :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 | def static_paths, do: ~w(assets fonts images favicon.ico robots.txt) 21 | 22 | def router do 23 | quote do 24 | use Phoenix.Router, helpers: false 25 | 26 | # Import common connection and controller functions to use in pipelines 27 | import Plug.Conn 28 | import Phoenix.Controller 29 | import Phoenix.LiveView.Router 30 | end 31 | end 32 | 33 | def channel do 34 | quote do 35 | use Phoenix.Channel 36 | end 37 | end 38 | 39 | def controller do 40 | quote do 41 | use Phoenix.Controller, 42 | formats: [:html, :json], 43 | layouts: [html: RecognizerWeb.Layouts] 44 | 45 | import Plug.Conn 46 | 47 | unquote(verified_routes()) 48 | end 49 | end 50 | 51 | def live_view do 52 | quote do 53 | use Phoenix.LiveView, 54 | layout: {RecognizerWeb.Layouts, :app} 55 | 56 | unquote(html_helpers()) 57 | end 58 | end 59 | 60 | def live_component do 61 | quote do 62 | use Phoenix.LiveComponent 63 | 64 | unquote(html_helpers()) 65 | end 66 | end 67 | 68 | def html do 69 | quote do 70 | use Phoenix.Component 71 | 72 | # Import convenience functions from controllers 73 | import Phoenix.Controller, 74 | only: [get_csrf_token: 0, view_module: 1, view_template: 1] 75 | 76 | # Include general helpers for rendering HTML 77 | unquote(html_helpers()) 78 | end 79 | end 80 | 81 | defp html_helpers do 82 | quote do 83 | # HTML escaping functionality 84 | import Phoenix.HTML 85 | # Core UI components and translation 86 | import RecognizerWeb.CoreComponents 87 | 88 | # Shortcut for generating JS commands 89 | alias Phoenix.LiveView.JS 90 | 91 | # Routes generation with the ~p sigil 92 | unquote(verified_routes()) 93 | end 94 | end 95 | 96 | def verified_routes do 97 | quote do 98 | use Phoenix.VerifiedRoutes, 99 | endpoint: RecognizerWeb.Endpoint, 100 | router: RecognizerWeb.Router, 101 | statics: RecognizerWeb.static_paths() 102 | end 103 | end 104 | 105 | @doc """ 106 | When used, dispatch to the appropriate controller/view/etc. 107 | """ 108 | defmacro __using__(which) when is_atom(which) do 109 | apply(__MODULE__, which, []) 110 | end 111 | end 112 | -------------------------------------------------------------------------------- /recognizer/lib/recognizer_web/channels/room_channel.ex: -------------------------------------------------------------------------------- 1 | defmodule RecognizerWeb.RoomChannel do 2 | @moduledoc false 3 | 4 | use Phoenix.Channel, restart: :temporary 5 | 6 | require Logger 7 | 8 | alias Recognizer.Room 9 | 10 | def join("room:" <> id, _message, socket) do 11 | id = String.to_integer(id) 12 | :ok = Room.connect(id, self()) 13 | {:ok, assign(socket, :room_id, id)} 14 | end 15 | 16 | def handle_in("signaling", msg, socket) do 17 | :ok = Room.receive_signaling_msg(socket.assigns.room_id, msg) 18 | {:noreply, socket} 19 | end 20 | 21 | def handle_info({:signaling, msg}, socket) do 22 | push(socket, "signaling", msg) 23 | {:noreply, socket} 24 | end 25 | 26 | def handle_info({:img_reco, msg}, socket) do 27 | push(socket, "imgReco", msg) 28 | {:noreply, socket} 29 | end 30 | 31 | def handle_info({:session_time, session_time}, socket) do 32 | push(socket, "sessionTime", %{time: session_time}) 33 | {:noreply, socket} 34 | end 35 | 36 | def handle_info(:session_expired, socket) do 37 | {:stop, {:shutdown, :session_expired}, socket} 38 | end 39 | 40 | def terminate(reason, _socket) do 41 | Logger.info("Stopping Phoenix chnannel, reason: #{inspect(reason)}.") 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /recognizer/lib/recognizer_web/components/layouts.ex: -------------------------------------------------------------------------------- 1 | defmodule RecognizerWeb.Layouts do 2 | use RecognizerWeb, :html 3 | 4 | embed_templates "layouts/*" 5 | end 6 | -------------------------------------------------------------------------------- /recognizer/lib/recognizer_web/components/layouts/app.html.heex: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 5 | 6 | 7 |
8 | 21 |
22 |
23 |
24 |
25 | <.flash_group flash={@flash} /> 26 | <%= @inner_content %> 27 |
28 |
29 |
30 | <%= Recognizer.Application.version() %> 31 |
32 | -------------------------------------------------------------------------------- /recognizer/lib/recognizer_web/components/layouts/root.html.heex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | <.live_title suffix=" · Recognizer"> 8 | <%= assigns[:page_title] %> 9 | 10 | 11 | 13 | 14 | 15 | <%= @inner_content %> 16 | 17 | 18 | -------------------------------------------------------------------------------- /recognizer/lib/recognizer_web/controllers/error_html.ex: -------------------------------------------------------------------------------- 1 | defmodule RecognizerWeb.ErrorHTML do 2 | use RecognizerWeb, :html 3 | 4 | # If you want to customize your error pages, 5 | # uncomment the embed_templates/1 call below 6 | # and add pages to the error directory: 7 | # 8 | # * lib/recognizer_web/controllers/error_html/404.html.heex 9 | # * lib/recognizer_web/controllers/error_html/500.html.heex 10 | # 11 | # embed_templates "error_html/*" 12 | 13 | # The default is to render a plain text page based on 14 | # the template name. For example, "404.html" becomes 15 | # "Not Found". 16 | def render(template, _assigns) do 17 | Phoenix.Controller.status_message_from_template(template) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /recognizer/lib/recognizer_web/controllers/error_json.ex: -------------------------------------------------------------------------------- 1 | defmodule RecognizerWeb.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 | def render(template, _assigns) do 13 | %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}} 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /recognizer/lib/recognizer_web/controllers/room_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule RecognizerWeb.RoomController do 2 | use RecognizerWeb, :controller 3 | 4 | def room(conn, _params) do 5 | render(conn, :room, page_title: "Room") 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /recognizer/lib/recognizer_web/controllers/room_html.ex: -------------------------------------------------------------------------------- 1 | defmodule RecognizerWeb.RoomHTML do 2 | use RecognizerWeb, :html 3 | 4 | embed_templates "room_html/*" 5 | end 6 | -------------------------------------------------------------------------------- /recognizer/lib/recognizer_web/controllers/room_html/room.html.heex: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 |
5 |
6 |
Remaining time
7 |
11 |
12 |
13 | 14 |
15 |
Score
16 |
20 |
21 |
22 |
23 | 24 |
25 |
Prediction
26 |
30 |
31 |
32 | 33 | 39 |
40 | -------------------------------------------------------------------------------- /recognizer/lib/recognizer_web/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule RecognizerWeb.Endpoint do 2 | use Phoenix.Endpoint, otp_app: :recognizer 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: "_recognizer_key", 10 | signing_salt: "nEL+BRvh", 11 | same_site: "Lax" 12 | ] 13 | 14 | socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]] 15 | 16 | socket "/socket", RecognizerWeb.RoomSocket, 17 | websocket: true, 18 | longpoll: false 19 | 20 | # Serve at "/" the static files from "priv/static" directory. 21 | # 22 | # You should set gzip to true if you are running phx.digest 23 | # when deploying your static files in production. 24 | plug Plug.Static, 25 | at: "/", 26 | from: :recognizer, 27 | gzip: false, 28 | only: RecognizerWeb.static_paths() 29 | 30 | # Code reloading can be explicitly enabled under the 31 | # :code_reloader configuration of your endpoint. 32 | if code_reloading? do 33 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket 34 | plug Phoenix.LiveReloader 35 | plug Phoenix.CodeReloader 36 | end 37 | 38 | plug Phoenix.LiveDashboard.RequestLogger, 39 | param_key: "request_logger", 40 | cookie_key: "request_logger" 41 | 42 | plug Plug.RequestId 43 | plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] 44 | 45 | plug Plug.Parsers, 46 | parsers: [:urlencoded, :multipart, :json], 47 | pass: ["*/*"], 48 | json_decoder: Phoenix.json_library() 49 | 50 | plug Plug.MethodOverride 51 | plug Plug.Head 52 | plug Plug.Session, @session_options 53 | plug RecognizerWeb.Router 54 | end 55 | -------------------------------------------------------------------------------- /recognizer/lib/recognizer_web/live/lobby_live.ex: -------------------------------------------------------------------------------- 1 | defmodule RecognizerWeb.LobbyLive do 2 | use Phoenix.LiveView, 3 | container: {:div, class: "contents"}, 4 | layout: {RecognizerWeb.Layouts, :app} 5 | 6 | alias Recognizer.Lobby 7 | 8 | @session_time 200 9 | @eta_update_interval_ms 1000 10 | 11 | def render(assigns) do 12 | ~H""" 13 |
14 |
15 |

16 | Whoops!

Looks like our servers are experiencing pretty high load! 17 | We put you in the queue and will redirect you once it's your turn.

18 | You are <%= @position %> in the queue. 19 | ETA: <%= @eta %> seconds. 20 |

21 |
22 |
23 | """ 24 | end 25 | 26 | def mount(_params, _session, socket) do 27 | case Lobby.get_room() do 28 | {:ok, room_id} -> 29 | {:ok, push_navigate(socket, to: "/room/#{room_id}")} 30 | 31 | {:error, :max_rooms, position} -> 32 | Process.send_after(self(), :update_eta, @eta_update_interval_ms) 33 | socket = assign(socket, page_title: "Lobby") 34 | socket = assign(socket, position: position) 35 | socket = assign(socket, eta: position * @session_time) 36 | socket = assign(socket, last_check: System.monotonic_time(:millisecond)) 37 | {:ok, socket} 38 | end 39 | end 40 | 41 | def handle_info({:position, position}, socket) do 42 | socket = assign(socket, position: position) 43 | socket = assign(socket, eta: position * @session_time) 44 | {:noreply, socket} 45 | end 46 | 47 | def handle_info({:room, room_id}, socket) do 48 | {:noreply, push_navigate(socket, to: "/room/#{room_id}")} 49 | end 50 | 51 | def handle_info(:update_eta, socket) do 52 | Process.send_after(self(), :update_eta, @eta_update_interval_ms) 53 | now = System.monotonic_time(:millisecond) 54 | elapsed = floor((now - socket.assigns.last_check) / 1000) 55 | eta = max(0, socket.assigns.eta - elapsed) 56 | socket = assign(socket, eta: eta) 57 | socket = assign(socket, last_check: now) 58 | {:noreply, socket} 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /recognizer/lib/recognizer_web/live/recognizer_live.ex: -------------------------------------------------------------------------------- 1 | defmodule RecognizerWeb.RecognizerLive do 2 | use Phoenix.LiveView, 3 | container: {:div, class: "contents"}, 4 | layout: {RecognizerWeb.Layouts, :app} 5 | 6 | def render(assigns) do 7 | ~H""" 8 | 15 | """ 16 | end 17 | 18 | def mount(_params, _session, socket) do 19 | socket = assign(socket, :page_title, "Home") 20 | {:ok, socket} 21 | end 22 | 23 | def handle_event("start", _params, socket) do 24 | {:noreply, push_navigate(socket, to: "/lobby")} 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /recognizer/lib/recognizer_web/room_socket.ex: -------------------------------------------------------------------------------- 1 | defmodule RecognizerWeb.RoomSocket do 2 | use Phoenix.Socket 3 | 4 | channel "room:*", RecognizerWeb.RoomChannel 5 | 6 | def connect(_params, socket, _connect_info) do 7 | {:ok, socket} 8 | end 9 | 10 | def id(_socket), do: nil 11 | end 12 | -------------------------------------------------------------------------------- /recognizer/lib/recognizer_web/router.ex: -------------------------------------------------------------------------------- 1 | defmodule RecognizerWeb.Router do 2 | use RecognizerWeb, :router 3 | 4 | import Phoenix.LiveDashboard.Router 5 | 6 | pipeline :browser do 7 | plug :accepts, ["html"] 8 | plug :fetch_session 9 | plug :fetch_live_flash 10 | plug :put_root_layout, html: {RecognizerWeb.Layouts, :root} 11 | plug :protect_from_forgery 12 | plug :put_secure_browser_headers 13 | end 14 | 15 | pipeline :auth do 16 | plug :admin_auth 17 | end 18 | 19 | scope "/", RecognizerWeb do 20 | pipe_through :browser 21 | 22 | live "/", RecognizerLive 23 | live "/lobby", LobbyLive 24 | get "/room/:room_id", RoomController, :room 25 | end 26 | 27 | scope "/admin", RecognizerWeb do 28 | pipe_through :auth 29 | pipe_through :browser 30 | 31 | live_dashboard "/dashboard", 32 | metrics: RecognizerWeb.Telemetry, 33 | additional_pages: [exwebrtc: ExWebRTCDashboard] 34 | end 35 | 36 | defp admin_auth(conn, _opts) do 37 | username = Application.fetch_env!(:recognizer, :admin_username) 38 | password = Application.fetch_env!(:recognizer, :admin_password) 39 | Plug.BasicAuth.basic_auth(conn, username: username, password: password) 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /recognizer/lib/recognizer_web/telemetry.ex: -------------------------------------------------------------------------------- 1 | defmodule RecognizerWeb.Telemetry do 2 | use Supervisor 3 | import Telemetry.Metrics 4 | 5 | def start_link(arg) do 6 | Supervisor.start_link(__MODULE__, arg, name: __MODULE__) 7 | end 8 | 9 | @impl true 10 | def init(_arg) do 11 | children = [ 12 | # Telemetry poller will execute the given period measurements 13 | # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics 14 | {:telemetry_poller, measurements: periodic_measurements(), period: 10_000} 15 | # Add reporters as children of your supervision tree. 16 | # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()} 17 | ] 18 | 19 | Supervisor.init(children, strategy: :one_for_one) 20 | end 21 | 22 | def metrics do 23 | [ 24 | # Phoenix Metrics 25 | summary("phoenix.endpoint.start.system_time", 26 | unit: {:native, :millisecond} 27 | ), 28 | summary("phoenix.endpoint.stop.duration", 29 | unit: {:native, :millisecond} 30 | ), 31 | summary("phoenix.router_dispatch.start.system_time", 32 | tags: [:route], 33 | unit: {:native, :millisecond} 34 | ), 35 | summary("phoenix.router_dispatch.exception.duration", 36 | tags: [:route], 37 | unit: {:native, :millisecond} 38 | ), 39 | summary("phoenix.router_dispatch.stop.duration", 40 | tags: [:route], 41 | unit: {:native, :millisecond} 42 | ), 43 | summary("phoenix.socket_connected.duration", 44 | unit: {:native, :millisecond} 45 | ), 46 | summary("phoenix.channel_joined.duration", 47 | unit: {:native, :millisecond} 48 | ), 49 | summary("phoenix.channel_handled_in.duration", 50 | tags: [:event], 51 | unit: {:native, :millisecond} 52 | ), 53 | 54 | # VM Metrics 55 | summary("vm.memory.total", unit: {:byte, :kilobyte}), 56 | summary("vm.total_run_queue_lengths.total"), 57 | summary("vm.total_run_queue_lengths.cpu"), 58 | summary("vm.total_run_queue_lengths.io") 59 | ] 60 | end 61 | 62 | defp periodic_measurements do 63 | [ 64 | # A module, function and arguments to be invoked periodically. 65 | # This function must call :telemetry.execute/3 and a metric must be added above. 66 | # {RecognizerWeb, :count_users, []} 67 | ] 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /recognizer/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Recognizer.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :recognizer, 7 | version: "0.6.0", 8 | elixir: "~> 1.14", 9 | elixirc_paths: elixirc_paths(Mix.env()), 10 | start_permanent: Mix.env() == :prod, 11 | aliases: aliases(), 12 | deps: deps(), 13 | 14 | # dialyzer 15 | dialyzer: [ 16 | plt_local_path: "_dialyzer", 17 | plt_core_path: "_dialyzer" 18 | ] 19 | ] 20 | end 21 | 22 | # Configuration for the OTP application. 23 | # 24 | # Type `mix help compile.app` for more information. 25 | def application do 26 | [ 27 | mod: {Recognizer.Application, []}, 28 | extra_applications: [:logger, :runtime_tools] 29 | ] 30 | end 31 | 32 | # Specifies which paths to compile per environment. 33 | defp elixirc_paths(:test), do: ["lib", "test/support"] 34 | defp elixirc_paths(_), do: ["lib"] 35 | 36 | # Specifies your project dependencies. 37 | # 38 | # Type `mix help deps` for examples and options. 39 | defp deps do 40 | [ 41 | {:phoenix, "~> 1.7.10"}, 42 | {:phoenix_html, "~> 3.3"}, 43 | {:phoenix_live_reload, "~> 1.2", only: :dev}, 44 | {:phoenix_live_view, "~> 0.20.1"}, 45 | {:floki, ">= 0.30.0", only: :test}, 46 | {:phoenix_live_dashboard, "~> 0.8.2"}, 47 | {:esbuild, "~> 0.8", runtime: Mix.env() == :dev}, 48 | {:tailwind, "~> 0.2.0", runtime: Mix.env() == :dev}, 49 | {:heroicons, 50 | github: "tailwindlabs/heroicons", 51 | tag: "v2.1.1", 52 | sparse: "optimized", 53 | app: false, 54 | compile: false, 55 | depth: 1}, 56 | {:telemetry_metrics, "~> 0.6"}, 57 | {:telemetry_poller, "~> 1.0"}, 58 | {:jason, "~> 1.2"}, 59 | {:dns_cluster, "~> 0.1.1"}, 60 | {:plug_cowboy, "~> 2.5"}, 61 | {:ex_webrtc, "~> 0.8.0"}, 62 | {:ex_webrtc_dashboard, "~> 0.8.0"}, 63 | {:xav, "~> 0.8.0"}, 64 | {:bumblebee, "~> 0.5.3"}, 65 | {:exla, "~> 0.7.1"}, 66 | 67 | # Dialyzer and credo 68 | {:dialyxir, ">= 0.0.0", only: :dev, runtime: false}, 69 | {:credo, ">= 0.0.0", only: :dev, runtime: false} 70 | ] 71 | end 72 | 73 | # Aliases are shortcuts or tasks specific to the current project. 74 | # For example, to install project dependencies and perform other setup tasks, run: 75 | # 76 | # $ mix setup 77 | # 78 | # See the documentation for `Mix` for more info on aliases. 79 | defp aliases do 80 | [ 81 | setup: ["deps.get", "assets.setup", "assets.build"], 82 | "assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"], 83 | "assets.build": ["tailwind default", "esbuild default"], 84 | "assets.deploy": ["tailwind default --minify", "esbuild default --minify", "phx.digest"], 85 | "assets.format": &lint_and_format_assets/1, 86 | "assets.check": &check_assets/1 87 | ] 88 | end 89 | 90 | defp lint_and_format_assets(_args) do 91 | with {_, 0} <- execute_npm_command(["ci"]), 92 | {_, 0} <- execute_npm_command(["run", "lint"]), 93 | {_, 0} <- execute_npm_command(["run", "format"]) do 94 | :ok 95 | else 96 | {cmd, rc} -> 97 | Mix.shell().error("npm command `#{Enum.join(cmd, " ")}` failed with code #{rc}") 98 | exit({:shutdown, rc}) 99 | end 100 | end 101 | 102 | defp check_assets(_args) do 103 | with {_, 0} <- execute_npm_command(["ci"]), 104 | {_, 0} <- execute_npm_command(["run", "check"]) do 105 | :ok 106 | else 107 | {cmd, rc} -> 108 | Mix.shell().error("npm command `#{Enum.join(cmd, " ")}` failed with code #{rc}") 109 | exit({:shutdown, rc}) 110 | end 111 | end 112 | 113 | defp execute_npm_command(command) do 114 | {_stream, rc} = System.cmd("npm", ["--prefix=assets"] ++ command, into: IO.stream()) 115 | {command, rc} 116 | end 117 | end 118 | -------------------------------------------------------------------------------- /recognizer/priv/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elixir-webrtc/apps/4502fee12b35b1e9e0b64d613dae70b63a07e78d/recognizer/priv/static/favicon.ico -------------------------------------------------------------------------------- /recognizer/priv/static/robots.txt: -------------------------------------------------------------------------------- 1 | # See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | # 3 | # To ban all spiders from the entire site uncomment the next two lines: 4 | # User-agent: * 5 | # Disallow: / 6 | -------------------------------------------------------------------------------- /recognizer/rel/overlays/bin/server: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -eu 3 | 4 | cd -P -- "$(dirname -- "$0")" 5 | PHX_SERVER=true exec ./recognizer start 6 | -------------------------------------------------------------------------------- /recognizer/rel/overlays/bin/server.bat: -------------------------------------------------------------------------------- 1 | set PHX_SERVER=true 2 | call "%~dp0\recognizer" start 3 | -------------------------------------------------------------------------------- /recognizer/test/recognizer_web/controllers/error_html_test.exs: -------------------------------------------------------------------------------- 1 | defmodule RecognizerWeb.ErrorHTMLTest do 2 | use RecognizerWeb.ConnCase, async: true 3 | 4 | # Bring render_to_string/4 for testing custom views 5 | import Phoenix.Template 6 | 7 | test "renders 404.html" do 8 | assert render_to_string(RecognizerWeb.ErrorHTML, "404", "html", []) == "Not Found" 9 | end 10 | 11 | test "renders 500.html" do 12 | assert render_to_string(RecognizerWeb.ErrorHTML, "500", "html", []) == "Internal Server Error" 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /recognizer/test/recognizer_web/controllers/error_json_test.exs: -------------------------------------------------------------------------------- 1 | defmodule RecognizerWeb.ErrorJSONTest do 2 | use RecognizerWeb.ConnCase, async: true 3 | 4 | test "renders 404" do 5 | assert RecognizerWeb.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}} 6 | end 7 | 8 | test "renders 500" do 9 | assert RecognizerWeb.ErrorJSON.render("500.json", %{}) == 10 | %{errors: %{detail: "Internal Server Error"}} 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /recognizer/test/recognizer_web/controllers/page_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule RecognizerWeb.PageControllerTest do 2 | use RecognizerWeb.ConnCase 3 | 4 | test "GET /", %{conn: conn} do 5 | conn = get(conn, ~p"/") 6 | assert html_response(conn, 200) =~ "Peace of mind from prototype to production" 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /recognizer/test/support/conn_case.ex: -------------------------------------------------------------------------------- 1 | defmodule RecognizerWeb.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 RecognizerWeb.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 RecognizerWeb.Endpoint 24 | 25 | use RecognizerWeb, :verified_routes 26 | 27 | # Import conveniences for testing with connections 28 | import Plug.Conn 29 | import Phoenix.ConnTest 30 | import RecognizerWeb.ConnCase 31 | end 32 | end 33 | 34 | setup _tags do 35 | {:ok, conn: Phoenix.ConnTest.build_conn()} 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /recognizer/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------