diff --git a/.github/workflows/k8s_broadcaster_cd.yml b/.github/workflows/k8s_broadcaster_cd.yml
new file mode 100644
index 0000000..3e766ac
--- /dev/null
+++ b/.github/workflows/k8s_broadcaster_cd.yml
@@ -0,0 +1,53 @@
+name: K8sBroadcaster CD
+
+on:
+ push:
+ tags:
+ - "v*.*.*"
+
+permissions:
+ contents: read
+ packages: write
+
+env:
+ REGISTRY: ghcr.io
+ APP_NAME: k8s_broadcaster
+
+jobs:
+ build-publish-image:
+ name: "Build and publish image"
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout the code
+ uses: actions/checkout@v4
+ with:
+ sparse-checkout: ${{ env.APP_NAME }}
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v2
+
+ - name: Login to Container Registry
+ uses: docker/login-action@v2
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Extract metadata (tags, labels) for Docker
+ id: meta
+ uses: docker/metadata-action@v4
+ with:
+ images: ${{ env.REGISTRY }}/${{ github.repository }}/${{ env.APP_NAME }}
+ tags: type=match,pattern=${{ env.APP_NAME }}-v(.*),group=1
+
+ - name: Build and push Docker image
+ uses: docker/build-push-action@v4
+ with:
+ context: ./${{ env.APP_NAME }}
+ platforms: linux/amd64
+ push: true
+ tags: ${{ steps.meta.outputs.tags }}
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
+
+
\ No newline at end of file
diff --git a/k8s_broadcaster/.formatter.exs b/k8s_broadcaster/.formatter.exs
new file mode 100644
index 0000000..e945e12
--- /dev/null
+++ b/k8s_broadcaster/.formatter.exs
@@ -0,0 +1,5 @@
+[
+ import_deps: [:phoenix],
+ plugins: [Phoenix.LiveView.HTMLFormatter],
+ inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}"]
+]
diff --git a/k8s_broadcaster/.gitignore b/k8s_broadcaster/.gitignore
new file mode 100644
index 0000000..a34dcdd
--- /dev/null
+++ b/k8s_broadcaster/.gitignore
@@ -0,0 +1,37 @@
+# The directory Mix will write compiled artifacts to.
+/_build/
+
+# If you run "mix test --cover", coverage assets end up here.
+/cover/
+
+# The directory Mix downloads your dependencies sources to.
+/deps/
+
+# Where 3rd-party dependencies like ExDoc output generated docs.
+/doc/
+
+# Ignore .fetch files in case you like to edit your project deps locally.
+/.fetch
+
+# If the VM crashes, it generates a dump, let's ignore it too.
+erl_crash.dump
+
+# Also ignore archive artifacts (built via "mix archive.build").
+*.ez
+
+# Temporary files, for example, from tests.
+/tmp/
+
+# Ignore package tarball (built via "mix hex.build").
+k8s_broadcaster-*.tar
+
+# Ignore assets that are produced by build tools.
+/priv/static/assets/
+
+# Ignore digested assets cache.
+/priv/static/cache_manifest.json
+
+# In case you use Node.js/npm, you want to ignore these.
+npm-debug.log
+/assets/node_modules/
+
diff --git a/k8s_broadcaster/Dockerfile b/k8s_broadcaster/Dockerfile
new file mode 100644
index 0000000..a82e5d1
--- /dev/null
+++ b/k8s_broadcaster/Dockerfile
@@ -0,0 +1,105 @@
+# Find eligible builder and runner images on Docker Hub. We use Ubuntu/Debian
+# instead of Alpine to avoid DNS resolution issues in production.
+#
+# https://hub.docker.com/r/hexpm/elixir/tags?page=1&name=ubuntu
+# https://hub.docker.com/_/ubuntu?tab=tags
+#
+# This file is based on these images:
+#
+# - https://hub.docker.com/r/hexpm/elixir/tags - for the build image
+# - https://hub.docker.com/_/debian?tab=tags&page=1&name=bullseye-20231009-slim - for the release image
+# - https://pkgs.org/ - resource for finding needed packages
+# - Ex: hexpm/elixir:1.16.0-erlang-26.2.1-debian-bullseye-20231009-slim
+#
+ARG ELIXIR_VERSION=1.17.2
+ARG OTP_VERSION=27.0.1
+ARG DEBIAN_VERSION=bookworm-20240701-slim
+
+ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}"
+ARG RUNNER_IMAGE="debian:${DEBIAN_VERSION}"
+
+FROM ${BUILDER_IMAGE} AS builder
+
+ARG TARGETPLATFORM
+RUN echo "Building for $TARGETPLATFORM"
+
+# install build dependencies
+RUN apt-get update -y && apt-get install -y build-essential git pkg-config libssl-dev
+# ex_srtp doesn't include precompiled libsrtp2 for ARM64 linux
+RUN if [ "$TARGETPLATFORM" = "linux/arm64" ]; then apt-get install -y libsrtp2-dev; fi
+RUN apt-get clean && rm -f /var/lib/apt/lists/*_*
+
+# prepare build dir
+WORKDIR /app
+
+# install hex + rebar
+RUN mix local.hex --force && \
+ mix local.rebar --force
+
+# set build ENV
+ENV MIX_ENV="prod"
+
+# install mix dependencies
+COPY mix.exs mix.lock ./
+RUN mix deps.get --only $MIX_ENV
+RUN mkdir config
+
+# copy compile-time config files before we compile dependencies
+# to ensure any relevant config change will trigger the dependencies
+# to be re-compiled.
+COPY config/config.exs config/${MIX_ENV}.exs config/
+RUN mix deps.compile
+
+COPY priv priv
+
+COPY lib lib
+
+COPY assets assets
+
+# compile assets
+RUN mix assets.deploy
+
+# Compile the release
+RUN mix compile
+
+# Changes to config/runtime.exs don't require recompiling the code
+COPY config/runtime.exs config/
+
+COPY rel rel
+RUN mix release
+
+# start a new build stage so that the final image will only contain
+# the compiled release and other runtime necessities
+FROM ${RUNNER_IMAGE}
+
+ARG TARGETPLATFORM
+RUN echo "Building for $TARGETPLATFORM"
+
+RUN apt-get update -y && apt-get install -y libstdc++6 openssl libncurses5 locales ca-certificates
+RUN if [ "$TARGETPLATFORM" = "linux/arm64" ]; then apt-get install -y libsrtp2-dev; fi
+RUN apt-get clean && rm -f /var/lib/apt/lists/*_*
+
+# Set the locale
+RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
+
+ENV LANG=en_US.UTF-8
+ENV LANGUAGE=en_US:en
+ENV LC_ALL=en_US.UTF-8
+
+WORKDIR "/app"
+RUN chown nobody /app
+
+# set runner ENV
+ENV MIX_ENV="prod"
+
+# Only copy the final release from the build stage
+COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/k8s_broadcaster ./
+
+USER nobody
+
+# If using an environment that doesn't automatically reap zombie processes, it is
+# advised to add an init process such as tini via `apt-get install`
+# above and adding an entrypoint. See https://github.com/krallin/tini for details
+# ENTRYPOINT ["/tini", "--"]
+
+CMD ["/app/bin/server"]
diff --git a/k8s_broadcaster/README.md b/k8s_broadcaster/README.md
new file mode 100644
index 0000000..342fad9
--- /dev/null
+++ b/k8s_broadcaster/README.md
@@ -0,0 +1,18 @@
+# K8sBroadcaster
+
+To start your Phoenix server:
+
+ * Run `mix setup` to install and setup dependencies
+ * Start Phoenix endpoint with `mix phx.server` or inside IEx with `iex -S mix phx.server`
+
+Now you can visit [`localhost:4000`](http://localhost:4000) from your browser.
+
+Ready to run in production? Please [check our deployment guides](https://hexdocs.pm/phoenix/deployment.html).
+
+## Learn more
+
+ * Official website: https://www.phoenixframework.org/
+ * Guides: https://hexdocs.pm/phoenix/overview.html
+ * Docs: https://hexdocs.pm/phoenix
+ * Forum: https://elixirforum.com/c/phoenix-forum
+ * Source: https://github.com/phoenixframework/phoenix
diff --git a/k8s_broadcaster/assets/css/app.css b/k8s_broadcaster/assets/css/app.css
new file mode 100644
index 0000000..d78d323
--- /dev/null
+++ b/k8s_broadcaster/assets/css/app.css
@@ -0,0 +1,25 @@
+@import "tailwindcss/base";
+@import "tailwindcss/components";
+@import "tailwindcss/utilities";
+
+/* This file is for your main application CSS */
+
+::-webkit-scrollbar {
+ display: none;
+}
+
+.details {
+ padding: 5px 0px;
+}
+
+summary {
+ color: #0d0d0d;
+ align-items: center;
+ padding-bottom: 5px;
+ justify-content: space-between;
+}
+
+.summary-content {
+ color: #606060;
+ padding: 5px 0px;
+}
\ No newline at end of file
diff --git a/k8s_broadcaster/assets/js/app.js b/k8s_broadcaster/assets/js/app.js
new file mode 100644
index 0000000..5c6a09d
--- /dev/null
+++ b/k8s_broadcaster/assets/js/app.js
@@ -0,0 +1,53 @@
+// If you want to use Phoenix channels, run `mix help phx.gen.channel`
+// to get started and then uncomment the line below.
+// import "./user_socket.js"
+
+// You can include dependencies in two ways.
+//
+// The simplest option is to put them in assets/vendor and
+// import them using relative paths:
+//
+// import "../vendor/some-package.js"
+//
+// Alternatively, you can `npm install some-package --prefix assets` and import
+// them using a path starting with the package name:
+//
+// import "some-package"
+//
+
+// Include phoenix_html to handle method=PUT/DELETE in forms and buttons.
+import 'phoenix_html';
+// Establish Phoenix Socket and LiveView configuration.
+import { Socket } from 'phoenix';
+import { LiveSocket } from 'phoenix_live_view';
+import topbar from '../vendor/topbar';
+
+import { Home } from './home.js';
+import { Panel } from './panel.js';
+
+let Hooks = {};
+Hooks.Home = Home;
+Hooks.Panel = Panel;
+
+let csrfToken = document
+ .querySelector("meta[name='csrf-token']")
+ .getAttribute('content');
+let liveSocket = new LiveSocket('/live', Socket, {
+ longPollFallbackMs: 2500,
+ params: { _csrf_token: csrfToken },
+ hooks: Hooks,
+});
+
+// Show progress bar on live navigation and form submits
+topbar.config({ barColors: { 0: '#29d' }, shadowColor: 'rgba(0, 0, 0, .3)' });
+window.addEventListener('phx:page-loading-start', (_info) => topbar.show(300));
+window.addEventListener('phx:page-loading-stop', (_info) => topbar.hide());
+
+// connect if there are any LiveViews on the page
+liveSocket.connect();
+
+// expose liveSocket on window for web console debug logs and latency simulation:
+// >> liveSocket.enableDebug()
+// >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session
+// >> liveSocket.disableLatencySim()
+window.liveSocket = liveSocket;
diff --git a/k8s_broadcaster/assets/js/home.js b/k8s_broadcaster/assets/js/home.js
new file mode 100644
index 0000000..37ff427
--- /dev/null
+++ b/k8s_broadcaster/assets/js/home.js
@@ -0,0 +1,366 @@
+import { Socket, Presence } from 'phoenix';
+
+import { WHEPClient } from './whep-client.js';
+
+const viewercount = document.getElementById('viewercount');
+const videoQuality = document.getElementById('video-quality');
+const rtxCheckbox = document.getElementById('rtx-checkbox');
+const videoPlayerGrid = document.getElementById('videoplayer-grid');
+const statusMessage = document.getElementById('status-message');
+const packetLossRange = document.getElementById('packet-loss-range');
+const packetLossRangeOutput = document.getElementById('packet-loss-range-output');
+
+const whepEndpointBase = `${window.location.origin}/api/whep`;
+const inputsData = new Map();
+const stats = {
+ time: document.getElementById('time'),
+ audioBitrate: document.getElementById('audio-bitrate'),
+ videoBitrate: document.getElementById('video-bitrate'),
+ frameWidth: document.getElementById('frame-width'),
+ frameHeight: document.getElementById('frame-height'),
+ fps: document.getElementById('fps'),
+ keyframesDecoded: document.getElementById('keyframes-decoded'),
+ pliCount: document.getElementById('pli-count'),
+ packetLoss: document.getElementById('packet-loss'),
+ avgJitterBufferDelay: document.getElementById('avg-jitter-buffer-delay'),
+ freezeCount: document.getElementById('freeze-count'),
+ freezeDuration: document.getElementById('freeze-duration')
+};
+
+let defaultLayer = 'h';
+let url;
+let inputId;
+
+const button1 = document.getElementById('button-1');
+const button2 = document.getElementById('button-2');
+const button3 = document.getElementById('button-3');
+const buttonAuto = document.getElementById('button-auto');
+
+let channel;
+
+async function connectSignaling(socket) {
+ channel = socket.channel('k8s_broadcaster:signaling');
+
+ const presence = new Presence(channel);
+ presence.onSync(() => (viewercount.innerText = presence.list().length));
+
+
+ channel.on('input_added', ({ id: id }) => {
+ console.log('New input:', id);
+ inputId = id;
+ connectInput();
+ });
+
+ channel.on('input_removed', ({ id: id }) => {
+ console.log('Input removed:', id);
+ removeInput();
+ });
+
+ channel
+ .join()
+ .receive('ok', () => {
+ console.log('Joined signaling channel successfully');
+ statusMessage.innerText = 'Connected. Waiting for the stream to begin...';
+ statusMessage.classList.remove('hidden');
+ })
+ .receive('error', (resp) => {
+ console.error('Unable to join signaling channel', resp);
+ statusMessage.innerText = 'Unable to join the stream, try again in a few minutes';
+ statusMessage.classList.remove('hidden');
+ });
+}
+
+async function connectInput() {
+
+ let whepEndpoint;
+ if (url) {
+ whepEndpoint = url + 'api/whep?inputId=' + inputId;
+ } else {
+ whepEndpoint = whepEndpointBase + '?inputId=' + inputId;
+ }
+
+ console.log("Trying to connect to: ", whepEndpoint);
+
+ if (inputId) {
+ removeInput();
+ }
+
+ const pcConfigUrl = (url || window.location.origin) + '/api/pc-config'
+ const response = await fetch(pcConfigUrl, {
+ method: 'GET',
+ cache: 'no-cache',
+ });
+ const pcConfig = await response.json();
+ console.log('Fetched PC config from server: ', pcConfig)
+
+ const whepClient = new WHEPClient(whepEndpoint, pcConfig);
+
+ const inputData = {
+ whepClient: whepClient,
+ videoPlayer: undefined,
+ };
+ inputsData.set(inputId, inputData);
+
+ whepClient.id = inputId;
+
+ whepClient.onstream = (stream) => {
+ console.log(`[${inputId}]: Creating new video element`);
+
+ const videoPlayer = document.createElement('video');
+ videoPlayer.srcObject = stream;
+ videoPlayer.autoplay = true;
+ videoPlayer.controls = true;
+ videoPlayer.muted = true;
+ videoPlayer.className = 'rounded-xl w-full h-full object-cover bg-black';
+
+ videoPlayerGrid.appendChild(videoPlayer);
+ inputData.videoPlayer = videoPlayer;
+ updateVideoGrid();
+ statusMessage.classList.add('hidden');
+ };
+
+ whepClient.onconnected = () => {
+ packetLossRange.onchange = () => {
+ packetLossRangeOutput.value = packetLossRange.value;
+ channel.push('packet_loss', {resourceId: whepClient.resourceId, value: packetLossRange.value});
+ }
+
+ rtxCheckbox.onchange = () => {
+ connectInput();
+ }
+
+ videoQuality.onchange = () => setDefaultLayer(videoQuality.value);
+
+
+ whepClient.changeLayer(defaultLayer);
+
+ if (whepClient.pc.connectionState === "connected") {
+ stats.startTime = new Date();
+ stats.intervalId = setInterval(async function () {
+ if (!whepClient.pc) {
+ clearInterval(stats.intervalId);
+ stats.intervalId = undefined;
+ return;
+ }
+
+ stats.time.innerText = toHHMMSS(new Date() - stats.startTime);
+
+ let bitrate;
+
+ (await whepClient.pc.getStats(null)).forEach((report) => {
+ if (report.type === "inbound-rtp" && report.kind === "video") {
+ if (!stats.lastVideoReport) {
+ bitrate = (report.bytesReceived * 8) / 1000;
+ } else {
+ const timeDiff =
+ (report.timestamp - stats.lastVideoReport.timestamp) / 1000;
+ if (timeDiff == 0) {
+ // this should never happen as we are getting stats every second
+ bitrate = 0;
+ } else {
+ bitrate =
+ ((report.bytesReceived - stats.lastVideoReport.bytesReceived) *
+ 8) /
+ timeDiff;
+ }
+ }
+
+ stats.videoBitrate.innerText = (bitrate / 1000).toFixed();
+ stats.frameWidth.innerText = report.frameWidth;
+ stats.frameHeight.innerText = report.frameHeight;
+ stats.fps.innerText = report.framesPerSecond;
+ stats.keyframesDecoded.innerText = report.keyFramesDecoded;
+ stats.pliCount.innerText = report.pliCount;
+ stats.avgJitterBufferDelay.innerText = report.jitterBufferDelay * 1000 / report.jitterBufferEmittedCount;
+ stats.freezeCount.innerText = report.freezeCount;
+ stats.freezeDuration.innerText = report.totalFreezesDuration;
+ stats.lastVideoReport = report;
+ } else if (
+ report.type === "inbound-rtp" &&
+ report.kind === "audio"
+ ) {
+ if (!stats.lastAudioReport) {
+ bitrate = report.bytesReceived;
+ } else {
+ const timeDiff =
+ (report.timestamp - stats.lastAudioReport.timestamp) / 1000;
+ if (timeDiff == 0) {
+ // this should never happen as we are getting stats every second
+ bitrate = 0;
+ } else {
+ bitrate =
+ ((report.bytesReceived - stats.lastAudioReport.bytesReceived) *
+ 8) /
+ timeDiff;
+ }
+ }
+
+ stats.audioBitrate.innerText = (bitrate / 1000).toFixed();
+ stats.lastAudioReport = report;
+ }
+ });
+
+ let packetsLost = 0;
+ let packetsReceived = 0;
+ // calculate packet loss
+ if (stats.lastAudioReport) {
+ packetsLost += stats.lastAudioReport.packetsLost;
+ packetsReceived += stats.lastAudioReport.packetsReceived;
+ }
+
+ if (stats.lastVideoReport) {
+ packetsLost += stats.lastVideoReport.packetsLost;
+ packetsReceived += stats.lastVideoReport.packetsReceived;
+
+ }
+
+ if (packetsReceived == 0) {
+ stats.packetLoss.innerText = 0;
+ } else {
+ stats.packetLoss.innerText = (packetsLost / packetsReceived * 100).toFixed(2);
+ }
+
+
+ }, 1000);
+ } else if (view.pc.connectionState === "failed") {
+ }
+ };
+
+ whepClient.connect(rtxCheckbox.checked);
+}
+
+async function removeInput() {
+ const inputData = inputsData.get(inputId);
+ inputsData.delete(inputId);
+
+ if (inputData) {
+ inputData.whepClient.disconnect();
+
+ if (inputData.videoPlayer) {
+ videoPlayerGrid.removeChild(inputData.videoPlayer);
+ updateVideoGrid();
+ }
+ }
+
+ if (inputsData.size === 0) {
+ statusMessage.innerText = 'Connected. Waiting for the stream to begin...';
+ statusMessage.classList.remove('hidden');
+ }
+
+ clearInterval(stats.intervalId);
+ stats.lastAudioReport = null;
+ stats.lastVideoReport = null;
+ stats.time.innerText = 0;
+ stats.audioBitrate.innerText = 0;
+ stats.videoBitrate.innerText = 0;
+ stats.frameWidth.innerText = 0;
+ stats.frameHeight.innerText = 0;
+ stats.fps.innerText = 0;
+ stats.keyframesDecoded.innerText = 0;
+ stats.pliCount.innerText = 0;
+ stats.packetLoss.innerText = 0;
+ stats.avgJitterBufferDelay.innerText = 0;
+ stats.freezeCount.innerText = 0;
+ stats.freezeDuration.innerText = 0;
+
+}
+
+async function setDefaultLayer(layer) {
+ if (defaultLayer !== layer) {
+ defaultLayer = layer;
+ for (const { whepClient: whepClient } of inputsData.values()) {
+ whepClient.changeLayer(layer);
+ }
+ }
+}
+
+function updateVideoGrid() {
+ const videoCount = videoPlayerGrid.children.length;
+
+ let columns;
+ if (videoCount <= 1) {
+ columns = 'grid-cols-1';
+ } else if (videoCount <= 4) {
+ columns = 'grid-cols-2';
+ } else if (videoCount <= 9) {
+ columns = 'grid-cols-3';
+ } else if (videoCount <= 16) {
+ columns = 'grid-cols-4';
+ } else {
+ columns = 'grid-cols-5';
+ }
+
+ videoPlayerGrid.classList.remove(
+ 'grid-cols-1',
+ 'grid-cols-2',
+ 'grid-cols-3',
+ 'grid-cols-4',
+ 'grid-cols-5'
+ );
+ videoPlayerGrid.classList.add(columns);
+}
+
+function toHHMMSS(milliseconds) {
+ // Calculate hours
+ let hours = Math.floor(milliseconds / (1000 * 60 * 60));
+ // Calculate minutes, subtracting the hours part
+ let minutes = Math.floor((milliseconds % (1000 * 60 * 60)) / (1000 * 60));
+ // Calculate seconds, subtracting the hours and minutes parts
+ let seconds = Math.floor((milliseconds % (1000 * 60)) / 1000);
+
+ // Formatting each unit to always have at least two digits
+ hours = hours < 10 ? "0" + hours : hours;
+ minutes = minutes < 10 ? "0" + minutes : minutes;
+ seconds = seconds < 10 ? "0" + seconds : seconds;
+
+ return hours + ":" + minutes + ":" + seconds;
+ }
+
+function resetButtons() {
+ button1.classList.remove("border-green-500");
+ button2.classList.remove("border-green-500");
+ button3.classList.remove("border-green-500");
+ buttonAuto.classList.remove("border-green-500");
+}
+
+export const Home = {
+ mounted() {
+ const socket = new Socket('/socket', {
+ params: { token: window.userToken },
+ });
+ socket.connect();
+
+ connectSignaling(socket);
+
+ //videoQuality.onchange = () => setDefaultLayer(videoQuality.value);
+
+ button1.onclick = () => {
+ url = button1.value
+ console.log(url);
+ resetButtons();
+ button1.classList.toggle("border-green-500");
+ connectInput();
+ };
+
+ button2.onclick = () => {
+ url = button2.value
+ resetButtons();
+ button2.classList.toggle("border-green-500");
+ connectInput();
+ };
+
+ button3.onclick = () => {
+ url = button3.value
+ resetButtons();
+ button3.classList.toggle("border-green-500");
+ connectInput();
+ };
+
+ buttonAuto.onclick = () => {
+ url = buttonAuto.value
+ resetButtons();
+ buttonAuto.classList.toggle("border-green-500");
+ connectInput();
+ };
+ },
+};
diff --git a/k8s_broadcaster/assets/js/panel.js b/k8s_broadcaster/assets/js/panel.js
new file mode 100644
index 0000000..0559f1e
--- /dev/null
+++ b/k8s_broadcaster/assets/js/panel.js
@@ -0,0 +1,437 @@
+import { Socket } from 'phoenix';
+
+const audioDevices = document.getElementById('audioDevices');
+const videoDevices = document.getElementById('videoDevices');
+const serverUrl = document.getElementById('serverUrl');
+const serverToken = document.getElementById('serverToken');
+const button = document.getElementById('button');
+const previewPlayer = document.getElementById('previewPlayer');
+const highVideoBitrate = document.getElementById('highVideoBitrate');
+const mediumVideoBitrate = document.getElementById('mediumVideoBitrate');
+const lowVideoBitrate = document.getElementById('lowVideoBitrate');
+const echoCancellation = document.getElementById('echoCancellation');
+const autoGainControl = document.getElementById('autoGainControl');
+const noiseSuppression = document.getElementById('noiseSuppression');
+const saveAudioConfigButton = document.getElementById('save-audio-config');
+
+const audioBitrate = document.getElementById('audio-bitrate');
+const videoBitrate = document.getElementById('video-bitrate');
+const packetLoss = document.getElementById('packet-loss');
+const time = document.getElementById('time');
+const statusOff = document.getElementById('status-off');
+const statusOn = document.getElementById('status-on');
+
+let lastAudioReport = undefined;
+let lastVideoReport = undefined;
+let statsIntervalId = undefined;
+let startTime = undefined;
+
+const mediaConstraints = {
+ video: {
+ width: { ideal: 1280 },
+ height: { ideal: 720 },
+ frameRate: { ideal: 24 },
+ },
+ audio: true,
+};
+
+let localStream = undefined;
+let pc = undefined;
+
+
+let pcConfig;
+const pcConfigData = document.body.getAttribute('data-pcConfig');
+if (pcConfigData) {
+ pcConfig = JSON.parse(pcConfigData);
+} else {
+ pcConfig = {};
+}
+
+
+async function setupStream() {
+ if (localStream != undefined) {
+ closeStream();
+ }
+
+ const videoDevice = videoDevices.value;
+ const audioDevice = audioDevices.value;
+
+ console.log(
+ `Setting up stream: audioDevice: ${audioDevice}, videoDevice: ${videoDevice}`
+ );
+
+ localStream = await navigator.mediaDevices.getUserMedia({
+ video: {
+ deviceId: { exact: videoDevice },
+ width: { ideal: 1280 },
+ height: { ideal: 720 },
+ },
+ audio: {
+ deviceId: { exact: audioDevice },
+ echoCancellation: echoCancellation.checked,
+ autoGainControl: autoGainControl.checked,
+ noiseSuppression: noiseSuppression.checked,
+ },
+ });
+
+ console.log(`Obtained stream with id: ${localStream.id}`);
+
+ previewPlayer.srcObject = localStream;
+}
+
+function closeStream() {
+ if (localStream != undefined) {
+ console.log(`Closing stream with id: ${localStream.id}`);
+ localStream.getTracks().forEach((track) => track.stop());
+ localStream = undefined;
+ }
+}
+
+function bindControls() {
+ audioDevices.onchange = setupStream;
+ videoDevices.onchange = setupStream;
+ button.onclick = startStreaming;
+}
+
+function disableControls() {
+ audioDevices.disabled = true;
+ videoDevices.disabled = true;
+ serverUrl.disabled = true;
+ serverToken.disabled = true;
+ saveAudioConfigButton.disabled = true;
+ highVideoBitrate.disabled = true;
+ mediumVideoBitrate.disabled = true;
+ lowVideoBitrate.disabled = true;
+ echoCancellation.disabled = true;
+ autoGainControl.disabled = true;
+ noiseSuppression.disabled = true;
+}
+
+function enableControls() {
+ audioDevices.disabled = false;
+ videoDevices.disabled = false;
+ serverUrl.disabled = false;
+ serverToken.disabled = false;
+ saveAudioConfigButton.disabled = false;
+ highVideoBitrate.disabled = false;
+ mediumVideoBitrate.disabled = false;
+ lowVideoBitrate.disabled = false;
+ echoCancellation.disabled = false;
+ autoGainControl.disabled = false;
+ noiseSuppression.disabled = false;
+}
+
+async function startStreaming() {
+ disableControls();
+
+ const pcConfigUrl = window.location.origin + '/api/pc-config'
+ const response = await fetch(pcConfigUrl, {
+ method: 'GET',
+ cache: 'no-cache',
+ });
+ const pcConfig = await response.json();
+ console.log('Fetched PC config from server: ', pcConfig)
+
+ const candidates = [];
+ let patchEndpoint = undefined;
+ pc = new RTCPeerConnection(pcConfig);
+
+ pc.onicegatheringstatechange = () =>
+ console.log('Gathering state change:', pc.iceGatheringState);
+ pc.onconnectionstatechange = () => {
+ console.log('Connection state change:', pc.connectionState);
+ if (pc.connectionState === 'connected') {
+ startTime = new Date();
+ setStatusIcon(true);
+
+ statsIntervalId = setInterval(async function () {
+ if (!pc) {
+ clearInterval(statsIntervalId);
+ statsIntervalId = undefined;
+ return;
+ }
+
+ time.innerText = toHHMMSS(new Date() - startTime);
+
+ const stats = await pc.getStats(null);
+
+ let audioReport;
+ let videoReport = {
+ timestamp: undefined,
+ bytesSent: 0,
+ packetsSent: 0,
+ retransmittedPacketsSent: 0,
+ nackCount: 0,
+ };
+
+ stats.forEach((report) => {
+ if (report.type === 'outbound-rtp' && report.kind === 'video') {
+ videoReport.timestamp = report.timestamp;
+ videoReport.bytesSent += report.bytesSent;
+ videoReport.packetsSent += report.packetsSent;
+ videoReport.retransmittedPacketsSent +=
+ report.retransmittedPacketsSent;
+ videoReport.nackCount += report.nackCount;
+ } else if (
+ report.type === 'outbound-rtp' &&
+ report.kind === 'audio'
+ ) {
+ audioReport = report;
+ }
+ });
+
+ // calculate bitrates
+ let bitrate;
+ if (!lastVideoReport) {
+ bitrate = (videoReport.bytesSent * 8) / 1000;
+ } else {
+ const timeDiff =
+ (videoReport.timestamp - lastVideoReport.timestamp) / 1000;
+ if (timeDiff == 0) {
+ // this should never happen as we are getting stats every second
+ bitrate = 0;
+ } else {
+ bitrate =
+ ((videoReport.bytesSent - lastVideoReport.bytesSent) * 8) /
+ timeDiff;
+ }
+ }
+
+ videoBitrate.innerText = (bitrate / 1000).toFixed();
+ lastVideoReport = videoReport;
+
+ if (!lastAudioReport) {
+ bitrate = audioReport.bytesSent;
+ } else {
+ const timeDiff =
+ (audioReport.timestamp - lastAudioReport.timestamp) / 1000;
+ if (timeDiff == 0) {
+ // this should never happen as we are getting stats every second
+ bitrate = 0;
+ } else {
+ bitrate =
+ ((audioReport.bytesSent - lastAudioReport.bytesSent) * 8) /
+ timeDiff;
+ }
+ }
+
+ audioBitrate.innerText = (bitrate / 1000).toFixed();
+ lastAudioReport = audioReport;
+
+ // calculate packet loss
+ if (!lastAudioReport || !lastVideoReport) {
+ packetLoss.innerText = 0;
+ } else {
+ const packetsSent =
+ lastVideoReport.packetsSent + lastAudioReport.packetsSent;
+ const rtxPacketsSent =
+ lastVideoReport.retransmittedPacketsSent +
+ lastAudioReport.retransmittedPacketsSent;
+ const nackReceived =
+ lastVideoReport.nackCount + lastAudioReport.nackCount;
+
+ if (nackReceived == 0) {
+ packetLoss.innerText = 0;
+ } else {
+ packetLoss.innerText = (
+ (nackReceived / (packetsSent - rtxPacketsSent)) *
+ 100
+ ).toFixed();
+ }
+ }
+ }, 1000);
+ } else if (pc.connectionState === 'disconnected') {
+ console.warn('Peer connection state changed to `disconnected`');
+ } else if (pc.connectionState === 'failed') {
+ console.error('Peer connection state changed to `failed`');
+ stopStreaming();
+ }
+ };
+
+ pc.onicecandidate = (event) => {
+ if (event.candidate == null) {
+ return;
+ }
+
+ const candidate = JSON.stringify(event.candidate);
+ if (patchEndpoint === undefined) {
+ candidates.push(candidate);
+ } else {
+ sendCandidate(patchEndpoint, candidate);
+ }
+ };
+
+ pc.addTrack(localStream.getAudioTracks()[0], localStream);
+ const { sender: videoSender } = pc.addTransceiver(
+ localStream.getVideoTracks()[0],
+ {
+ streams: [localStream],
+ sendEncodings: [
+ { rid: 'h', maxBitrate: 1500 * 1024 },
+ { rid: 'm', scaleResolutionDownBy: 2, maxBitrate: 600 * 1024 },
+ { rid: 'l', scaleResolutionDownBy: 4, maxBitrate: 300 * 1024 },
+ ],
+ }
+ );
+
+ // limit max bitrate
+ const params = videoSender.getParameters();
+ params.encodings.find((e) => e.rid === 'h').maxBitrate =
+ parseInt(highVideoBitrate.value) * 1024;
+ params.encodings.find((e) => e.rid === 'm').maxBitrate =
+ parseInt(mediumVideoBitrate.value) * 1024;
+ params.encodings.find((e) => e.rid === 'l').maxBitrate =
+ parseInt(lowVideoBitrate.value) * 1024;
+ await videoSender.setParameters(params);
+
+ const offer = await pc.createOffer();
+ await pc.setLocalDescription(offer);
+
+ try {
+ const response = await fetch(serverUrl.value, {
+ method: 'POST',
+ cache: 'no-cache',
+ headers: {
+ Accept: 'application/sdp',
+ 'Content-Type': 'application/sdp',
+ Authorization: `Bearer ${serverToken.value}`,
+ },
+ body: offer.sdp,
+ });
+
+ if (response.status == 201) {
+ patchEndpoint = response.headers.get('location');
+ console.log('Successfully initialized WHIP connection');
+
+ for (const candidate of candidates) {
+ sendCandidate(patchEndpoint, candidate);
+ }
+
+ const sdp = await response.text();
+ await pc.setRemoteDescription({ type: 'answer', sdp: sdp });
+ button.innerText = 'Stop Streaming';
+ button.onclick = stopStreaming;
+ } else {
+ console.error('Request to server failed with response:', response);
+ pc.close();
+ pc = undefined;
+ enableControls();
+ }
+ } catch (err) {
+ console.error(err);
+ pc.close();
+ pc = undefined;
+ enableControls();
+ }
+}
+
+async function sendCandidate(patchEndpoint, candidate) {
+ const response = await fetch(patchEndpoint, {
+ method: 'PATCH',
+ cache: 'no-cache',
+ headers: {
+ 'Content-Type': 'application/trickle-ice-sdpfrag',
+ },
+ body: candidate,
+ });
+
+ if (response.status === 204) {
+ console.log(`Successfully sent ICE candidate:`, candidate);
+ } else {
+ console.error(
+ `Failed to send ICE, status: ${response.status}, candidate:`,
+ candidate
+ );
+ }
+}
+function stopStreaming() {
+ pc.close();
+ pc = undefined;
+
+ resetStats();
+ enableControls();
+
+ button.innerText = 'Start Streaming';
+ button.onclick = startStreaming;
+}
+
+function resetStats() {
+ startTime = undefined;
+ lastAudioReport = undefined;
+ lastVideoReport = undefined;
+ audioBitrate.innerText = 0;
+ videoBitrate.innerText = 0;
+ packetLoss.innerText = 0;
+ time.innerText = '00:00:00';
+ setStatusIcon(false);
+}
+
+function toHHMMSS(milliseconds) {
+ // Calculate hours
+ let hours = Math.floor(milliseconds / (1000 * 60 * 60));
+ // Calculate minutes, subtracting the hours part
+ let minutes = Math.floor((milliseconds % (1000 * 60 * 60)) / (1000 * 60));
+ // Calculate seconds, subtracting the hours and minutes parts
+ let seconds = Math.floor((milliseconds % (1000 * 60)) / 1000);
+
+ // Formatting each unit to always have at least two digits
+ hours = hours < 10 ? '0' + hours : hours;
+ minutes = minutes < 10 ? '0' + minutes : minutes;
+ seconds = seconds < 10 ? '0' + seconds : seconds;
+
+ return hours + ':' + minutes + ':' + seconds;
+}
+
+function setStatusIcon(isOn) {
+ if (isOn) {
+ statusOff.classList.add('hidden');
+ statusOn.classList.remove('hidden');
+ } else {
+ statusOn.classList.add('hidden');
+ statusOff.classList.remove('hidden');
+ }
+}
+
+async function run() {
+ // ask for permissions
+ localStream = await navigator.mediaDevices.getUserMedia(mediaConstraints);
+
+ console.log(`Obtained stream with id: ${localStream.id}`);
+
+ // enumerate devices
+ const devices = await navigator.mediaDevices.enumerateDevices();
+ devices.forEach((device) => {
+ if (device.kind === 'videoinput') {
+ videoDevices.options[videoDevices.options.length] = new Option(
+ device.label,
+ device.deviceId
+ );
+ } else if (device.kind === 'audioinput') {
+ audioDevices.options[audioDevices.options.length] = new Option(
+ device.label,
+ device.deviceId
+ );
+ }
+ });
+
+ // for some reasons, firefox loses labels after closing the stream
+ // so we close it after filling audio/video devices selects
+ closeStream();
+
+ // setup preview
+ await setupStream();
+
+ // bind buttons
+ bindControls();
+}
+
+export const Panel = {
+ mounted() {
+ const socket = new Socket('/socket', {
+ params: { token: window.userToken },
+ });
+ socket.connect();
+
+ run();
+ },
+};
diff --git a/k8s_broadcaster/assets/js/whep-client.js b/k8s_broadcaster/assets/js/whep-client.js
new file mode 100644
index 0000000..98082c5
--- /dev/null
+++ b/k8s_broadcaster/assets/js/whep-client.js
@@ -0,0 +1,145 @@
+export class WHEPClient {
+ constructor(url, pcConfig = {}) {
+ this.url = url;
+ this.id = 'WHEP Client';
+ this.pc = undefined;
+ this.resourceId = undefined;
+ this.patchEndpoint = undefined;
+ this.onstream = undefined;
+ this.onconnected = undefined;
+ this.pcConfig = pcConfig;
+ }
+
+ async connect(rtx = true) {
+ const candidates = [];
+ const pc = new RTCPeerConnection(this.pcConfig);
+ this.pc = pc;
+
+ pc.ontrack = (event) => {
+ if (event.track.kind == 'video') {
+ console.log(`[${this.id}]: Video track added`);
+
+ if (this.onstream) {
+ this.onstream(event.streams[0]);
+ }
+ } else {
+ // Audio tracks are associated with the stream (`event.streams[0]`) and require no separate actions
+ console.log(`[${this.id}]: Audio track added`);
+ }
+ };
+
+ pc.onicegatheringstatechange = () =>
+ console.log(
+ `[${this.id}]: Gathering state change:`,
+ pc.iceGatheringState
+ );
+
+ pc.onconnectionstatechange = () => {
+ console.log(`[${this.id}]: Connection state change:`, pc.connectionState);
+ if (pc.connectionState === 'connected' && this.onconnected) {
+ this.onconnected();
+ }
+ };
+
+ pc.onicecandidate = (event) => {
+ if (event.candidate == null) {
+ return;
+ }
+
+ const candidate = JSON.stringify(event.candidate);
+ if (this.patchEndpoint === undefined) {
+ candidates.push(candidate);
+ } else {
+ this.sendCandidate(candidate);
+ }
+ };
+
+ pc.addTransceiver('video', { direction: 'recvonly' });
+ pc.addTransceiver('audio', { direction: 'recvonly' });
+
+ const offer = await pc.createOffer();
+ await pc.setLocalDescription(offer);
+
+ const response = await fetch(this.url, {
+ method: 'POST',
+ cache: 'no-cache',
+ headers: {
+ Accept: 'application/sdp',
+ 'Content-Type': 'application/sdp',
+ },
+ body: JSON.stringify({
+ rtx: rtx,
+ sdp: pc.localDescription.sdp,
+ })
+ });
+
+ if (response.status !== 201) {
+ console.error(
+ `[${this.id}]: Failed to initialize WHEP connection, status: ${response.status}`
+ );
+ return;
+ }
+
+ this.patchEndpoint = response.headers.get('location');
+ this.resourceId = this.patchEndpoint.split('/').pop();
+ console.log(`[${this.id}]: Sucessfully initialized WHEP connection`);
+
+ for (const candidate of candidates) {
+ this.sendCandidate(candidate);
+ }
+
+ const sdp = await response.text();
+ await pc.setRemoteDescription({ type: 'answer', sdp: sdp });
+ }
+
+ async disconnect() {
+ this.pc.close();
+ }
+
+ async changeLayer(layer) {
+ // According to the spec, we should gather the info about available layers from the `layers` event
+ // emitted in the SSE stream tied to *one* given WHEP session.
+ //
+ // However, to simplify the implementation and decrease resource usage, we're assuming each stream
+ // has the layers with `encodingId` of `h`, `m` and `l`, corresponding to high, medium and low video quality.
+ // If that's not the case (e.g. the stream doesn't use simulcast), the server returns an error response which we ignore.
+ //
+ // Nevertheless, the server supports the `Server Sent Events` and `Video Layer Selection` WHEP extensions,
+ // and WHEP players other than this site are free to use them.
+ //
+ // For more info refer to https://www.ietf.org/archive/id/draft-ietf-wish-whep-01.html#section-4.6.2
+ if (this.patchEndpoint) {
+ const response = await fetch(`${this.patchEndpoint}/layer`, {
+ method: 'POST',
+ cache: 'no-cache',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ encodingId: layer }),
+ });
+
+ if (response.status != 200) {
+ console.warn(`[${this.id}]: Changing layer failed`, response);
+ }
+ }
+ }
+
+ async sendCandidate(candidate) {
+ const response = await fetch(this.patchEndpoint, {
+ method: 'PATCH',
+ cache: 'no-cache',
+ headers: {
+ 'Content-Type': 'application/trickle-ice-sdpfrag',
+ },
+ body: candidate,
+ });
+
+ if (response.status === 204) {
+ console.log(`[${this.id}]: Successfully sent ICE candidate:`, candidate);
+ } else {
+ console.error(
+ `[${this.id}]: Failed to send ICE, status: ${response.status}, candidate:`,
+ candidate
+ );
+ }
+ }
+ }
+
\ No newline at end of file
diff --git a/k8s_broadcaster/assets/tailwind.config.js b/k8s_broadcaster/assets/tailwind.config.js
new file mode 100644
index 0000000..18ae026
--- /dev/null
+++ b/k8s_broadcaster/assets/tailwind.config.js
@@ -0,0 +1,76 @@
+// See the Tailwind configuration guide for advanced usage
+// https://tailwindcss.com/docs/configuration
+
+const plugin = require("tailwindcss/plugin")
+const fs = require("fs")
+const path = require("path")
+
+module.exports = {
+ content: [
+ "./js/**/*.js",
+ "../lib/k8s_broadcaster_web.ex",
+ "../lib/k8s_broadcaster_web/**/*.*ex"
+ ],
+ theme: {
+ extend: {
+ colors: {
+ brand: "#4339AC",
+ b: "#606060"
+
+ }
+ },
+ },
+ plugins: [
+ require("@tailwindcss/forms"),
+ // Allows prefixing tailwind classes with LiveView classes to add rules
+ // only when LiveView classes are applied, for example:
+ //
+ //
+ //
+ plugin(({addVariant}) => addVariant("phx-click-loading", [".phx-click-loading&", ".phx-click-loading &"])),
+ plugin(({addVariant}) => addVariant("phx-submit-loading", [".phx-submit-loading&", ".phx-submit-loading &"])),
+ plugin(({addVariant}) => addVariant("phx-change-loading", [".phx-change-loading&", ".phx-change-loading &"])),
+
+ // Embeds Heroicons (https://heroicons.com) into your app.css bundle
+ // See your `CoreComponents.icon/1` for more information.
+ //
+ plugin(function({matchComponents, theme}) {
+ let iconsDir = path.join(__dirname, "../deps/heroicons/optimized")
+ let values = {}
+ let icons = [
+ ["", "/24/outline"],
+ ["-solid", "/24/solid"],
+ ["-mini", "/20/solid"],
+ ["-micro", "/16/solid"]
+ ]
+ icons.forEach(([suffix, dir]) => {
+ fs.readdirSync(path.join(iconsDir, dir)).forEach(file => {
+ let name = path.basename(file, ".svg") + suffix
+ values[name] = {name, fullPath: path.join(iconsDir, dir, file)}
+ })
+ })
+ matchComponents({
+ "hero": ({name, fullPath}) => {
+ let content = fs.readFileSync(fullPath).toString().replace(/\r?\n|\r/g, "")
+ let size = theme("spacing.6")
+ if (name.endsWith("-mini")) {
+ size = theme("spacing.5")
+ } else if (name.endsWith("-micro")) {
+ size = theme("spacing.4")
+ }
+ return {
+ [`--hero-${name}`]: `url('data:image/svg+xml;utf8,${content}')`,
+ "-webkit-mask": `var(--hero-${name})`,
+ "mask": `var(--hero-${name})`,
+ "mask-repeat": "no-repeat",
+ "background-color": "currentColor",
+ "vertical-align": "middle",
+ "display": "inline-block",
+ "width": size,
+ "height": size
+ }
+ }
+ }, {values})
+ })
+ ]
+}
diff --git a/k8s_broadcaster/assets/vendor/topbar.js b/k8s_broadcaster/assets/vendor/topbar.js
new file mode 100644
index 0000000..4195727
--- /dev/null
+++ b/k8s_broadcaster/assets/vendor/topbar.js
@@ -0,0 +1,165 @@
+/**
+ * @license MIT
+ * topbar 2.0.0, 2023-02-04
+ * https://buunguyen.github.io/topbar
+ * Copyright (c) 2021 Buu Nguyen
+ */
+(function (window, document) {
+ "use strict";
+
+ // https://gist.github.com/paulirish/1579671
+ (function () {
+ var lastTime = 0;
+ var vendors = ["ms", "moz", "webkit", "o"];
+ for (var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) {
+ window.requestAnimationFrame =
+ window[vendors[x] + "RequestAnimationFrame"];
+ window.cancelAnimationFrame =
+ window[vendors[x] + "CancelAnimationFrame"] ||
+ window[vendors[x] + "CancelRequestAnimationFrame"];
+ }
+ if (!window.requestAnimationFrame)
+ window.requestAnimationFrame = function (callback, element) {
+ var currTime = new Date().getTime();
+ var timeToCall = Math.max(0, 16 - (currTime - lastTime));
+ var id = window.setTimeout(function () {
+ callback(currTime + timeToCall);
+ }, timeToCall);
+ lastTime = currTime + timeToCall;
+ return id;
+ };
+ if (!window.cancelAnimationFrame)
+ window.cancelAnimationFrame = function (id) {
+ clearTimeout(id);
+ };
+ })();
+
+ var canvas,
+ currentProgress,
+ showing,
+ progressTimerId = null,
+ fadeTimerId = null,
+ delayTimerId = null,
+ addEvent = function (elem, type, handler) {
+ if (elem.addEventListener) elem.addEventListener(type, handler, false);
+ else if (elem.attachEvent) elem.attachEvent("on" + type, handler);
+ else elem["on" + type] = handler;
+ },
+ options = {
+ autoRun: true,
+ barThickness: 3,
+ barColors: {
+ 0: "rgba(26, 188, 156, .9)",
+ ".25": "rgba(52, 152, 219, .9)",
+ ".50": "rgba(241, 196, 15, .9)",
+ ".75": "rgba(230, 126, 34, .9)",
+ "1.0": "rgba(211, 84, 0, .9)",
+ },
+ shadowBlur: 10,
+ shadowColor: "rgba(0, 0, 0, .6)",
+ className: null,
+ },
+ repaint = function () {
+ canvas.width = window.innerWidth;
+ canvas.height = options.barThickness * 5; // need space for shadow
+
+ var ctx = canvas.getContext("2d");
+ ctx.shadowBlur = options.shadowBlur;
+ ctx.shadowColor = options.shadowColor;
+
+ var lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0);
+ for (var stop in options.barColors)
+ lineGradient.addColorStop(stop, options.barColors[stop]);
+ ctx.lineWidth = options.barThickness;
+ ctx.beginPath();
+ ctx.moveTo(0, options.barThickness / 2);
+ ctx.lineTo(
+ Math.ceil(currentProgress * canvas.width),
+ options.barThickness / 2
+ );
+ ctx.strokeStyle = lineGradient;
+ ctx.stroke();
+ },
+ createCanvas = function () {
+ canvas = document.createElement("canvas");
+ var style = canvas.style;
+ style.position = "fixed";
+ style.top = style.left = style.right = style.margin = style.padding = 0;
+ style.zIndex = 100001;
+ style.display = "none";
+ if (options.className) canvas.classList.add(options.className);
+ document.body.appendChild(canvas);
+ addEvent(window, "resize", repaint);
+ },
+ topbar = {
+ config: function (opts) {
+ for (var key in opts)
+ if (options.hasOwnProperty(key)) options[key] = opts[key];
+ },
+ show: function (delay) {
+ if (showing) return;
+ if (delay) {
+ if (delayTimerId) return;
+ delayTimerId = setTimeout(() => topbar.show(), delay);
+ } else {
+ showing = true;
+ if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId);
+ if (!canvas) createCanvas();
+ canvas.style.opacity = 1;
+ canvas.style.display = "block";
+ topbar.progress(0);
+ if (options.autoRun) {
+ (function loop() {
+ progressTimerId = window.requestAnimationFrame(loop);
+ topbar.progress(
+ "+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2)
+ );
+ })();
+ }
+ }
+ },
+ progress: function (to) {
+ if (typeof to === "undefined") return currentProgress;
+ if (typeof to === "string") {
+ to =
+ (to.indexOf("+") >= 0 || to.indexOf("-") >= 0
+ ? currentProgress
+ : 0) + parseFloat(to);
+ }
+ currentProgress = to > 1 ? 1 : to;
+ repaint();
+ return currentProgress;
+ },
+ hide: function () {
+ clearTimeout(delayTimerId);
+ delayTimerId = null;
+ if (!showing) return;
+ showing = false;
+ if (progressTimerId != null) {
+ window.cancelAnimationFrame(progressTimerId);
+ progressTimerId = null;
+ }
+ (function loop() {
+ if (topbar.progress("+.1") >= 1) {
+ canvas.style.opacity -= 0.05;
+ if (canvas.style.opacity <= 0.05) {
+ canvas.style.display = "none";
+ fadeTimerId = null;
+ return;
+ }
+ }
+ fadeTimerId = window.requestAnimationFrame(loop);
+ })();
+ },
+ };
+
+ if (typeof module === "object" && typeof module.exports === "object") {
+ module.exports = topbar;
+ } else if (typeof define === "function" && define.amd) {
+ define(function () {
+ return topbar;
+ });
+ } else {
+ this.topbar = topbar;
+ }
+}.call(this, window, document));
diff --git a/k8s_broadcaster/config/config.exs b/k8s_broadcaster/config/config.exs
new file mode 100644
index 0000000..9048b50
--- /dev/null
+++ b/k8s_broadcaster/config/config.exs
@@ -0,0 +1,66 @@
+# This file is responsible for configuring your application
+# and its dependencies with the aid of the Config module.
+#
+# This configuration file is loaded before any dependency and
+# is restricted to this project.
+
+# General application configuration
+import Config
+
+config :k8s_broadcaster,
+ generators: [timestamp_type: :utc_datetime]
+
+# Configures the endpoint
+config :k8s_broadcaster, K8sBroadcasterWeb.Endpoint,
+ url: [host: "localhost"],
+ adapter: Bandit.PhoenixAdapter,
+ render_errors: [
+ formats: [html: K8sBroadcasterWeb.ErrorHTML, json: K8sBroadcasterWeb.ErrorJSON],
+ layout: false
+ ],
+ pubsub_server: K8sBroadcaster.PubSub,
+ live_view: [signing_salt: "2KRXfjle"]
+
+# Configure esbuild (the version is required)
+config :esbuild,
+ version: "0.17.11",
+ k8s_broadcaster: [
+ args:
+ ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*),
+ cd: Path.expand("../assets", __DIR__),
+ env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
+ ]
+
+# Configure tailwind (the version is required)
+config :tailwind,
+ version: "3.4.3",
+ k8s_broadcaster: [
+ args: ~w(
+ --config=tailwind.config.js
+ --input=css/app.css
+ --output=../priv/static/assets/app.css
+ ),
+ cd: Path.expand("../assets", __DIR__)
+ ]
+
+# Configures Elixir's Logger
+config :logger, :console,
+ format: "$time $metadata[$level] $message\n",
+ metadata: [:request_id]
+
+# Use Jason for JSON parsing in Phoenix
+config :phoenix, :json_library, Jason
+
+config :mime, :types, %{
+ "application/sdp" => ["sdp"],
+ "application/trickle-ice-sdpfrag" => ["trickle-ice-sdpfrag"]
+}
+
+config :k8s_broadcaster,
+ whip_token: "example",
+ admin_username: "admin",
+ admin_password: "admin"
+
+# Import environment specific config. This must remain at the bottom
+# of this file so it overrides the configuration defined above.
+import_config "#{config_env()}.exs"
diff --git a/k8s_broadcaster/config/dev.exs b/k8s_broadcaster/config/dev.exs
new file mode 100644
index 0000000..7778bc3
--- /dev/null
+++ b/k8s_broadcaster/config/dev.exs
@@ -0,0 +1,71 @@
+import Config
+
+# For development, we disable any cache and enable
+# debugging and code reloading.
+#
+# The watchers configuration can be used to run external
+# watchers to your application. For example, we can use it
+# to bundle .js and .css sources.
+config :k8s_broadcaster, K8sBroadcasterWeb.Endpoint,
+ # Binding to loopback ipv4 address prevents access from other machines.
+ # Change to `ip: {0, 0, 0, 0}` to allow access from other machines.
+ http: [ip: {127, 0, 0, 1}, port: 4000],
+ check_origin: false,
+ code_reloader: true,
+ debug_errors: true,
+ secret_key_base: "qbvm8IEj0Eqk4SBhwAMhWthROktYxdSF8Bd+isbKYGyaP7vQM5zM4FkI9hX315/H",
+ watchers: [
+ esbuild: {Esbuild, :install_and_run, [:k8s_broadcaster, ~w(--sourcemap=inline --watch)]},
+ tailwind: {Tailwind, :install_and_run, [:k8s_broadcaster, ~w(--watch)]}
+ ]
+
+# ## SSL Support
+#
+# In order to use HTTPS in development, a self-signed
+# certificate can be generated by running the following
+# Mix task:
+#
+# mix phx.gen.cert
+#
+# Run `mix help phx.gen.cert` for more information.
+#
+# The `http:` config above can be replaced with:
+#
+# https: [
+# port: 4001,
+# cipher_suite: :strong,
+# keyfile: "priv/cert/selfsigned_key.pem",
+# certfile: "priv/cert/selfsigned.pem"
+# ],
+#
+# If desired, both `http:` and `https:` keys can be
+# configured to run both http and https servers on
+# different ports.
+
+# Watch static and templates for browser reloading.
+config :k8s_broadcaster, K8sBroadcasterWeb.Endpoint,
+ live_reload: [
+ patterns: [
+ ~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg)$",
+ ~r"lib/k8s_broadcaster_web/(controllers|live|components)/.*(ex|heex)$"
+ ]
+ ]
+
+# Enable dev routes for dashboard and mailbox
+config :k8s_broadcaster, dev_routes: true
+
+# Do not include metadata nor timestamps in development logs
+config :logger, :console, level: :info, format: "[$level] $message\n"
+
+# Set a higher stacktrace during development. Avoid configuring such
+# in production as building large stacktraces may be expensive.
+config :phoenix, :stacktrace_depth, 20
+
+# Initialize plugs at runtime for faster development compilation
+config :phoenix, :plug_init_mode, :runtime
+
+config :phoenix_live_view,
+ # Include HEEx debug annotations as HTML comments in rendered markup
+ debug_heex_annotations: true,
+ # Enable helpful, but potentially expensive runtime checks
+ enable_expensive_runtime_checks: true
diff --git a/k8s_broadcaster/config/prod.exs b/k8s_broadcaster/config/prod.exs
new file mode 100644
index 0000000..2dcfcec
--- /dev/null
+++ b/k8s_broadcaster/config/prod.exs
@@ -0,0 +1,15 @@
+import Config
+
+# Note we also include the path to a cache manifest
+# containing the digested version of static files. This
+# manifest is generated by the `mix assets.deploy` task,
+# which you should run after static files are built and
+# before starting your production server.
+config :k8s_broadcaster, K8sBroadcasterWeb.Endpoint,
+ cache_static_manifest: "priv/static/cache_manifest.json"
+
+# Do not print debug messages in production
+config :logger, level: :info
+
+# Runtime production configuration, including reading
+# of environment variables, is done on config/runtime.exs.
diff --git a/k8s_broadcaster/config/runtime.exs b/k8s_broadcaster/config/runtime.exs
new file mode 100644
index 0000000..419c11d
--- /dev/null
+++ b/k8s_broadcaster/config/runtime.exs
@@ -0,0 +1,190 @@
+import Config
+
+read_cluster! = fn env ->
+ case System.get_env(env) do
+ nil ->
+ %{reg: nil, url: nil}
+
+ cluster ->
+ [reg, url] = String.split(cluster, ";")
+ %{reg: reg, url: url}
+ end
+end
+
+read_ice_port_range! = fn ->
+ case System.get_env("ICE_PORT_RANGE") do
+ nil ->
+ [0]
+
+ raw_port_range ->
+ case String.split(raw_port_range, "-", parts: 2) do
+ [from, to] -> String.to_integer(from)..String.to_integer(to)
+ _other -> raise "ICE_PORT_RANGE has to be in form of FROM-TO, passed: #{raw_port_range}"
+ end
+ end
+end
+
+read_k8s_dist_config! = fn ->
+ case System.get_env("K8S_SERVICE_NAME") do
+ nil ->
+ raise "Distribution mode `k8s` requires setting the env variable K8S_SERVICE_NAME"
+
+ service ->
+ [
+ strategy: Cluster.Strategy.Kubernetes.DNS,
+ config: [
+ application_name: "k8s_broadcaster",
+ service: service
+ ]
+ ]
+ end
+end
+
+read_check_origin = fn ->
+ case System.get_env("CHECK_ORIGIN") do
+ nil -> true
+ "true" -> true
+ "false" -> false
+ value -> String.split(value, ";")
+ end
+end
+
+ice_server_config =
+ %{
+ urls: System.get_env("ICE_SERVER_URL") || "stun:stun.l.google.com:19302",
+ username: System.get_env("ICE_SERVER_USERNAME"),
+ credential: System.get_env("ICE_SERVER_CREDENTIAL")
+ }
+ |> Map.reject(fn {_k, v} -> is_nil(v) end)
+
+ice_transport_policy =
+ case System.get_env("ICE_TRANSPORT_POLICY") do
+ "relay" -> :relay
+ _other -> :all
+ end
+
+pc_config = [
+ ice_servers: [ice_server_config],
+ ice_transport_policy: ice_transport_policy,
+ ice_port_range: read_ice_port_range!.()
+]
+
+# Cluster info is in form of %{reg: reg, url: url}.
+# If not provided, %{reg: nil, url: nil} will be used
+# and interpreted as using current window location
+cluster_info =
+ %{
+ c0: read_cluster!.("C0"),
+ c1: read_cluster!.("C1"),
+ c2: read_cluster!.("C2"),
+ c3: read_cluster!.("C3")
+ }
+ |> IO.inspect()
+
+dist_config =
+ case System.get_env("DISTRIBUTION_MODE") do
+ "k8s" -> read_k8s_dist_config!.()
+ _else -> nil
+ end
+
+config :k8s_broadcaster,
+ cluster_info: cluster_info,
+ pc_config: pc_config,
+ dist_config: dist_config
+
+# config/runtime.exs is executed for all environments, including
+# during releases. It is executed after compilation and before the
+# system starts, so it is typically used to load production configuration
+# and secrets from environment variables or elsewhere. Do not define
+# any compile-time configuration in here, as it won't be applied.
+# The block below contains prod specific runtime configuration.
+
+# ## Using releases
+#
+# If you use `mix release`, you need to explicitly enable the server
+# by passing the PHX_SERVER=true when you start it:
+#
+# PHX_SERVER=true bin/k8s_broadcaster start
+#
+# Alternatively, you can use `mix phx.gen.release` to generate a `bin/server`
+# script that automatically sets the env var above.
+if System.get_env("PHX_SERVER") do
+ config :k8s_broadcaster, K8sBroadcasterWeb.Endpoint, server: true
+end
+
+if config_env() == :prod do
+ # The secret key base is used to sign/encrypt cookies and other secrets.
+ # A default value is used in config/dev.exs and config/test.exs but you
+ # want to use a different value for prod and you most likely don't want
+ # to check this value into version control, so we use an environment
+ # variable instead.
+ secret_key_base =
+ System.get_env("SECRET_KEY_BASE") ||
+ raise """
+ environment variable SECRET_KEY_BASE is missing.
+ You can generate one by calling: mix phx.gen.secret
+ """
+
+ host = System.get_env("PHX_HOST") || "example.com"
+ port = String.to_integer(System.get_env("PORT") || "4000")
+
+ config :k8s_broadcaster, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
+
+ config :k8s_broadcaster, K8sBroadcasterWeb.Endpoint,
+ url: [host: host, port: 443, scheme: "https"],
+ http: [
+ # Enable IPv6 and bind on all interfaces.
+ # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access.
+ # See the documentation on https://hexdocs.pm/bandit/Bandit.html#t:options/0
+ # for details about using IPv6 vs IPv4 and loopback vs public addresses.
+ ip: {0, 0, 0, 0, 0, 0, 0, 0},
+ port: port
+ ],
+ secret_key_base: secret_key_base,
+ check_origin: read_check_origin.()
+
+ whip_token = System.get_env("WHIP_TOKEN") || raise "Environment variable WHIP_TOKEN is missing."
+
+ admin_username =
+ System.get_env("ADMIN_USERNAME") || raise "Environment variable ADMIN_USERNAME is missing."
+
+ admin_password =
+ System.get_env("ADMIN_PASSWORD") || raise "Environment variable ADMIN_PASSWORD is missing."
+
+ config :k8s_broadcaster,
+ whip_token: whip_token,
+ admin_username: admin_username,
+ admin_password: admin_password
+
+ # ## SSL Support
+ #
+ # To get SSL working, you will need to add the `https` key
+ # to your endpoint configuration:
+ #
+ # config :k8s_broadcaster, K8sBroadcasterWeb.Endpoint,
+ # https: [
+ # ...,
+ # port: 443,
+ # cipher_suite: :strong,
+ # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"),
+ # certfile: System.get_env("SOME_APP_SSL_CERT_PATH")
+ # ]
+ #
+ # The `cipher_suite` is set to `:strong` to support only the
+ # latest and more secure SSL ciphers. This means old browsers
+ # and clients may not be supported. You can set it to
+ # `:compatible` for wider support.
+ #
+ # `:keyfile` and `:certfile` expect an absolute path to the key
+ # and cert in disk or a relative path inside priv, for example
+ # "priv/ssl/server.key". For all supported SSL configuration
+ # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1
+ #
+ # We also recommend setting `force_ssl` in your config/prod.exs,
+ # ensuring no data is ever sent via http, always redirecting to https:
+ #
+ # config :k8s_broadcaster, K8sBroadcasterWeb.Endpoint,
+ # force_ssl: [hsts: true]
+ #
+ # Check `Plug.SSL` for all available options in `force_ssl`.
+end
diff --git a/k8s_broadcaster/config/test.exs b/k8s_broadcaster/config/test.exs
new file mode 100644
index 0000000..cd23e1f
--- /dev/null
+++ b/k8s_broadcaster/config/test.exs
@@ -0,0 +1,18 @@
+import Config
+
+# We don't run a server during test. If one is required,
+# you can enable the server option below.
+config :k8s_broadcaster, K8sBroadcasterWeb.Endpoint,
+ http: [ip: {127, 0, 0, 1}, port: 4002],
+ secret_key_base: "IZC4GPoi59CquGdP0YpCdEZx/+/2zpcbdySxdLhfrydB7tQS/ac1fAwYQ2AC0Kv3",
+ server: false
+
+# Print only warnings and errors during test
+config :logger, level: :warning
+
+# Initialize plugs at runtime for faster test compilation
+config :phoenix, :plug_init_mode, :runtime
+
+# Enable helpful, but potentially expensive runtime checks
+config :phoenix_live_view,
+ enable_expensive_runtime_checks: true
diff --git a/k8s_broadcaster/kubernetes.yaml b/k8s_broadcaster/kubernetes.yaml
new file mode 100644
index 0000000..234fa5b
--- /dev/null
+++ b/k8s_broadcaster/kubernetes.yaml
@@ -0,0 +1,77 @@
+apiVersion: v1
+kind: Service
+metadata:
+ name: k8sbroadcaster-headless
+spec:
+ selector:
+ app: k8sbroadcaster
+ type: ClusterIP
+ clusterIP: None
+---
+apiVersion: v1
+kind: Service
+metadata:
+ name: k8sbroadcaster-external
+spec:
+ selector:
+ app: k8sbroadcaster
+ type: LoadBalancer
+ ports:
+ - port: 8080
+ targetPort: 4000
+ protocol: TCP
+ name: http
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: k8sbroadcaster
+ namespace: default
+spec:
+ replicas: 2
+ selector:
+ matchLabels:
+ app: k8sbroadcaster
+ template:
+ metadata:
+ labels:
+ app: k8sbroadcaster
+ spec:
+ containers:
+ - name: k8sbroadcaster
+ # image: ghcr.io/elixir-webrtc/apps/k8s_broadcaster:latest
+ image: k8s_broadcaster:dev
+ imagePullPolicy: Never
+ ports:
+ - name: http
+ containerPort: 4000
+ protocol: TCP
+ env:
+ - name: POD_IP
+ valueFrom:
+ fieldRef:
+ fieldPath: status.podIP
+ - name: DISTRIBUTION_MODE
+ value: k8s
+ - name: K8S_SERVICE_NAME
+ value: k8sbroadcaster-headless
+ # - name: ICE_SERVER_URL
+ # value: turn:turn.example.org:3478?transport=udp
+ # - name: ICE_SERVER_USERNAME
+ # value: user-1
+ # - name: ICE_SERVER_CREDENTIAL
+ # value: pass-1
+ # - name: ICE_TRANSPORT_POLICY
+ # value: relay
+ - name: ICE_PORT_RANGE
+ value: 51000-52000
+ - name: SECRET_KEY_BASE
+ value: u1gYGbDNgA5RwdKGFe9CdK+5qLCVROAHZAFPgUVlcmjTEGdvpXqgYW9qFjLQvxZO
+ - name: PHX_HOST
+ value: localhost
+ - name: ADMIN_USERNAME
+ value: admin
+ - name: ADMIN_PASSWORD
+ value: admin
+ - name: WHIP_TOKEN
+ value: token
diff --git a/k8s_broadcaster/lib/k8s_broadcaster.ex b/k8s_broadcaster/lib/k8s_broadcaster.ex
new file mode 100644
index 0000000..ae99161
--- /dev/null
+++ b/k8s_broadcaster/lib/k8s_broadcaster.ex
@@ -0,0 +1,9 @@
+defmodule K8sBroadcaster do
+ @moduledoc """
+ K8sBroadcaster keeps the contexts that define your domain
+ and business logic.
+
+ Contexts are also responsible for managing your data, regardless
+ if it comes from the database, an external API or others.
+ """
+end
diff --git a/k8s_broadcaster/lib/k8s_broadcaster/application.ex b/k8s_broadcaster/lib/k8s_broadcaster/application.ex
new file mode 100644
index 0000000..f7bc444
--- /dev/null
+++ b/k8s_broadcaster/lib/k8s_broadcaster/application.ex
@@ -0,0 +1,55 @@
+defmodule K8sBroadcaster.Application do
+ # See https://hexdocs.pm/elixir/Application.html
+ # for more information on OTP Applications
+ @moduledoc false
+
+ use Application
+
+ @version Mix.Project.config()[:version]
+
+ @spec version() :: String.t()
+ def version(), do: @version
+
+ @spec cluster(:c0 | :c1 | :c2 | :c3) :: map()
+ def cluster(name),
+ do: Application.fetch_env!(:k8s_broadcaster, :cluster_info) |> Map.fetch!(name)
+
+ @impl true
+ def start(_type, _args) do
+ dist_config =
+ case Application.fetch_env!(:k8s_broadcaster, :dist_config) do
+ nil ->
+ nil
+
+ config ->
+ {Cluster.Supervisor, [[cluster: config], [name: K8sBroadcaster.ClusterSupervisor]]}
+ end
+
+ children =
+ [
+ K8sBroadcasterWeb.Telemetry,
+ {DNSCluster, query: Application.get_env(:k8s_broadcaster, :dns_cluster_query) || :ignore},
+ {Phoenix.PubSub, name: K8sBroadcaster.PubSub},
+ # Start to serve requests, typically the last entry
+ dist_config,
+ K8sBroadcaster.PeerSupervisor,
+ K8sBroadcaster.Forwarder,
+ K8sBroadcasterWeb.Endpoint,
+ K8sBroadcasterWeb.Presence
+ ]
+ |> Enum.reject(&is_nil(&1))
+
+ # See https://hexdocs.pm/elixir/Supervisor.html
+ # for other strategies and supported options
+ opts = [strategy: :one_for_one, name: K8sBroadcaster.Supervisor]
+ Supervisor.start_link(children, opts)
+ end
+
+ # Tell Phoenix to update the endpoint configuration
+ # whenever the application is updated.
+ @impl true
+ def config_change(changed, _new, removed) do
+ K8sBroadcasterWeb.Endpoint.config_change(changed, removed)
+ :ok
+ end
+end
diff --git a/k8s_broadcaster/lib/k8s_broadcaster/forwarder.ex b/k8s_broadcaster/lib/k8s_broadcaster/forwarder.ex
new file mode 100644
index 0000000..4e2d495
--- /dev/null
+++ b/k8s_broadcaster/lib/k8s_broadcaster/forwarder.ex
@@ -0,0 +1,541 @@
+defmodule K8sBroadcaster.Forwarder do
+ @moduledoc false
+
+ use GenServer
+
+ require Logger
+
+ alias Phoenix.PubSub
+
+ alias ExWebRTC.PeerConnection
+ alias ExWebRTC.RTP.H264
+ alias ExWebRTC.RTP.Munger
+
+ alias K8sBroadcaster.PeerSupervisor
+ alias K8sBroadcasterWeb.Channel
+
+ @pubsub K8sBroadcaster.PubSub
+ # Timeout for removing input/outputs that fail to connect
+ @connect_timeout_s 15
+ @connect_timeout_ms @connect_timeout_s * 1000
+
+ @type id :: String.t()
+
+ @type input_spec :: %{
+ pc: pid(),
+ id: id(),
+ video: String.t() | nil,
+ audio: String.t() | nil,
+ available_layers: [String.t()] | nil
+ }
+
+ @type output_spec :: %{
+ video: String.t() | nil,
+ audio: String.t() | nil,
+ munger: Munger.t(),
+ packet_loss: non_neg_integer(),
+ layer: String.t() | nil,
+ pending_layer: String.t() | nil
+ }
+
+ @type state :: %{
+ # WHIP
+ pending_input: input_spec() | nil,
+ local_input: input_spec() | nil,
+ remote_inputs: %{pid() => input_spec()},
+
+ # WHEP
+ pending_outputs: MapSet.t(pid()),
+ outputs: %{pid() => output_spec()}
+ }
+
+ @spec start_link(any()) :: GenServer.on_start()
+ def start_link(_arg) do
+ GenServer.start_link(__MODULE__, nil, name: __MODULE__)
+ end
+
+ @spec set_layer(pid(), String.t()) :: :ok | :error
+ def set_layer(pc, layer) do
+ GenServer.call(__MODULE__, {:set_layer, pc, layer})
+ end
+
+ @spec get_layers() :: {:ok, [String.t()] | nil} | :error
+ def get_layers() do
+ GenServer.call(__MODULE__, :get_layers)
+ end
+
+ @spec connect_input(pid(), id()) :: :ok
+ def connect_input(pc, id) do
+ GenServer.call(__MODULE__, {:connect_input, id, pc})
+ end
+
+ @spec connect_output(pid(), id()) :: :ok
+ def connect_output(pc, input_id) do
+ GenServer.call(__MODULE__, {:connect_output, input_id, pc})
+ end
+
+ @spec get_input() :: input_spec() | nil
+ def get_input() do
+ GenServer.call(__MODULE__, :get_input)
+ end
+
+ @spec get_local_input() :: input_spec() | nil
+ def get_local_input() do
+ GenServer.call(__MODULE__, :get_local_input)
+ end
+
+ @spec set_packet_loss(pid(), non_neg_integer()) :: :ok
+ def set_packet_loss(pc, value) do
+ GenServer.cast(__MODULE__, {:set_packet_loss, pc, value})
+ end
+
+ @impl true
+ def init(_arg) do
+ state = %{
+ pending_input: nil,
+ local_input: nil,
+ remote_input: nil,
+ pending_outputs: MapSet.new(),
+ outputs: %{}
+ }
+
+ {:ok, state, {:continue, :after_init}}
+ end
+
+ @impl true
+ def handle_continue(:after_init, state) do
+ # Get remote inputs already present in cluster
+ Node.list()
+ |> :erpc.multicall(__MODULE__, :get_local_input, [], 5000)
+ |> case do
+ input when is_map(input) -> send(self(), {:input_added, input})
+ _ -> :ok
+ end
+
+ PubSub.subscribe(@pubsub, "inputs")
+
+ {:noreply, state}
+ end
+
+ @impl true
+ def handle_call(:get_input, _from, state) do
+ {:reply, state.local_input || state.remote_input, state}
+ end
+
+ @impl true
+ def handle_call(:get_local_input, _from, state) do
+ {:reply, state.local_input, state}
+ end
+
+ @impl true
+ def handle_call({:set_layer, pc, layer}, _from, state) do
+ with {:ok, output} <- Map.fetch(state.outputs, pc),
+ input when not is_nil(input) <- state.local_input || state.remote_input,
+ true <- input.available_layers != nil,
+ true <- layer in input.available_layers do
+ output = %{output | pending_layer: layer}
+ state = %{state | outputs: Map.put(state.outputs, pc, output)}
+
+ PeerConnection.send_pli(input.pc, input.video, layer)
+
+ {:reply, :ok, state}
+ else
+ _other -> {:reply, :error, state}
+ end
+ end
+
+ @impl true
+ def handle_call(:get_layers, _from, state) do
+ case state.local_input || state.remote_input do
+ nil -> {:reply, :error, state}
+ input -> {:reply, {:ok, input.available_layers}, state}
+ end
+ end
+
+ @impl true
+ def handle_call({:connect_input, id, pc}, _from, %{local_input: nil, remote_input: nil} = state) do
+ terminate_pending_input(state)
+ Process.monitor(pc)
+
+ PeerConnection.controlling_process(pc, self())
+
+ input = %{
+ pc: pc,
+ id: id,
+ video: nil,
+ audio: nil,
+ available_layers: nil
+ }
+
+ state = %{state | pending_input: input}
+
+ Logger.info("Added new input #{id} (#{inspect(pc)})")
+ Process.send_after(self(), {:connect_timeout, pc}, @connect_timeout_ms)
+
+ {:reply, :ok, state}
+ end
+
+ @impl true
+ def handle_call({:connect_input, id, _pc}, _from, state) do
+ Logger.info("Cannot add input #{id} as there already is one connected.")
+ {:reply, :error, state}
+ end
+
+ @impl true
+ def handle_call({:connect_output, id, pc}, _from, state) do
+ Process.monitor(pc)
+
+ PeerConnection.controlling_process(pc, self())
+ pending_outputs = MapSet.put(state.pending_outputs, pc)
+
+ Logger.info("Added new output #{inspect(pc)} for input #{inspect(id)}")
+ Process.send_after(self(), {:connect_timeout, pc}, @connect_timeout_ms)
+
+ {:reply, :ok, %{state | pending_outputs: pending_outputs}}
+ end
+
+ @impl true
+ def handle_cast({:set_packet_loss, pc, value}, state) do
+ case Map.get(state.outputs, pc) do
+ nil ->
+ Logger.warning("Tried to set packet loss for non-existing peer connection.")
+ {:noreply, state}
+
+ _output ->
+ PeerConnection.set_packet_loss(pc, String.to_integer(value))
+ {:noreply, state}
+ end
+ end
+
+ @impl true
+ def handle_info({:connect_timeout, pc}, state) do
+ direction =
+ cond do
+ pc == state.pending_input -> :input
+ MapSet.member?(state.pending_outputs, pc) -> :output
+ true -> nil
+ end
+
+ if direction != nil do
+ Logger.warning("""
+ Terminating #{direction} #{inspect(pc)} \
+ because it didn't connect within #{@connect_timeout_s} seconds \
+ """)
+
+ PeerSupervisor.terminate_pc(pc)
+ end
+
+ {:noreply, state}
+ end
+
+ @impl true
+ def handle_info(
+ {:ex_webrtc, pc, {:connection_state_change, :connected}},
+ %{pending_input: %{pc: pc} = input} = state
+ ) do
+ {audio_track, video_track} = get_tracks(pc, :receiver)
+
+ input = %{
+ input
+ | video: video_track.id,
+ audio: audio_track.id,
+ available_layers: video_track.rids
+ }
+
+ state = %{state | local_input: input}
+
+ Logger.info("Input #{input.id} (#{inspect(pc)}) has successfully connected")
+ Channel.input_added(input.id)
+
+ # ID collisions in the cluster are unlikely and thus will not be checked against
+ PubSub.broadcast_from(@pubsub, self(), "inputs", {:input_added, input})
+
+ {:noreply, state}
+ end
+
+ @impl true
+ def handle_info({:ex_webrtc, pc, {:connection_state_change, :connected}}, state) do
+ case MapSet.member?(state.pending_outputs, pc) do
+ true ->
+ pending_outpus = MapSet.delete(state.pending_outputs, pc)
+ state = %{state | pending_outputs: pending_outpus}
+
+ state =
+ case state.local_input || state.remote_input do
+ nil ->
+ Logger.info("Terminating output #{inspect(pc)} because there is no input")
+ PeerSupervisor.terminate_pc(pc)
+ state
+
+ input ->
+ do_connect_output(pc, input, state)
+ end
+
+ {:noreply, state}
+
+ false ->
+ # We might have received this message at the same moment we where terminating peer connection
+ {:noreply, state}
+ end
+ end
+
+ @impl true
+ def handle_info({:ex_webrtc, pc, {:connection_state_change, :failed}}, state) do
+ Logger.warning("Peer connection #{inspect(pc)} state changed to `failed`. Terminating")
+ PeerSupervisor.terminate_pc(pc)
+ {:noreply, state}
+ end
+
+ @impl true
+ def handle_info({:ex_webrtc, input_pc, {:rtp, input_track, rid, packet}}, state) do
+ input = state.local_input || state.remote_input
+
+ state =
+ cond do
+ input_track == input.audio and rid == nil ->
+ PubSub.broadcast(@pubsub, "input:#{input.id}", {:input, input_pc, :audio, nil, packet})
+ forward_audio_packet(packet, state)
+
+ input_track == input.video ->
+ PubSub.broadcast(@pubsub, "input:#{input.id}", {:input, input_pc, :video, rid, packet})
+ forward_video_packet(packet, rid, state)
+
+ true ->
+ Logger.warning("Received an RTP packet corresponding to unknown track. Ignoring")
+ state
+ end
+
+ {:noreply, state}
+ end
+
+ @impl true
+ def handle_info({:ex_webrtc, pc, {:rtcp, packets}}, state) do
+ with {:ok, %{layer: layer}} <- Map.fetch(state.outputs, pc),
+ input when not is_nil(input) <- state.local_input || state.remote_input do
+ for packet <- packets do
+ case packet do
+ {_id, %ExRTCP.Packet.PayloadFeedback.PLI{}} ->
+ PeerConnection.send_pli(input.pc, input.video, layer)
+
+ _other ->
+ :ok
+ end
+ end
+ end
+
+ {:noreply, state}
+ end
+
+ @impl true
+ def handle_info({:input_added, input}, state) do
+ Logger.info("New remote input #{input.id}")
+
+ state =
+ state
+ |> terminate_pending_input()
+ |> terminate_local_input()
+ |> unsubscribe_remote_input()
+
+ PubSub.subscribe(@pubsub, "input:#{input.id}")
+
+ Logger.info("Subscribed to remote input #{input.id}")
+
+ {:noreply, %{state | remote_input: input}}
+ end
+
+ @impl true
+ def handle_info({:input_removed, id}, state) do
+ case state.remote_input do
+ %{id: ^id, pc: pc} = input ->
+ Logger.info("Remote input #{input.id} (#{inspect(pc)}) removed")
+ state = unsubscribe_remote_input(state)
+
+ for {output_pc, _output} <- state.outputs do
+ PeerSupervisor.terminate_pc(output_pc)
+ end
+
+ {:noreply, state}
+
+ %{id: id} ->
+ Logger.info("Remote input #{id} removed, but we are not subscribed to it. Ignoring.")
+
+ {:noreply, state}
+ end
+ end
+
+ @impl true
+ def handle_info({:input, pc, kind, rid, packet}, %{remote_input: %{pc: pc}} = state) do
+ state =
+ cond do
+ kind == :audio and rid == nil ->
+ forward_audio_packet(packet, state)
+
+ kind == :video ->
+ forward_video_packet(packet, rid, state)
+
+ true ->
+ Logger.warning("Received an RTP packet corresponding to unknown remote track. Ignoring")
+ state
+ end
+
+ {:noreply, state}
+ end
+
+ @impl true
+ def handle_info(
+ {:DOWN, _ref, :process, pid, reason},
+ %{local_input: %{pc: pid} = input} = state
+ ) do
+ Logger.info(
+ "Input #{input.id}: process #{inspect(pid)} exited with reason #{inspect(reason)}"
+ )
+
+ for {pc, _} <- state.outputs do
+ PeerSupervisor.terminate_pc(pc)
+ end
+
+ Channel.input_removed(input.id)
+ PubSub.broadcast_from(@pubsub, self(), "inputs", {:input_removed, pid})
+
+ {:noreply, %{state | local_input: nil}}
+ end
+
+ @impl true
+ def handle_info({:DOWN, _ref, :process, pid, reason}, state) do
+ cond do
+ pid == state.pending_input ->
+ Logger.info("""
+ Pending input #{state.pending_input.id}: process #{inspect(pid)} \
+ exited with reason #{inspect(reason)} \
+ """)
+
+ {:noreply, %{state | pending_input: nil}}
+
+ Map.has_key?(state.outputs, pid) ->
+ {output, state} = pop_in(state, [:outputs, pid])
+
+ Logger.info("""
+ Output process #{inspect(pid)} (input #{output.input_id}) \
+ exited with reason #{inspect(reason)} \
+ """)
+
+ {:noreply, state}
+
+ MapSet.member?(state.pending_outputs, pid) ->
+ pending_outputs = MapSet.delete(state.pending_outputs, pid)
+ state = %{state | pending_outputs: pending_outputs}
+
+ Logger.info(
+ "Pending output process #{inspect(pid)} exited with reason #{inspect(reason)} "
+ )
+
+ {:noreply, state}
+
+ true ->
+ Logger.warning("Unknown process #{inspect(pid)} died with reason #{inspect(reason)}")
+ {:noreply, state}
+ end
+ end
+
+ @impl true
+ def handle_info(_msg, state) do
+ {:noreply, state}
+ end
+
+ defp do_connect_output(pc, input, state) do
+ layer = default_layer(input)
+
+ {audio_track, video_track} = get_tracks(pc, :sender)
+ munger = Munger.new(90_000)
+
+ output = %{
+ audio: audio_track.id,
+ video: video_track.id,
+ input_id: input.id,
+ munger: munger,
+ layer: layer,
+ pending_layer: layer
+ }
+
+ Logger.info("Output #{inspect(pc)} has successfully connected")
+
+ # We don't send a PLI on behalf of the newly connected output.
+ # Once the remote end sends a PLI to us, we'll forward it.
+
+ put_in(state, [:outputs, pc], output)
+ end
+
+ defp forward_video_packet(packet, rid, state) do
+ outputs =
+ Map.new(state.outputs, fn
+ {pc, %{layer: layer, pending_layer: p_layer} = output} ->
+ output =
+ if p_layer == rid and p_layer != layer and H264.keyframe?(packet) do
+ munger = Munger.update(output.munger)
+ %{output | layer: p_layer, munger: munger}
+ else
+ output
+ end
+
+ output =
+ if rid == output.layer do
+ {packet, munger} = Munger.munge(output.munger, packet)
+ PeerConnection.send_rtp(pc, output.video, packet)
+ %{output | munger: munger}
+ else
+ output
+ end
+
+ {pc, output}
+ end)
+
+ %{state | outputs: outputs}
+ end
+
+ defp forward_audio_packet(packet, state) do
+ for {pc, output} <- state.outputs do
+ PeerConnection.send_rtp(pc, output.audio, packet)
+ end
+
+ state
+ end
+
+ defp get_tracks(pc, type) do
+ transceivers = PeerConnection.get_transceivers(pc)
+ audio_transceiver = Enum.find(transceivers, fn tr -> tr.kind == :audio end)
+ video_transceiver = Enum.find(transceivers, fn tr -> tr.kind == :video end)
+
+ audio_track = Map.fetch!(audio_transceiver, type).track
+ video_track = Map.fetch!(video_transceiver, type).track
+
+ {audio_track, video_track}
+ end
+
+ defp default_layer(%{available_layers: nil}), do: nil
+ defp default_layer(%{available_layers: [first | _]}), do: first
+
+ defp terminate_pending_input(%{pending_input: nil} = state), do: state
+
+ defp terminate_pending_input(%{pending_input: %{pc: pc, id: id}} = state) when is_pid(pc) do
+ Logger.info("Terminating pending local input: #{id}")
+ :ok = PeerSupervisor.terminate_pc(pc)
+ %{state | pending_input: nil}
+ end
+
+ defp terminate_local_input(%{local_input: nil} = state), do: state
+
+ defp terminate_local_input(%{local_input: %{pc: pc, id: id}} = state) when is_pid(pc) do
+ Logger.info("Terminating local input: #{id}")
+ :ok = PeerSupervisor.terminate_pc(pc)
+ PubSub.broadcast_from(@pubsub, self(), "inputs", {:input_removed, pc})
+ %{state | local_input: nil}
+ end
+
+ defp unsubscribe_remote_input(%{remote_input: nil} = state), do: state
+
+ defp unsubscribe_remote_input(%{remote_input: %{id: id}} = state) do
+ Logger.info("Unsubscribing from remote input: #{id}")
+ PubSub.unsubscribe(@pubsub, "input:#{id}")
+ %{state | remote_input: nil}
+ end
+end
diff --git a/k8s_broadcaster/lib/k8s_broadcaster/peer_supervisor.ex b/k8s_broadcaster/lib/k8s_broadcaster/peer_supervisor.ex
new file mode 100644
index 0000000..77581f0
--- /dev/null
+++ b/k8s_broadcaster/lib/k8s_broadcaster/peer_supervisor.ex
@@ -0,0 +1,151 @@
+defmodule K8sBroadcaster.PeerSupervisor do
+ @moduledoc false
+
+ use DynamicSupervisor
+
+ require Logger
+
+ alias ExWebRTC.{MediaStreamTrack, PeerConnection, SessionDescription, RTPCodecParameters}
+
+ @audio_codecs [
+ %RTPCodecParameters{
+ payload_type: 111,
+ mime_type: "audio/opus",
+ clock_rate: 48_000,
+ channels: 2
+ }
+ ]
+
+ @video_codecs [
+ %RTPCodecParameters{
+ payload_type: 96,
+ mime_type: "video/H264",
+ clock_rate: 90_000
+ }
+ ]
+
+ @spec client_pc_config() :: String.t()
+ def client_pc_config() do
+ pc_config = Application.fetch_env!(:k8s_broadcaster, :pc_config)
+
+ %{
+ iceServers: pc_config[:ice_servers],
+ iceTransportPolicy: pc_config[:ice_transport_policy]
+ }
+ |> Jason.encode!()
+ end
+
+ @spec start_link(any()) :: Supervisor.on_start()
+ def start_link(arg) do
+ :syn.add_node_to_scopes([K8sBroadcaster.GlobalPeerRegistry])
+
+ DynamicSupervisor.start_link(__MODULE__, arg, name: __MODULE__)
+ end
+
+ @spec start_whip(String.t()) :: {:ok, pid(), String.t(), String.t()} | {:error, term()}
+ def start_whip(offer_sdp), do: start_pc(offer_sdp, :recvonly)
+
+ @spec start_whep(String.t(), boolean()) ::
+ {:ok, pid(), String.t(), String.t()} | {:error, term()}
+ def start_whep(offer_sdp, rtx \\ true), do: start_pc(offer_sdp, :sendonly, rtx)
+
+ @spec fetch_pid(String.t()) :: {:ok, pid()} | {:error, :peer_not_found}
+ def fetch_pid(id) do
+ case :syn.lookup(K8sBroadcaster.GlobalPeerRegistry, id) do
+ :undefined -> {:error, :peer_not_found}
+ {pid, _val} -> {:ok, pid}
+ end
+ end
+
+ @spec terminate_pc(pid()) :: :ok | {:error, :not_found}
+ def terminate_pc(pc) do
+ DynamicSupervisor.terminate_child(__MODULE__, pc)
+ end
+
+ @impl true
+ def init(_arg) do
+ DynamicSupervisor.init(strategy: :one_for_one)
+ end
+
+ defp start_pc(offer_sdp, direction, rtx \\ true) do
+ offer = %SessionDescription{type: :offer, sdp: offer_sdp}
+ pc_id = generate_pc_id()
+ {:ok, pc} = spawn_peer_connection(rtx)
+ :syn.register(K8sBroadcaster.GlobalPeerRegistry, pc_id, pc)
+
+ Logger.info("Received offer for #{inspect(pc)}")
+ Logger.debug("Offer SDP for #{inspect(pc)}:\n#{offer.sdp}")
+
+ with :ok <- PeerConnection.set_remote_description(pc, offer),
+ :ok <- setup_transceivers(pc, direction),
+ {:ok, answer} <- PeerConnection.create_answer(pc),
+ :ok <- PeerConnection.set_local_description(pc, answer),
+ :ok <- gather_candidates(pc),
+ answer <- PeerConnection.get_local_description(pc) do
+ Logger.info("Sent answer for #{inspect(pc)}")
+ Logger.debug("Answer SDP for #{inspect(pc)}:\n#{answer.sdp}")
+
+ {:ok, pc, pc_id, answer.sdp}
+ else
+ {:error, _res} = err ->
+ Logger.info("Failed to complete negotiation for #{inspect(pc)}")
+ terminate_pc(pc)
+ err
+ end
+ end
+
+ defp setup_transceivers(pc, direction) do
+ if direction == :sendonly do
+ stream_id = MediaStreamTrack.generate_stream_id()
+ {:ok, _sender} = PeerConnection.add_track(pc, MediaStreamTrack.new(:audio, [stream_id]))
+ {:ok, _sender} = PeerConnection.add_track(pc, MediaStreamTrack.new(:video, [stream_id]))
+ end
+
+ transceivers = PeerConnection.get_transceivers(pc)
+
+ for %{id: id} <- transceivers do
+ PeerConnection.set_transceiver_direction(pc, id, direction)
+ end
+
+ :ok
+ end
+
+ defp spawn_peer_connection(rtx) do
+ features =
+ if rtx do
+ ExWebRTC.PeerConnection.Configuration.default_features()
+ else
+ ExWebRTC.PeerConnection.Configuration.default_features() -- [:inbound_rtx, :outbound_rtx]
+ end
+
+ pc_opts =
+ (Application.fetch_env!(:k8s_broadcaster, :pc_config) ++
+ [
+ audio_codecs: @audio_codecs,
+ video_codecs: @video_codecs,
+ controlling_process: self(),
+ features: features
+ ])
+ |> Keyword.delete(:ice_transport_policy)
+
+ child_spec = %{
+ id: PeerConnection,
+ start: {PeerConnection, :start_link, [pc_opts, []]},
+ restart: :temporary
+ }
+
+ DynamicSupervisor.start_child(__MODULE__, child_spec)
+ end
+
+ defp gather_candidates(pc) do
+ # we either wait for all of the candidates
+ # or whatever we were able to gather in one second
+ receive do
+ {:ex_webrtc, ^pc, {:ice_gathering_state_change, :complete}} -> :ok
+ after
+ 1000 -> :ok
+ end
+ end
+
+ defp generate_pc_id(), do: for(_ <- 1..10, into: "", do: <
>)
+end
diff --git a/k8s_broadcaster/lib/k8s_broadcaster_web.ex b/k8s_broadcaster/lib/k8s_broadcaster_web.ex
new file mode 100644
index 0000000..00d0818
--- /dev/null
+++ b/k8s_broadcaster/lib/k8s_broadcaster_web.ex
@@ -0,0 +1,111 @@
+defmodule K8sBroadcasterWeb do
+ @moduledoc """
+ The entrypoint for defining your web interface, such
+ as controllers, components, channels, and so on.
+
+ This can be used in your application as:
+
+ use K8sBroadcasterWeb, :controller
+ use K8sBroadcasterWeb, :html
+
+ The definitions below will be executed for every controller,
+ component, etc, so keep them short and clean, focused
+ on imports, uses and aliases.
+
+ Do NOT define functions inside the quoted expressions
+ below. Instead, define additional modules and import
+ those modules here.
+ """
+
+ def static_paths, do: ~w(assets fonts images favicon.ico robots.txt)
+
+ def router do
+ quote do
+ use Phoenix.Router, helpers: false
+
+ # Import common connection and controller functions to use in pipelines
+ import Plug.Conn
+ import Phoenix.Controller
+ import Phoenix.LiveView.Router
+ end
+ end
+
+ def channel do
+ quote do
+ use Phoenix.Channel
+ end
+ end
+
+ def controller do
+ quote do
+ use Phoenix.Controller,
+ formats: [:html, :json],
+ layouts: [html: K8sBroadcasterWeb.Layouts]
+
+ import Plug.Conn
+
+ unquote(verified_routes())
+ end
+ end
+
+ def live_view do
+ quote do
+ use Phoenix.LiveView,
+ layout: {K8sBroadcasterWeb.Layouts, :app}
+
+ unquote(html_helpers())
+ end
+ end
+
+ def live_component do
+ quote do
+ use Phoenix.LiveComponent
+
+ unquote(html_helpers())
+ end
+ end
+
+ def html do
+ quote do
+ use Phoenix.Component
+
+ # Import convenience functions from controllers
+ import Phoenix.Controller,
+ only: [get_csrf_token: 0, view_module: 1, view_template: 1]
+
+ # Include general helpers for rendering HTML
+ unquote(html_helpers())
+ end
+ end
+
+ defp html_helpers do
+ quote do
+ # HTML escaping functionality
+ import Phoenix.HTML
+ # Core UI components
+ import K8sBroadcasterWeb.CoreComponents
+
+ # Shortcut for generating JS commands
+ alias Phoenix.LiveView.JS
+
+ # Routes generation with the ~p sigil
+ unquote(verified_routes())
+ end
+ end
+
+ def verified_routes do
+ quote do
+ use Phoenix.VerifiedRoutes,
+ endpoint: K8sBroadcasterWeb.Endpoint,
+ router: K8sBroadcasterWeb.Router,
+ statics: K8sBroadcasterWeb.static_paths()
+ end
+ end
+
+ @doc """
+ When used, dispatch to the appropriate controller/live_view/etc.
+ """
+ defmacro __using__(which) when is_atom(which) do
+ apply(__MODULE__, which, [])
+ end
+end
diff --git a/k8s_broadcaster/lib/k8s_broadcaster_web/channels/channel.ex b/k8s_broadcaster/lib/k8s_broadcaster_web/channels/channel.ex
new file mode 100644
index 0000000..f869631
--- /dev/null
+++ b/k8s_broadcaster/lib/k8s_broadcaster_web/channels/channel.ex
@@ -0,0 +1,46 @@
+defmodule K8sBroadcasterWeb.Channel do
+ @moduledoc false
+
+ use K8sBroadcasterWeb, :channel
+
+ alias K8sBroadcasterWeb.{Endpoint, Presence}
+
+ @spec input_added(String.t()) :: :ok
+ def input_added(id) do
+ Endpoint.broadcast!("k8s_broadcaster:signaling", "input_added", %{id: id})
+ end
+
+ @spec input_removed(String.t()) :: :ok
+ def input_removed(id) do
+ Endpoint.broadcast!("k8s_broadcaster:signaling", "input_removed", %{id: id})
+ end
+
+ @impl true
+ def join("k8s_broadcaster:signaling", _, socket) do
+ send(self(), :after_join)
+ {:ok, socket}
+ end
+
+ @impl true
+ def handle_info(:after_join, socket) do
+ {:ok, _} = Presence.track(socket, socket.assigns.user_id, %{})
+ push(socket, "presence_state", Presence.list(socket))
+
+ case K8sBroadcaster.Forwarder.get_input() do
+ nil -> :ok
+ input -> push(socket, "input_added", %{id: input.id})
+ end
+
+ {:noreply, socket}
+ end
+
+ @impl true
+ def handle_in("packet_loss", payload, socket) do
+ case K8sBroadcaster.PeerSupervisor.fetch_pid(payload["resourceId"]) do
+ {:ok, pid} -> K8sBroadcaster.Forwarder.set_packet_loss(pid, payload["value"])
+ _ -> :ok
+ end
+
+ {:noreply, socket}
+ end
+end
diff --git a/k8s_broadcaster/lib/k8s_broadcaster_web/channels/presence.ex b/k8s_broadcaster/lib/k8s_broadcaster_web/channels/presence.ex
new file mode 100644
index 0000000..3321563
--- /dev/null
+++ b/k8s_broadcaster/lib/k8s_broadcaster_web/channels/presence.ex
@@ -0,0 +1,11 @@
+defmodule K8sBroadcasterWeb.Presence do
+ @moduledoc """
+ Provides presence tracking to channels and processes.
+
+ See the [`Phoenix.Presence`](https://hexdocs.pm/phoenix/Phoenix.Presence.html)
+ docs for more details.
+ """
+ use Phoenix.Presence,
+ otp_app: :k8s_broadcaster,
+ pubsub_server: K8sBroadcaster.PubSub
+end
diff --git a/k8s_broadcaster/lib/k8s_broadcaster_web/channels/user_socket.ex b/k8s_broadcaster/lib/k8s_broadcaster_web/channels/user_socket.ex
new file mode 100644
index 0000000..89a8a77
--- /dev/null
+++ b/k8s_broadcaster/lib/k8s_broadcaster_web/channels/user_socket.ex
@@ -0,0 +1,19 @@
+defmodule K8sBroadcasterWeb.UserSocket do
+ use Phoenix.Socket
+
+ channel "k8s_broadcaster:*", K8sBroadcasterWeb.Channel
+
+ @impl true
+ def connect(_params, socket, _connect_info) do
+ {:ok, assign(socket, :user_id, generate_id())}
+ end
+
+ @impl true
+ def id(socket), do: "user_socket:#{socket.assigns.user_id}"
+
+ defp generate_id do
+ 10
+ |> :crypto.strong_rand_bytes()
+ |> Base.url_encode64()
+ end
+end
diff --git a/k8s_broadcaster/lib/k8s_broadcaster_web/components/core_components.ex b/k8s_broadcaster/lib/k8s_broadcaster_web/components/core_components.ex
new file mode 100644
index 0000000..c1f3872
--- /dev/null
+++ b/k8s_broadcaster/lib/k8s_broadcaster_web/components/core_components.ex
@@ -0,0 +1,672 @@
+defmodule K8sBroadcasterWeb.CoreComponents do
+ @moduledoc """
+ Provides core UI components.
+
+ At first glance, this module may seem daunting, but its goal is to provide
+ core building blocks for your application, such as modals, tables, and
+ forms. The components consist mostly of markup and are well-documented
+ with doc strings and declarative assigns. You may customize and style
+ them in any way you want, based on your application growth and needs.
+
+ The default components use Tailwind CSS, a utility-first CSS framework.
+ See the [Tailwind CSS documentation](https://tailwindcss.com) to learn
+ how to customize them or feel free to swap in another framework altogether.
+
+ Icons are provided by [heroicons](https://heroicons.com). See `icon/1` for usage.
+ """
+ use Phoenix.Component
+
+ alias Phoenix.LiveView.JS
+
+ @doc """
+ Renders a modal.
+
+ ## Examples
+
+ <.modal id="confirm-modal">
+ This is a modal.
+
+
+ JS commands may be passed to the `:on_cancel` to configure
+ the closing/cancel event, for example:
+
+ <.modal id="confirm" on_cancel={JS.navigate(~p"/posts")}>
+ This is another modal.
+
+
+ """
+ attr :id, :string, required: true
+ attr :show, :boolean, default: false
+ attr :on_cancel, JS, default: %JS{}
+ slot :inner_block, required: true
+
+ def modal(assigns) do
+ ~H"""
+
+
+
+
+
+ <.focus_wrap
+ id={"#{@id}-container"}
+ phx-window-keydown={JS.exec("data-cancel", to: "##{@id}")}
+ phx-key="escape"
+ phx-click-away={JS.exec("data-cancel", to: "##{@id}")}
+ class="shadow-zinc-700/10 ring-zinc-700/10 relative hidden rounded-2xl bg-white p-14 shadow-lg ring-1 transition"
+ >
+
+
+ <.icon name="hero-x-mark-solid" class="h-5 w-5" />
+
+
+
+ {render_slot(@inner_block)}
+
+
+
+
+
+
+ """
+ end
+
+ @doc """
+ Renders flash notices.
+
+ ## Examples
+
+ <.flash kind={:info} flash={@flash} />
+ <.flash kind={:info} phx-mounted={show("#flash")}>Welcome Back!
+ """
+ attr :id, :string, doc: "the optional id of flash container"
+ attr :flash, :map, default: %{}, doc: "the map of flash messages to display"
+ attr :title, :string, default: nil
+ attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup"
+ attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"
+
+ slot :inner_block, doc: "the optional inner block that renders the flash message"
+
+ def flash(assigns) do
+ assigns = assign_new(assigns, :id, fn -> "flash-#{assigns.kind}" end)
+
+ ~H"""
+ hide("##{@id}")}
+ role="alert"
+ class={[
+ "fixed top-2 right-2 mr-2 w-80 sm:w-96 z-50 rounded-lg p-3 ring-1",
+ @kind == :info && "bg-emerald-50 text-emerald-800 ring-emerald-500 fill-cyan-900",
+ @kind == :error && "bg-rose-50 text-rose-900 shadow-md ring-rose-500 fill-rose-900"
+ ]}
+ {@rest}
+ >
+
+ <.icon :if={@kind == :info} name="hero-information-circle-mini" class="h-4 w-4" />
+ <.icon :if={@kind == :error} name="hero-exclamation-circle-mini" class="h-4 w-4" />
+ {@title}
+
+
{msg}
+
+ <.icon name="hero-x-mark-solid" class="h-5 w-5 opacity-40 group-hover:opacity-70" />
+
+
+ """
+ end
+
+ @doc """
+ Shows the flash group with standard titles and content.
+
+ ## Examples
+
+ <.flash_group flash={@flash} />
+ """
+ attr :flash, :map, required: true, doc: "the map of flash messages"
+ attr :id, :string, default: "flash-group", doc: "the optional id of flash container"
+
+ def flash_group(assigns) do
+ ~H"""
+
+ <.flash kind={:info} title="Success!" flash={@flash} />
+ <.flash kind={:error} title="Error!" flash={@flash} />
+ <.flash
+ id="client-error"
+ kind={:error}
+ title="We can't find the internet"
+ phx-disconnected={show(".phx-client-error #client-error")}
+ phx-connected={hide("#client-error")}
+ hidden
+ >
+ Attempting to reconnect
+ <.icon name="hero-arrow-path" class="ml-1 h-3 w-3 animate-spin" />
+
+
+ <.flash
+ id="server-error"
+ kind={:error}
+ title="Something went wrong!"
+ phx-disconnected={show(".phx-server-error #server-error")}
+ phx-connected={hide("#server-error")}
+ hidden
+ >
+ Hang in there while we get back on track
+ <.icon name="hero-arrow-path" class="ml-1 h-3 w-3 animate-spin" />
+
+
+ """
+ end
+
+ @doc """
+ Renders a simple form.
+
+ ## Examples
+
+ <.simple_form for={@form} phx-change="validate" phx-submit="save">
+ <.input field={@form[:email]} label="Email"/>
+ <.input field={@form[:username]} label="Username" />
+ <:actions>
+ <.button>Save
+
+
+ """
+ attr :for, :any, required: true, doc: "the data structure for the form"
+ attr :as, :any, default: nil, doc: "the server side parameter to collect all input under"
+
+ attr :rest, :global,
+ include: ~w(autocomplete name rel action enctype method novalidate target multipart),
+ doc: "the arbitrary HTML attributes to apply to the form tag"
+
+ slot :inner_block, required: true
+ slot :actions, doc: "the slot for form actions, such as a submit button"
+
+ def simple_form(assigns) do
+ ~H"""
+ <.form :let={f} for={@for} as={@as} {@rest}>
+
+ {render_slot(@inner_block, f)}
+
+ {render_slot(action, f)}
+
+
+
+ """
+ end
+
+ @doc """
+ Renders a button.
+
+ ## Examples
+
+ <.button>Send!
+ <.button phx-click="go" class="ml-2">Send!
+ """
+ attr :type, :string, default: nil
+ attr :class, :string, default: nil
+ attr :rest, :global, include: ~w(disabled form name value)
+
+ slot :inner_block, required: true
+
+ def button(assigns) do
+ ~H"""
+
+ {render_slot(@inner_block)}
+
+ """
+ end
+
+ @doc """
+ Renders an input with label and error messages.
+
+ A `Phoenix.HTML.FormField` may be passed as argument,
+ which is used to retrieve the input name, id, and values.
+ Otherwise all attributes may be passed explicitly.
+
+ ## Types
+
+ This function accepts all HTML input types, considering that:
+
+ * You may also set `type="select"` to render a `` tag
+
+ * `type="checkbox"` is used exclusively to render boolean values
+
+ * For live file uploads, see `Phoenix.Component.live_file_input/1`
+
+ See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input
+ for more information. Unsupported types, such as hidden and radio,
+ are best written directly in your templates.
+
+ ## Examples
+
+ <.input field={@form[:email]} type="email" />
+ <.input name="my-input" errors={["oh no!"]} />
+ """
+ attr :id, :any, default: nil
+ attr :name, :any
+ attr :label, :string, default: nil
+ attr :value, :any
+
+ attr :type, :string,
+ default: "text",
+ values: ~w(checkbox color date datetime-local email file month number password
+ range search select tel text textarea time url week)
+
+ attr :field, Phoenix.HTML.FormField,
+ doc: "a form field struct retrieved from the form, for example: @form[:email]"
+
+ attr :errors, :list, default: []
+ attr :checked, :boolean, doc: "the checked flag for checkbox inputs"
+ attr :prompt, :string, default: nil, doc: "the prompt for select inputs"
+ attr :options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2"
+ attr :multiple, :boolean, default: false, doc: "the multiple flag for select inputs"
+
+ attr :rest, :global,
+ include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength
+ multiple pattern placeholder readonly required rows size step)
+
+ def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
+ errors = if Phoenix.Component.used_input?(field), do: field.errors, else: []
+
+ assigns
+ |> assign(field: nil, id: assigns.id || field.id)
+ |> assign(:errors, Enum.map(errors, &translate_error(&1)))
+ |> assign_new(:name, fn -> if assigns.multiple, do: field.name <> "[]", else: field.name end)
+ |> assign_new(:value, fn -> field.value end)
+ |> input()
+ end
+
+ def input(%{type: "checkbox"} = assigns) do
+ assigns =
+ assign_new(assigns, :checked, fn ->
+ Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value])
+ end)
+
+ ~H"""
+
+
+
+
+ {@label}
+
+ <.error :for={msg <- @errors}>{msg}
+
+ """
+ end
+
+ def input(%{type: "select"} = assigns) do
+ ~H"""
+
+ <.label for={@id}>{@label}
+
+ {@prompt}
+ {Phoenix.HTML.Form.options_for_select(@options, @value)}
+
+ <.error :for={msg <- @errors}>{msg}
+
+ """
+ end
+
+ def input(%{type: "textarea"} = assigns) do
+ ~H"""
+
+ <.label for={@id}>{@label}
+
+ <.error :for={msg <- @errors}>{msg}
+
+ """
+ end
+
+ # All other inputs text, datetime-local, url, password, etc. are handled here...
+ def input(assigns) do
+ ~H"""
+
+ <.label for={@id}>{@label}
+
+ <.error :for={msg <- @errors}>{msg}
+
+ """
+ end
+
+ @doc """
+ Renders a label.
+ """
+ attr :for, :string, default: nil
+ slot :inner_block, required: true
+
+ def label(assigns) do
+ ~H"""
+
+ {render_slot(@inner_block)}
+
+ """
+ end
+
+ @doc """
+ Generates a generic error message.
+ """
+ slot :inner_block, required: true
+
+ def error(assigns) do
+ ~H"""
+
+ <.icon name="hero-exclamation-circle-mini" class="mt-0.5 h-5 w-5 flex-none" />
+ {render_slot(@inner_block)}
+
+ """
+ end
+
+ @doc """
+ Renders a header with title.
+ """
+ attr :class, :string, default: nil
+
+ slot :inner_block, required: true
+ slot :subtitle
+ slot :actions
+
+ def header(assigns) do
+ ~H"""
+
+ """
+ end
+
+ @doc ~S"""
+ Renders a table with generic styling.
+
+ ## Examples
+
+ <.table id="users" rows={@users}>
+ <:col :let={user} label="id">{user.id}
+ <:col :let={user} label="username">{user.username}
+
+ """
+ attr :id, :string, required: true
+ attr :rows, :list, required: true
+ attr :row_id, :any, default: nil, doc: "the function for generating the row id"
+ attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row"
+
+ attr :row_item, :any,
+ default: &Function.identity/1,
+ doc: "the function for mapping each row before calling the :col and :action slots"
+
+ slot :col, required: true do
+ attr :label, :string
+ end
+
+ slot :action, doc: "the slot for showing user actions in the last table column"
+
+ def table(assigns) do
+ assigns =
+ with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do
+ assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end)
+ end
+
+ ~H"""
+
+
+
+
+ {col[:label]}
+
+ Actions
+
+
+
+
+
+
+
+
+
+ {render_slot(col, @row_item.(row))}
+
+
+
+
+
+
+
+ {render_slot(action, @row_item.(row))}
+
+
+
+
+
+
+
+ """
+ end
+
+ @doc """
+ Renders a data list.
+
+ ## Examples
+
+ <.list>
+ <:item title="Title">{@post.title}
+ <:item title="Views">{@post.views}
+
+ """
+ slot :item, required: true do
+ attr :title, :string, required: true
+ end
+
+ def list(assigns) do
+ ~H"""
+
+
+
+
{item.title}
+ {render_slot(item)}
+
+
+
+ """
+ end
+
+ @doc """
+ Renders a back navigation link.
+
+ ## Examples
+
+ <.back navigate={~p"/posts"}>Back to posts
+ """
+ attr :navigate, :any, required: true
+ slot :inner_block, required: true
+
+ def back(assigns) do
+ ~H"""
+
+ <.link
+ navigate={@navigate}
+ class="text-sm font-semibold leading-6 text-zinc-900 hover:text-zinc-700"
+ >
+ <.icon name="hero-arrow-left-solid" class="h-3 w-3" />
+ {render_slot(@inner_block)}
+
+
+ """
+ end
+
+ @doc """
+ Renders a [Heroicon](https://heroicons.com).
+
+ Heroicons come in three styles – outline, solid, and mini.
+ By default, the outline style is used, but solid and mini may
+ be applied by using the `-solid` and `-mini` suffix.
+
+ You can customize the size and colors of the icons by setting
+ width, height, and background color classes.
+
+ Icons are extracted from the `deps/heroicons` directory and bundled within
+ your compiled app.css by the plugin in your `assets/tailwind.config.js`.
+
+ ## Examples
+
+ <.icon name="hero-x-mark-solid" />
+ <.icon name="hero-arrow-path" class="ml-1 w-3 h-3 animate-spin" />
+ """
+ attr :name, :string, required: true
+ attr :class, :string, default: nil
+
+ def icon(%{name: "hero-" <> _} = assigns) do
+ ~H"""
+
+ """
+ end
+
+ ## JS Commands
+
+ def show(js \\ %JS{}, selector) do
+ JS.show(js,
+ to: selector,
+ time: 300,
+ transition:
+ {"transition-all transform ease-out duration-300",
+ "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95",
+ "opacity-100 translate-y-0 sm:scale-100"}
+ )
+ end
+
+ def hide(js \\ %JS{}, selector) do
+ JS.hide(js,
+ to: selector,
+ time: 200,
+ transition:
+ {"transition-all transform ease-in duration-200",
+ "opacity-100 translate-y-0 sm:scale-100",
+ "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"}
+ )
+ end
+
+ def show_modal(js \\ %JS{}, id) when is_binary(id) do
+ js
+ |> JS.show(to: "##{id}")
+ |> JS.show(
+ to: "##{id}-bg",
+ time: 300,
+ transition: {"transition-all transform ease-out duration-300", "opacity-0", "opacity-100"}
+ )
+ |> show("##{id}-container")
+ |> JS.add_class("overflow-hidden", to: "body")
+ |> JS.focus_first(to: "##{id}-content")
+ end
+
+ def hide_modal(js \\ %JS{}, id) do
+ js
+ |> JS.hide(
+ to: "##{id}-bg",
+ transition: {"transition-all transform ease-in duration-200", "opacity-100", "opacity-0"}
+ )
+ |> hide("##{id}-container")
+ |> JS.hide(to: "##{id}", transition: {"block", "block", "hidden"})
+ |> JS.remove_class("overflow-hidden", to: "body")
+ |> JS.pop_focus()
+ end
+
+ @doc """
+ Translates an error message using gettext.
+ """
+ def translate_error({msg, opts}) do
+ # You can make use of gettext to translate error messages by
+ # uncommenting and adjusting the following code:
+
+ # if count = opts[:count] do
+ # Gettext.dngettext(K8sBroadcasterWeb.Gettext, "errors", msg, msg, count, opts)
+ # else
+ # Gettext.dgettext(K8sBroadcasterWeb.Gettext, "errors", msg, opts)
+ # end
+
+ Enum.reduce(opts, msg, fn {key, value}, acc ->
+ String.replace(acc, "%{#{key}}", fn _ -> to_string(value) end)
+ end)
+ end
+
+ @doc """
+ Translates the errors for a field from a keyword list of errors.
+ """
+ def translate_errors(errors, field) when is_list(errors) do
+ for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts})
+ end
+end
diff --git a/k8s_broadcaster/lib/k8s_broadcaster_web/components/layouts.ex b/k8s_broadcaster/lib/k8s_broadcaster_web/components/layouts.ex
new file mode 100644
index 0000000..e9c3f9c
--- /dev/null
+++ b/k8s_broadcaster/lib/k8s_broadcaster_web/components/layouts.ex
@@ -0,0 +1,14 @@
+defmodule K8sBroadcasterWeb.Layouts do
+ @moduledoc """
+ This module holds different layouts used by your application.
+
+ See the `layouts` directory for all templates available.
+ The "root" layout is a skeleton rendered as part of the
+ application router. The "app" layout is set as the default
+ layout on both `use K8sBroadcasterWeb, :controller` and
+ `use K8sBroadcasterWeb, :live_view`.
+ """
+ use K8sBroadcasterWeb, :html
+
+ embed_templates "layouts/*"
+end
diff --git a/k8s_broadcaster/lib/k8s_broadcaster_web/components/layouts/app.html.heex b/k8s_broadcaster/lib/k8s_broadcaster_web/components/layouts/app.html.heex
new file mode 100644
index 0000000..bb1c158
--- /dev/null
+++ b/k8s_broadcaster/lib/k8s_broadcaster_web/components/layouts/app.html.heex
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
0
+ <.icon name="hero-user-solid" />
+
+
+
+
+
+
+
+
+ <.flash_group flash={@flash} />
+ {@inner_content}
+
+
+
+ {K8sBroadcaster.Application.version()}
+
diff --git a/k8s_broadcaster/lib/k8s_broadcaster_web/components/layouts/root.html.heex b/k8s_broadcaster/lib/k8s_broadcaster_web/components/layouts/root.html.heex
new file mode 100644
index 0000000..a7ae144
--- /dev/null
+++ b/k8s_broadcaster/lib/k8s_broadcaster_web/components/layouts/root.html.heex
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+ <.live_title suffix=" · K8sBroadcaster">
+ {assigns[:page_title]}
+
+
+
+
+
+ {@inner_content}
+
+
diff --git a/k8s_broadcaster/lib/k8s_broadcaster_web/controllers/error_html.ex b/k8s_broadcaster/lib/k8s_broadcaster_web/controllers/error_html.ex
new file mode 100644
index 0000000..accbea6
--- /dev/null
+++ b/k8s_broadcaster/lib/k8s_broadcaster_web/controllers/error_html.ex
@@ -0,0 +1,24 @@
+defmodule K8sBroadcasterWeb.ErrorHTML do
+ @moduledoc """
+ This module is invoked by your endpoint in case of errors on HTML requests.
+
+ See config/config.exs.
+ """
+ use K8sBroadcasterWeb, :html
+
+ # If you want to customize your error pages,
+ # uncomment the embed_templates/1 call below
+ # and add pages to the error directory:
+ #
+ # * lib/k8s_broadcaster_web/controllers/error_html/404.html.heex
+ # * lib/k8s_broadcaster_web/controllers/error_html/500.html.heex
+ #
+ # embed_templates "error_html/*"
+
+ # The default is to render a plain text page based on
+ # the template name. For example, "404.html" becomes
+ # "Not Found".
+ def render(template, _assigns) do
+ Phoenix.Controller.status_message_from_template(template)
+ end
+end
diff --git a/k8s_broadcaster/lib/k8s_broadcaster_web/controllers/error_json.ex b/k8s_broadcaster/lib/k8s_broadcaster_web/controllers/error_json.ex
new file mode 100644
index 0000000..5fb1015
--- /dev/null
+++ b/k8s_broadcaster/lib/k8s_broadcaster_web/controllers/error_json.ex
@@ -0,0 +1,21 @@
+defmodule K8sBroadcasterWeb.ErrorJSON do
+ @moduledoc """
+ This module is invoked by your endpoint in case of errors on JSON requests.
+
+ See config/config.exs.
+ """
+
+ # If you want to customize a particular status code,
+ # you may add your own clauses, such as:
+ #
+ # def render("500.json", _assigns) do
+ # %{errors: %{detail: "Internal Server Error"}}
+ # end
+
+ # By default, Phoenix returns the status message from
+ # the template name. For example, "404.json" becomes
+ # "Not Found".
+ def render(template, _assigns) do
+ %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}}
+ end
+end
diff --git a/k8s_broadcaster/lib/k8s_broadcaster_web/controllers/media_controller.ex b/k8s_broadcaster/lib/k8s_broadcaster_web/controllers/media_controller.ex
new file mode 100644
index 0000000..d550d75
--- /dev/null
+++ b/k8s_broadcaster/lib/k8s_broadcaster_web/controllers/media_controller.ex
@@ -0,0 +1,158 @@
+defmodule K8sBroadcasterWeb.MediaController do
+ use K8sBroadcasterWeb, :controller
+
+ alias K8sBroadcaster.{Forwarder, PeerSupervisor}
+ alias ExWebRTC.PeerConnection
+
+ @sse ~s/rel = "urn:ietf:params:whep:ext:core:server-sent-events"; events="layers"/
+ @layer ~s/rel = "urn:ietf:params:whep:ext:core:layer"/
+
+ plug :accepts, ["sdp"] when action in [:whip, :whep]
+ plug :accepts, ["trickle-ice-sdpfrag"] when action in [:ice_candidate]
+
+ # Used by Corsica in handling CORS requests: allows fetching response headers
+ # by external WHEP players implemented in a browser.
+ #
+ # All headers used in the WHEP responses should be specified here
+ def cors_expose_headers do
+ ["location", "link", "content-type", "connection", "cache-control"]
+ end
+
+ def pc_config(conn, _params) do
+ conn
+ |> resp(200, K8sBroadcaster.PeerSupervisor.client_pc_config())
+ |> send_resp()
+ end
+
+ # TODO: use proper statuses in case of error
+ def whip(conn, _params) do
+ with :ok <- authenticate(conn),
+ {:ok, offer_sdp, conn} <- read_body(conn),
+ {:ok, pc, pc_id, answer_sdp} <- PeerSupervisor.start_whip(offer_sdp),
+ :ok <- Forwarder.connect_input(pc, pc_id) do
+ conn
+ |> put_resp_header("location", ~p"/api/resource/#{pc_id}")
+ |> put_resp_content_type("application/sdp")
+ |> resp(201, answer_sdp)
+ else
+ _other -> resp(conn, 400, "Bad request")
+ end
+ |> send_resp()
+ end
+
+ def whep(conn, params) do
+ input_id = params["inputId"]
+
+ with {:ok, body, conn} <- read_body(conn),
+ {:ok, %{"sdp" => offer_sdp, "rtx" => rtx}} <- Jason.decode(body),
+ {:ok, pc, pc_id, answer_sdp} <- PeerSupervisor.start_whep(offer_sdp, rtx),
+ :ok <- Forwarder.connect_output(pc, input_id) do
+ uri = ~p"/api/resource/#{pc_id}"
+
+ conn
+ |> put_resp_header("location", uri)
+ # Plug does not allow adding multiple headers with the same name
+ |> put_resp_header("link", "<#{uri}/layer>; " <> @layer)
+ |> put_resp_header("link", "<#{uri}/sse>; " <> @sse)
+ |> put_resp_content_type("application/sdp")
+ |> resp(201, answer_sdp)
+ else
+ other ->
+ dbg(other)
+ resp(conn, 400, "Bad request")
+ end
+ |> send_resp()
+ end
+
+ def ice_candidate(conn, %{"resource_id" => resource_id}) do
+ with {:ok, pid} <- PeerSupervisor.fetch_pid(resource_id),
+ {:ok, body, conn} <- read_body(conn),
+ {:ok, json} <- Jason.decode(body) do
+ candidate = ExWebRTC.ICECandidate.from_json(json)
+ :ok = PeerConnection.add_ice_candidate(pid, candidate)
+ resp(conn, 204, "")
+ else
+ _other -> resp(conn, 400, "Bad request")
+ end
+ |> send_resp()
+ end
+
+ def sse(conn, %{"resource_id" => resource_id}) do
+ with {:ok, _pid} <- PeerSupervisor.fetch_pid(resource_id),
+ {:ok, events} when is_list(events) <- Map.fetch(conn.body_params, "_json") do
+ # for now, we just ignore events
+ conn
+ |> put_resp_header("location", ~p"/api/resource/#{resource_id}/sse/event-stream")
+ |> resp(201, "")
+ else
+ _other -> resp(conn, 400, "Bad request")
+ end
+ |> send_resp()
+ end
+
+ def event_stream(conn, %{"resource_id" => resource_id}) do
+ case PeerSupervisor.fetch_pid(resource_id) do
+ {:ok, _pid} ->
+ conn
+ |> put_resp_header("content-type", "text/event-stream")
+ |> put_resp_header("connection", "keep-alive")
+ |> put_resp_header("cache-control", "no-cache")
+ |> send_chunked(200)
+ |> update_layers()
+
+ {:error, :peer_not_found} ->
+ send_resp(conn, 400, "Bad request")
+ end
+ end
+
+ def layer(conn, %{"resource_id" => resource_id}) do
+ with {:ok, pid} <- PeerSupervisor.fetch_pid(resource_id),
+ :ok <- Forwarder.set_layer(pid, conn.body_params["encodingId"]) do
+ resp(conn, 200, "")
+ else
+ _other ->
+ resp(conn, 400, "Bad reqeust")
+ end
+ |> send_resp()
+ end
+
+ def remove_pc(conn, %{"resource_id" => resource_id}) do
+ case PeerSupervisor.fetch_pid(resource_id) do
+ {:ok, pid} ->
+ PeerSupervisor.terminate_pc(pid)
+ resp(conn, 200, "")
+
+ _other ->
+ resp(conn, 400, "Bad request")
+ end
+ |> send_resp()
+ end
+
+ defp authenticate(conn) do
+ valid_token = Application.fetch_env!(:k8s_broadcaster, :whip_token)
+
+ with ["Bearer " <> token] <- get_req_header(conn, "authorization"),
+ true <- token == valid_token do
+ :ok
+ else
+ _other -> {:error, :unauthorized}
+ end
+ end
+
+ defp update_layers(conn) do
+ case Forwarder.get_layers() do
+ {:ok, layers} ->
+ data = Jason.encode!(%{layers: layers})
+ chunk(conn, ~s/data: #{data}\n\n/)
+
+ Process.send_after(self(), :layers, 2000)
+
+ receive do
+ :layers -> update_layers(conn)
+ end
+
+ :error ->
+ conn
+ end
+ end
+end
diff --git a/k8s_broadcaster/lib/k8s_broadcaster_web/controllers/page_controller.ex b/k8s_broadcaster/lib/k8s_broadcaster_web/controllers/page_controller.ex
new file mode 100644
index 0000000..a67465d
--- /dev/null
+++ b/k8s_broadcaster/lib/k8s_broadcaster_web/controllers/page_controller.ex
@@ -0,0 +1,11 @@
+defmodule K8sBroadcasterWeb.PageController do
+ use K8sBroadcasterWeb, :controller
+
+ def home(conn, _params) do
+ render(conn, :home, page_title: "Home", current_url: current_url(conn))
+ end
+
+ def panel(conn, _params) do
+ render(conn, :panel, page_title: "Panel")
+ end
+end
diff --git a/k8s_broadcaster/lib/k8s_broadcaster_web/controllers/page_html.ex b/k8s_broadcaster/lib/k8s_broadcaster_web/controllers/page_html.ex
new file mode 100644
index 0000000..fe00faa
--- /dev/null
+++ b/k8s_broadcaster/lib/k8s_broadcaster_web/controllers/page_html.ex
@@ -0,0 +1,10 @@
+defmodule K8sBroadcasterWeb.PageHTML do
+ @moduledoc """
+ This module contains pages rendered by PageController.
+
+ See the `page_html` directory for all templates available.
+ """
+ use K8sBroadcasterWeb, :html
+
+ embed_templates "page_html/*"
+end
diff --git a/k8s_broadcaster/lib/k8s_broadcaster_web/controllers/page_html/home.html.heex b/k8s_broadcaster/lib/k8s_broadcaster_web/controllers/page_html/home.html.heex
new file mode 100644
index 0000000..fdd997b
--- /dev/null
+++ b/k8s_broadcaster/lib/k8s_broadcaster_web/controllers/page_html/home.html.heex
@@ -0,0 +1,133 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ time
+ 0
+
+
+ audioBitrate (kbps)
+ 0
+
+
+ videoBitrate (kbps)
+ 0
+
+
+ frameWidth
+ 0
+
+
+ frameHeight
+ 0
+
+
+ framesPerSecond
+ 0
+
+
+ keyframesDecoded
+ 0
+
+
+ pliCount
+ 0
+
+
+ packetLoss (%)
+ 0
+
+
+ avgJitterBufferDelay (ms) VALIDATE
+ 0
+
+
+ freezeCount
+ 0
+
+
+ freezeDuration (s)
+ 0
+
+
+
+
+
diff --git a/k8s_broadcaster/lib/k8s_broadcaster_web/controllers/page_html/panel.html.heex b/k8s_broadcaster/lib/k8s_broadcaster_web/controllers/page_html/panel.html.heex
new file mode 100644
index 0000000..6daa58f
--- /dev/null
+++ b/k8s_broadcaster/lib/k8s_broadcaster_web/controllers/page_html/panel.html.heex
@@ -0,0 +1,139 @@
+
diff --git a/k8s_broadcaster/lib/k8s_broadcaster_web/endpoint.ex b/k8s_broadcaster/lib/k8s_broadcaster_web/endpoint.ex
new file mode 100644
index 0000000..6d3cfce
--- /dev/null
+++ b/k8s_broadcaster/lib/k8s_broadcaster_web/endpoint.ex
@@ -0,0 +1,62 @@
+defmodule K8sBroadcasterWeb.Endpoint do
+ use Phoenix.Endpoint, otp_app: :k8s_broadcaster
+
+ # The session will be stored in the cookie and signed,
+ # this means its contents can be read but not tampered with.
+ # Set :encryption_salt if you would also like to encrypt it.
+ @session_options [
+ store: :cookie,
+ key: "_k8s_broadcaster_key",
+ signing_salt: "Vui9HRTO",
+ same_site: "Lax"
+ ]
+
+ socket "/socket", K8sBroadcasterWeb.UserSocket,
+ websocket: true,
+ longpoll: false
+
+ socket "/live", Phoenix.LiveView.Socket,
+ websocket: [connect_info: [session: @session_options]],
+ longpoll: [connect_info: [session: @session_options]]
+
+ plug Corsica,
+ origins: "*",
+ allow_headers: :all,
+ allow_methods: :all,
+ expose_headers: K8sBroadcasterWeb.Router.cors_expose_headers()
+
+ # Serve at "/" the static files from "priv/static" directory.
+ #
+ # You should set gzip to true if you are running phx.digest
+ # when deploying your static files in production.
+ plug Plug.Static,
+ at: "/",
+ from: :k8s_broadcaster,
+ gzip: false,
+ only: K8sBroadcasterWeb.static_paths()
+
+ # Code reloading can be explicitly enabled under the
+ # :code_reloader configuration of your endpoint.
+ if code_reloading? do
+ socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
+ plug Phoenix.LiveReloader
+ plug Phoenix.CodeReloader
+ end
+
+ plug Phoenix.LiveDashboard.RequestLogger,
+ param_key: "request_logger",
+ cookie_key: "request_logger"
+
+ plug Plug.RequestId
+ plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
+
+ plug Plug.Parsers,
+ parsers: [:urlencoded, :multipart, :json],
+ pass: ["*/*"],
+ json_decoder: Phoenix.json_library()
+
+ plug Plug.MethodOverride
+ plug Plug.Head
+ plug Plug.Session, @session_options
+ plug K8sBroadcasterWeb.Router
+end
diff --git a/k8s_broadcaster/lib/k8s_broadcaster_web/router.ex b/k8s_broadcaster/lib/k8s_broadcaster_web/router.ex
new file mode 100644
index 0000000..f2c7597
--- /dev/null
+++ b/k8s_broadcaster/lib/k8s_broadcaster_web/router.ex
@@ -0,0 +1,57 @@
+defmodule K8sBroadcasterWeb.Router do
+ use K8sBroadcasterWeb, :router
+
+ import Phoenix.LiveDashboard.Router
+
+ pipeline :browser do
+ plug :accepts, ["html"]
+ plug :fetch_session
+ plug :fetch_live_flash
+ plug :put_root_layout, html: {K8sBroadcasterWeb.Layouts, :root}
+ plug :protect_from_forgery
+ plug :put_secure_browser_headers
+ end
+
+ pipeline :auth do
+ plug :admin_auth
+ end
+
+ scope "/", K8sBroadcasterWeb do
+ pipe_through :browser
+
+ get "/", PageController, :home
+ end
+
+ scope "/api", K8sBroadcasterWeb do
+ get "/pc-config", MediaController, :pc_config
+ post "/whip", MediaController, :whip
+ post "/whep", MediaController, :whep
+
+ scope "/resource/:resource_id" do
+ patch "/", MediaController, :ice_candidate
+ delete "/", MediaController, :remove_pc
+ get "/sse/event-stream", MediaController, :event_stream
+ post "/sse", MediaController, :sse
+ post "/layer", MediaController, :layer
+ end
+ end
+
+ scope "/admin", K8sBroadcasterWeb do
+ pipe_through :auth
+ pipe_through :browser
+
+ get "/panel", PageController, :panel
+
+ live_dashboard "/dashboard",
+ metrics: K8sBroadcasterWeb.Telemetry,
+ additional_pages: [exwebrtc: ExWebRTCDashboard]
+ end
+
+ def cors_expose_headers, do: K8sBroadcasterWeb.MediaController.cors_expose_headers()
+
+ defp admin_auth(conn, _opts) do
+ username = Application.fetch_env!(:k8s_broadcaster, :admin_username)
+ password = Application.fetch_env!(:k8s_broadcaster, :admin_password)
+ Plug.BasicAuth.basic_auth(conn, username: username, password: password)
+ end
+end
diff --git a/k8s_broadcaster/lib/k8s_broadcaster_web/telemetry.ex b/k8s_broadcaster/lib/k8s_broadcaster_web/telemetry.ex
new file mode 100644
index 0000000..f805129
--- /dev/null
+++ b/k8s_broadcaster/lib/k8s_broadcaster_web/telemetry.ex
@@ -0,0 +1,69 @@
+defmodule K8sBroadcasterWeb.Telemetry do
+ use Supervisor
+ import Telemetry.Metrics
+
+ def start_link(arg) do
+ Supervisor.start_link(__MODULE__, arg, name: __MODULE__)
+ end
+
+ @impl true
+ def init(_arg) do
+ children = [
+ # Telemetry poller will execute the given period measurements
+ # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics
+ {:telemetry_poller, measurements: periodic_measurements(), period: 10_000}
+ # Add reporters as children of your supervision tree.
+ # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()}
+ ]
+
+ Supervisor.init(children, strategy: :one_for_one)
+ end
+
+ def metrics do
+ [
+ # Phoenix Metrics
+ summary("phoenix.endpoint.start.system_time",
+ unit: {:native, :millisecond}
+ ),
+ summary("phoenix.endpoint.stop.duration",
+ unit: {:native, :millisecond}
+ ),
+ summary("phoenix.router_dispatch.start.system_time",
+ tags: [:route],
+ unit: {:native, :millisecond}
+ ),
+ summary("phoenix.router_dispatch.exception.duration",
+ tags: [:route],
+ unit: {:native, :millisecond}
+ ),
+ summary("phoenix.router_dispatch.stop.duration",
+ tags: [:route],
+ unit: {:native, :millisecond}
+ ),
+ summary("phoenix.socket_connected.duration",
+ unit: {:native, :millisecond}
+ ),
+ summary("phoenix.channel_joined.duration",
+ unit: {:native, :millisecond}
+ ),
+ summary("phoenix.channel_handled_in.duration",
+ tags: [:event],
+ unit: {:native, :millisecond}
+ ),
+
+ # VM Metrics
+ summary("vm.memory.total", unit: {:byte, :kilobyte}),
+ summary("vm.total_run_queue_lengths.total"),
+ summary("vm.total_run_queue_lengths.cpu"),
+ summary("vm.total_run_queue_lengths.io")
+ ]
+ end
+
+ defp periodic_measurements do
+ [
+ # A module, function and arguments to be invoked periodically.
+ # This function must call :telemetry.execute/3 and a metric must be added above.
+ # {K8sBroadcasterWeb, :count_users, []}
+ ]
+ end
+end
diff --git a/k8s_broadcaster/mix.exs b/k8s_broadcaster/mix.exs
new file mode 100644
index 0000000..d946c0b
--- /dev/null
+++ b/k8s_broadcaster/mix.exs
@@ -0,0 +1,84 @@
+defmodule K8sBroadcaster.MixProject do
+ use Mix.Project
+
+ def project do
+ [
+ app: :k8s_broadcaster,
+ version: "0.1.0",
+ elixir: "~> 1.14",
+ elixirc_paths: elixirc_paths(Mix.env()),
+ start_permanent: Mix.env() == :prod,
+ aliases: aliases(),
+ deps: deps()
+ ]
+ end
+
+ # Configuration for the OTP application.
+ #
+ # Type `mix help compile.app` for more information.
+ def application do
+ [
+ mod: {K8sBroadcaster.Application, []},
+ extra_applications: [:logger, :runtime_tools]
+ ]
+ end
+
+ # Specifies which paths to compile per environment.
+ defp elixirc_paths(:test), do: ["lib", "test/support"]
+ defp elixirc_paths(_), do: ["lib"]
+
+ # Specifies your project dependencies.
+ #
+ # Type `mix help deps` for examples and options.
+ defp deps do
+ [
+ {:phoenix, "~> 1.7.18"},
+ {:phoenix_html, "~> 4.1"},
+ {:phoenix_live_reload, "~> 1.2", only: :dev},
+ {:phoenix_live_view, "~> 1.0.0"},
+ {:floki, ">= 0.30.0", only: :test},
+ {:phoenix_live_dashboard, "~> 0.8.3"},
+ {:esbuild, "~> 0.8", runtime: Mix.env() == :dev},
+ {:tailwind, "~> 0.2", runtime: Mix.env() == :dev},
+ {:heroicons,
+ github: "tailwindlabs/heroicons",
+ tag: "v2.1.1",
+ sparse: "optimized",
+ app: false,
+ compile: false,
+ depth: 1},
+ {:telemetry_metrics, "~> 1.0"},
+ {:telemetry_poller, "~> 1.0"},
+ {:jason, "~> 1.2"},
+ {:dns_cluster, "~> 0.1.1"},
+ {:bandit, "~> 1.5"},
+
+ # custom deps
+ # {:ex_webrtc, "~> 0.7.0"},
+ {:ex_webrtc, github: "elixir-webrtc/ex_webrtc", branch: "packet-loss", override: true},
+ {:ex_webrtc_dashboard, "~> 0.7.0"},
+ {:syn, "~> 3.3"},
+ {:corsica, "~> 2.1.3"},
+ {:libcluster, "~> 3.4"}
+ ]
+ end
+
+ # Aliases are shortcuts or tasks specific to the current project.
+ # For example, to install project dependencies and perform other setup tasks, run:
+ #
+ # $ mix setup
+ #
+ # See the documentation for `Mix` for more info on aliases.
+ defp aliases do
+ [
+ setup: ["deps.get", "assets.setup", "assets.build"],
+ "assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"],
+ "assets.build": ["tailwind k8s_broadcaster", "esbuild k8s_broadcaster"],
+ "assets.deploy": [
+ "tailwind k8s_broadcaster --minify",
+ "esbuild k8s_broadcaster --minify",
+ "phx.digest"
+ ]
+ ]
+ end
+end
diff --git a/k8s_broadcaster/mix.lock b/k8s_broadcaster/mix.lock
new file mode 100644
index 0000000..fd01adf
--- /dev/null
+++ b/k8s_broadcaster/mix.lock
@@ -0,0 +1,57 @@
+%{
+ "bandit": {:hex, :bandit, "1.6.1", "9e01b93d72ddc21d8c576a704949e86ee6cde7d11270a1d3073787876527a48f", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5a904bf010ea24b67979835e0507688e31ac873d4ffc8ed0e5413e8d77455031"},
+ "bunch": {:hex, :bunch, "1.6.1", "5393d827a64d5f846092703441ea50e65bc09f37fd8e320878f13e63d410aec7", [:mix], [], "hexpm", "286cc3add551628b30605efbe2fca4e38cc1bea89bcd0a1a7226920b3364fe4a"},
+ "bunch_native": {:hex, :bunch_native, "0.5.0", "8ac1536789a597599c10b652e0b526d8833348c19e4739a0759a2bedfd924e63", [:mix], [{:bundlex, "~> 1.0", [hex: :bundlex, repo: "hexpm", optional: false]}], "hexpm", "24190c760e32b23b36edeb2dc4852515c7c5b3b8675b1a864e0715bdd1c8f80d"},
+ "bundlex": {:hex, :bundlex, "1.5.4", "3726acd463f4d31894a59bbc177c17f3b574634a524212f13469f41c4834a1d9", [:mix], [{:bunch, "~> 1.0", [hex: :bunch, repo: "hexpm", optional: false]}, {:elixir_uuid, "~> 1.2", [hex: :elixir_uuid, repo: "hexpm", optional: false]}, {:qex, "~> 0.5", [hex: :qex, repo: "hexpm", optional: false]}, {:req, ">= 0.4.0", [hex: :req, repo: "hexpm", optional: false]}, {:zarex, "~> 1.0", [hex: :zarex, repo: "hexpm", optional: false]}], "hexpm", "e745726606a560275182a8ac1c8ebd5e11a659bb7460d8abf30f397e59b4c5d2"},
+ "castore": {:hex, :castore, "1.0.10", "43bbeeac820f16c89f79721af1b3e092399b3a1ecc8df1a472738fd853574911", [:mix], [], "hexpm", "1b0b7ea14d889d9ea21202c43a4fa015eb913021cb535e8ed91946f4b77a8848"},
+ "corsica": {:hex, :corsica, "2.1.3", "dccd094ffce38178acead9ae743180cdaffa388f35f0461ba1e8151d32e190e6", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "616c08f61a345780c2cf662ff226816f04d8868e12054e68963e95285b5be8bc"},
+ "crc": {:hex, :crc, "0.10.5", "ee12a7c056ac498ef2ea985ecdc9fa53c1bfb4e53a484d9f17ff94803707dfd8", [:mix, :rebar3], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3e673b6495a9525c5c641585af1accba59a1eb33de697bedf341e247012c2c7f"},
+ "dns_cluster": {:hex, :dns_cluster, "0.1.3", "0bc20a2c88ed6cc494f2964075c359f8c2d00e1bf25518a6a6c7fd277c9b0c66", [:mix], [], "hexpm", "46cb7c4a1b3e52c7ad4cbe33ca5079fbde4840dedeafca2baf77996c2da1bc33"},
+ "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
+ "elixir_uuid": {:hex, :elixir_uuid, "1.2.1", "dce506597acb7e6b0daeaff52ff6a9043f5919a4c3315abb4143f0b00378c097", [:mix], [], "hexpm", "f7eba2ea6c3555cea09706492716b0d87397b88946e6380898c2889d68585752"},
+ "esbuild": {:hex, :esbuild, "0.8.2", "5f379dfa383ef482b738e7771daf238b2d1cfb0222bef9d3b20d4c8f06c7a7ac", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "558a8a08ed78eb820efbfda1de196569d8bfa9b51e8371a1934fbb31345feda7"},
+ "ex_dtls": {:hex, :ex_dtls, "0.16.0", "3ae38025ccc77f6db573e2e391602fa9bbc02253c137d8d2d59469a66cbe806b", [:mix], [{:bundlex, "~> 1.5.3", [hex: :bundlex, repo: "hexpm", optional: false]}, {:unifex, "~> 1.0", [hex: :unifex, repo: "hexpm", optional: false]}], "hexpm", "2a4e30d74c6ddf95cc5b796423293c06a0da295454c3823819808ff031b4b361"},
+ "ex_ice": {:hex, :ex_ice, "0.9.2", "7f5513416a8fe33b36d988dd30d6bb79ddd7cfa408e09e2e3d3e3a97e075614d", [:mix], [{:elixir_uuid, "~> 1.0", [hex: :elixir_uuid, repo: "hexpm", optional: false]}, {:ex_stun, "~> 0.2.0", [hex: :ex_stun, repo: "hexpm", optional: false]}, {:ex_turn, "~> 0.2.0", [hex: :ex_turn, repo: "hexpm", optional: false]}], "hexpm", "70f688582dbe36a82cf8bbedf5adb2f0b89996620e229213bd7ff9a9b642e571"},
+ "ex_libsrtp": {:hex, :ex_libsrtp, "0.7.2", "211bd89c08026943ce71f3e2c0231795b99cee748808ed3ae7b97cd8d2450b6b", [:mix], [{:bunch, "~> 1.6", [hex: :bunch, repo: "hexpm", optional: false]}, {:bundlex, "~> 1.3", [hex: :bundlex, repo: "hexpm", optional: false]}, {:membrane_precompiled_dependency_provider, "~> 0.1.0", [hex: :membrane_precompiled_dependency_provider, repo: "hexpm", optional: false]}, {:unifex, "~> 1.1", [hex: :unifex, repo: "hexpm", optional: false]}], "hexpm", "2e20645d0d739a4ecdcf8d4810a0c198120c8a2f617f2b75b2e2e704d59f492a"},
+ "ex_rtcp": {:hex, :ex_rtcp, "0.4.0", "f9e515462a9581798ff6413583a25174cfd2101c94a2ebee871cca7639886f0a", [:mix], [], "hexpm", "28956602cf210d692fcdaf3f60ca49681634e1deb28ace41246aee61ee22dc3b"},
+ "ex_rtp": {:hex, :ex_rtp, "0.4.0", "1f1b5c1440a904706011e3afbb41741f5da309ce251cb986690ce9fd82636658", [:mix], [], "hexpm", "0f72d80d5953a62057270040f0f1ee6f955c08eeae82ac659c038001d7d5a790"},
+ "ex_sdp": {:hex, :ex_sdp, "1.1.0", "a93d72d00704efd83f7e144e4ca9822ca4aea5b5d851353d092de40e1ad0ecdc", [:mix], [{:bunch, "~> 1.3", [hex: :bunch, repo: "hexpm", optional: false]}, {:elixir_uuid, "~> 1.2", [hex: :elixir_uuid, repo: "hexpm", optional: false]}], "hexpm", "f5c033abcda958a9b090210f9429f24b74b003c28c24175c58a033a5205a1cfe"},
+ "ex_stun": {:hex, :ex_stun, "0.2.0", "feb1fc7db0356406655b2a617805e6c712b93308c8ea2bf0ba1197b1f0866deb", [:mix], [], "hexpm", "1e01ba8290082ccbf37acaa5190d1f69b51edd6de2026a8d6d51368b29d115d0"},
+ "ex_turn": {:hex, :ex_turn, "0.2.0", "4e1f9b089e9a5ee44928d12370cc9ea7a89b84b2f6256832de65271212eb80de", [:mix], [{:ex_stun, "~> 0.2.0", [hex: :ex_stun, repo: "hexpm", optional: false]}], "hexpm", "08e884f0af2c4a147e3f8cd4ffe33e3452a256389f0956e55a8c4d75bf0e74cd"},
+ "ex_webrtc": {:git, "https://github.com/elixir-webrtc/ex_webrtc.git", "87e9304a62fd0c69ff93fe4c0a33501c36197942", [branch: "packet-loss"]},
+ "ex_webrtc_dashboard": {:hex, :ex_webrtc_dashboard, "0.7.0", "6b46a8271f0886345a7d7552e9642672009487b90efe45154e5f6a34b067233e", [:mix], [{:ex_webrtc, "~> 0.7.0", [hex: :ex_webrtc, repo: "hexpm", optional: false]}, {:phoenix_live_dashboard, "~> 0.8.3", [hex: :phoenix_live_dashboard, repo: "hexpm", optional: false]}], "hexpm", "9ffc0d0d7537db3f333fb94ace10415d7580b3e9667938046f1df02214fa57a2"},
+ "file_system": {:hex, :file_system, "1.0.1", "79e8ceaddb0416f8b8cd02a0127bdbababe7bf4a23d2a395b983c1f8b3f73edd", [:mix], [], "hexpm", "4414d1f38863ddf9120720cd976fce5bdde8e91d8283353f0e31850fa89feb9e"},
+ "finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"},
+ "floki": {:hex, :floki, "0.37.0", "b83e0280bbc6372f2a403b2848013650b16640cd2470aea6701f0632223d719e", [:mix], [], "hexpm", "516a0c15a69f78c47dc8e0b9b3724b29608aa6619379f91b1ffa47109b5d0dd3"},
+ "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "88ab3a0d790e6a47404cba02800a6b25d2afae50", [tag: "v2.1.1", sparse: "optimized"]},
+ "hpax": {:hex, :hpax, "1.0.1", "c857057f89e8bd71d97d9042e009df2a42705d6d690d54eca84c8b29af0787b0", [:mix], [], "hexpm", "4e2d5a4f76ae1e3048f35ae7adb1641c36265510a2d4638157fbcb53dda38445"},
+ "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
+ "libcluster": {:hex, :libcluster, "3.4.1", "271d2da892763bbef53c2872036c936fe8b80111eb1feefb2d30a3bb15c9b4f6", [:mix], [{:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1d568157f069c6afa70ec0d736704cf799734bdbb6343f0322af4a980301c853"},
+ "membrane_precompiled_dependency_provider": {:hex, :membrane_precompiled_dependency_provider, "0.1.2", "8af73b7dc15ba55c9f5fbfc0453d4a8edfb007ade54b56c37d626be0d1189aba", [:mix], [{:bundlex, "~> 1.4", [hex: :bundlex, repo: "hexpm", optional: false]}], "hexpm", "7fe3e07361510445a29bee95336adde667c4162b76b7f4c8af3aeb3415292023"},
+ "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"},
+ "mint": {:hex, :mint, "1.6.2", "af6d97a4051eee4f05b5500671d47c3a67dac7386045d87a904126fd4bbcea2e", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "5ee441dffc1892f1ae59127f74afe8fd82fda6587794278d924e4d90ea3d63f9"},
+ "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
+ "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
+ "phoenix": {:hex, :phoenix, "1.7.18", "5310c21443514be44ed93c422e15870aef254cf1b3619e4f91538e7529d2b2e4", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "1797fcc82108442a66f2c77a643a62980f342bfeb63d6c9a515ab8294870004e"},
+ "phoenix_html": {:hex, :phoenix_html, "4.1.1", "4c064fd3873d12ebb1388425a8f2a19348cef56e7289e1998e2d2fa758aa982e", [:mix], [], "hexpm", "f2f2df5a72bc9a2f510b21497fd7d2b86d932ec0598f0210fed4114adc546c6f"},
+ "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.5", "d5f44d7dbd7cfacaa617b70c5a14b2b598d6f93b9caa8e350c51d56cd4350a9b", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "1d73920515554d7d6c548aee0bf10a4780568b029d042eccb336db29ea0dad70"},
+ "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.5.3", "f2161c207fda0e4fb55165f650f7f8db23f02b29e3bff00ff7ef161d6ac1f09d", [:mix], [{:file_system, "~> 0.3 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "b4ec9cd73cb01ff1bd1cac92e045d13e7030330b74164297d1aee3907b54803c"},
+ "phoenix_live_view": {:hex, :phoenix_live_view, "1.0.0", "3a10dfce8f87b2ad4dc65de0732fc2a11e670b2779a19e8d3281f4619a85bce4", [:mix], [{:floki, "~> 0.36", [hex: :floki, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "254caef0028765965ca6bd104cc7d68dcc7d57cc42912bef92f6b03047251d99"},
+ "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"},
+ "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
+ "plug": {:hex, :plug, "1.16.1", "40c74619c12f82736d2214557dedec2e9762029b2438d6d175c5074c933edc9d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a13ff6b9006b03d7e33874945b2755253841b238c34071ed85b0e86057f8cddc"},
+ "plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"},
+ "qex": {:hex, :qex, "0.5.1", "0d82c0f008551d24fffb99d97f8299afcb8ea9cf99582b770bd004ed5af63fd6", [:mix], [], "hexpm", "935a39fdaf2445834b95951456559e9dc2063d0a055742c558a99987b38d6bab"},
+ "req": {:hex, :req, "0.5.8", "50d8d65279d6e343a5e46980ac2a70e97136182950833a1968b371e753f6a662", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "d7fc5898a566477e174f26887821a3c5082b243885520ee4b45555f5d53f40ef"},
+ "shmex": {:hex, :shmex, "0.5.1", "81dd209093416bf6608e66882cb7e676089307448a1afd4fc906c1f7e5b94cf4", [:mix], [{:bunch_native, "~> 0.5.0", [hex: :bunch_native, repo: "hexpm", optional: false]}, {:bundlex, "~> 1.0", [hex: :bundlex, repo: "hexpm", optional: false]}], "hexpm", "c29f8286891252f64c4e1dac40b217d960f7d58def597c4e606ff8fbe71ceb80"},
+ "syn": {:hex, :syn, "3.3.0", "4684a909efdfea35ce75a9662fc523e4a8a4e8169a3df275e4de4fa63f99c486", [:rebar3], [], "hexpm", "e58ee447bc1094bdd21bf0acc102b1fbf99541a508cd48060bf783c245eaf7d6"},
+ "tailwind": {:hex, :tailwind, "0.2.4", "5706ec47182d4e7045901302bf3a333e80f3d1af65c442ba9a9eed152fb26c2e", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "c6e4a82b8727bab593700c998a4d98cf3d8025678bfde059aed71d0000c3e463"},
+ "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
+ "telemetry_metrics": {:hex, :telemetry_metrics, "1.0.0", "29f5f84991ca98b8eb02fc208b2e6de7c95f8bb2294ef244a176675adc7775df", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f23713b3847286a534e005126d4c959ebcca68ae9582118ce436b521d1d47d5d"},
+ "telemetry_poller": {:hex, :telemetry_poller, "1.1.0", "58fa7c216257291caaf8d05678c8d01bd45f4bdbc1286838a28c4bb62ef32999", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9eb9d9cbfd81cbd7cdd24682f8711b6e2b691289a0de6826e58452f28c103c8f"},
+ "thousand_island": {:hex, :thousand_island, "1.3.7", "1da7598c0f4f5f50562c097a3f8af308ded48cd35139f0e6f17d9443e4d0c9c5", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "0139335079953de41d381a6134d8b618d53d084f558c734f2662d1a72818dd12"},
+ "unifex": {:hex, :unifex, "1.2.1", "6841c170a6e16509fac30b19e4e0a19937c33155a59088b50c15fc2c36251b6b", [:mix], [{:bunch, "~> 1.0", [hex: :bunch, repo: "hexpm", optional: false]}, {:bundlex, "~> 1.4", [hex: :bundlex, repo: "hexpm", optional: false]}, {:shmex, "~> 0.5.0", [hex: :shmex, repo: "hexpm", optional: false]}], "hexpm", "8c9d2e3c48df031e9995dd16865bab3df402c0295ba3a31f38274bb5314c7d37"},
+ "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
+ "websock_adapter": {:hex, :websock_adapter, "0.5.8", "3b97dc94e407e2d1fc666b2fb9acf6be81a1798a2602294aac000260a7c4a47d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "315b9a1865552212b5f35140ad194e67ce31af45bcee443d4ecb96b5fd3f3782"},
+ "zarex": {:hex, :zarex, "1.0.5", "58239e3ee5d75f343262bb4df5cf466555a1c689f920e5d3651a9333972f7c7e", [:mix], [], "hexpm", "9fb72ef0567c2b2742f5119a1ba8a24a2fabb21b8d09820aefbf3e592fa9a46a"},
+}
diff --git a/k8s_broadcaster/priv/static/favicon.ico b/k8s_broadcaster/priv/static/favicon.ico
new file mode 100644
index 0000000..7f372bf
Binary files /dev/null and b/k8s_broadcaster/priv/static/favicon.ico differ
diff --git a/k8s_broadcaster/priv/static/images/cluster.svg b/k8s_broadcaster/priv/static/images/cluster.svg
new file mode 100644
index 0000000..d0c3502
--- /dev/null
+++ b/k8s_broadcaster/priv/static/images/cluster.svg
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/k8s_broadcaster/priv/static/images/logo.svg b/k8s_broadcaster/priv/static/images/logo.svg
new file mode 100644
index 0000000..348a77c
--- /dev/null
+++ b/k8s_broadcaster/priv/static/images/logo.svg
@@ -0,0 +1,122 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/k8s_broadcaster/priv/static/robots.txt b/k8s_broadcaster/priv/static/robots.txt
new file mode 100644
index 0000000..26e06b5
--- /dev/null
+++ b/k8s_broadcaster/priv/static/robots.txt
@@ -0,0 +1,5 @@
+# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
+#
+# To ban all spiders from the entire site uncomment the next two lines:
+# User-agent: *
+# Disallow: /
diff --git a/k8s_broadcaster/rel/overlays/bin/server b/k8s_broadcaster/rel/overlays/bin/server
new file mode 100755
index 0000000..e4b517b
--- /dev/null
+++ b/k8s_broadcaster/rel/overlays/bin/server
@@ -0,0 +1,11 @@
+#!/bin/sh
+set -eu
+
+cd -P -- "$(dirname -- "$0")"
+
+if [ "${DISTRIBUTION_MODE-}" = "k8s" ]; then
+ export RELEASE_DISTRIBUTION=name
+ export RELEASE_NODE=k8s_broadcaster@${POD_IP}
+fi
+
+PHX_SERVER=true exec ./k8s_broadcaster start
diff --git a/k8s_broadcaster/rel/overlays/bin/server.bat b/k8s_broadcaster/rel/overlays/bin/server.bat
new file mode 100755
index 0000000..ddc0c0e
--- /dev/null
+++ b/k8s_broadcaster/rel/overlays/bin/server.bat
@@ -0,0 +1,7 @@
+if "%DISTRIBUTION_MODE%"=="k8s" (
+ set RELEASE_DISTRIBUTION=name
+ set RELEASE_NODE="k8s_broadcaster@%POD_IP%"
+)
+
+set PHX_SERVER=true
+call "%~dp0\k8s_broadcaster" start
diff --git a/k8s_broadcaster/test/k8s_broadcaster_web/controllers/error_html_test.exs b/k8s_broadcaster/test/k8s_broadcaster_web/controllers/error_html_test.exs
new file mode 100644
index 0000000..7c4acab
--- /dev/null
+++ b/k8s_broadcaster/test/k8s_broadcaster_web/controllers/error_html_test.exs
@@ -0,0 +1,14 @@
+defmodule K8sBroadcasterWeb.ErrorHTMLTest do
+ use K8sBroadcasterWeb.ConnCase, async: true
+
+ # Bring render_to_string/4 for testing custom views
+ import Phoenix.Template
+
+ test "renders 404.html" do
+ assert render_to_string(K8sBroadcasterWeb.ErrorHTML, "404", "html", []) == "Not Found"
+ end
+
+ test "renders 500.html" do
+ assert render_to_string(K8sBroadcasterWeb.ErrorHTML, "500", "html", []) == "Internal Server Error"
+ end
+end
diff --git a/k8s_broadcaster/test/k8s_broadcaster_web/controllers/error_json_test.exs b/k8s_broadcaster/test/k8s_broadcaster_web/controllers/error_json_test.exs
new file mode 100644
index 0000000..6307f0f
--- /dev/null
+++ b/k8s_broadcaster/test/k8s_broadcaster_web/controllers/error_json_test.exs
@@ -0,0 +1,12 @@
+defmodule K8sBroadcasterWeb.ErrorJSONTest do
+ use K8sBroadcasterWeb.ConnCase, async: true
+
+ test "renders 404" do
+ assert K8sBroadcasterWeb.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}}
+ end
+
+ test "renders 500" do
+ assert K8sBroadcasterWeb.ErrorJSON.render("500.json", %{}) ==
+ %{errors: %{detail: "Internal Server Error"}}
+ end
+end
diff --git a/k8s_broadcaster/test/k8s_broadcaster_web/controllers/page_controller_test.exs b/k8s_broadcaster/test/k8s_broadcaster_web/controllers/page_controller_test.exs
new file mode 100644
index 0000000..cfe1a96
--- /dev/null
+++ b/k8s_broadcaster/test/k8s_broadcaster_web/controllers/page_controller_test.exs
@@ -0,0 +1,8 @@
+defmodule K8sBroadcasterWeb.PageControllerTest do
+ use K8sBroadcasterWeb.ConnCase
+
+ test "GET /", %{conn: conn} do
+ conn = get(conn, ~p"/")
+ assert html_response(conn, 200) =~ "Peace of mind from prototype to production"
+ end
+end
diff --git a/k8s_broadcaster/test/support/conn_case.ex b/k8s_broadcaster/test/support/conn_case.ex
new file mode 100644
index 0000000..1f7bea4
--- /dev/null
+++ b/k8s_broadcaster/test/support/conn_case.ex
@@ -0,0 +1,37 @@
+defmodule K8sBroadcasterWeb.ConnCase do
+ @moduledoc """
+ This module defines the test case to be used by
+ tests that require setting up a connection.
+
+ Such tests rely on `Phoenix.ConnTest` and also
+ import other functionality to make it easier
+ to build common data structures and query the data layer.
+
+ Finally, if the test case interacts with the database,
+ we enable the SQL sandbox, so changes done to the database
+ are reverted at the end of every test. If you are using
+ PostgreSQL, you can even run database tests asynchronously
+ by setting `use K8sBroadcasterWeb.ConnCase, async: true`, although
+ this option is not recommended for other databases.
+ """
+
+ use ExUnit.CaseTemplate
+
+ using do
+ quote do
+ # The default endpoint for testing
+ @endpoint K8sBroadcasterWeb.Endpoint
+
+ use K8sBroadcasterWeb, :verified_routes
+
+ # Import conveniences for testing with connections
+ import Plug.Conn
+ import Phoenix.ConnTest
+ import K8sBroadcasterWeb.ConnCase
+ end
+ end
+
+ setup _tags do
+ {:ok, conn: Phoenix.ConnTest.build_conn()}
+ end
+end
diff --git a/k8s_broadcaster/test/test_helper.exs b/k8s_broadcaster/test/test_helper.exs
new file mode 100644
index 0000000..869559e
--- /dev/null
+++ b/k8s_broadcaster/test/test_helper.exs
@@ -0,0 +1 @@
+ExUnit.start()