32 |
33 |
--------------------------------------------------------------------------------
/examples/phoenix_signaling/test/support/conn_case.ex:
--------------------------------------------------------------------------------
1 | defmodule PhoenixSignalingWeb.ConnCase do
2 | @moduledoc """
3 | This module defines the test case to be used by
4 | tests that require setting up a connection.
5 |
6 | Such tests rely on `Phoenix.ConnTest` and also
7 | import other functionality to make it easier
8 | to build common data structures and query the data layer.
9 |
10 | Finally, if the test case interacts with the database,
11 | we enable the SQL sandbox, so changes done to the database
12 | are reverted at the end of every test. If you are using
13 | PostgreSQL, you can even run database tests asynchronously
14 | by setting `use PhoenixSignalingWeb.ConnCase, async: true`, although
15 | this option is not recommended for other databases.
16 | """
17 |
18 | use ExUnit.CaseTemplate
19 |
20 | using do
21 | quote do
22 | # The default endpoint for testing
23 | @endpoint PhoenixSignalingWeb.Endpoint
24 |
25 | use PhoenixSignalingWeb, :verified_routes
26 |
27 | # Import conveniences for testing with connections
28 | import Plug.Conn
29 | import Phoenix.ConnTest
30 | import PhoenixSignalingWeb.ConnCase
31 | end
32 | end
33 |
34 | setup _tags do
35 | {:ok, conn: Phoenix.ConnTest.build_conn()}
36 | end
37 | end
38 |
--------------------------------------------------------------------------------
/examples/live_view/lib/webrtc_live_view_web/live/home.ex:
--------------------------------------------------------------------------------
1 | defmodule WebrtcLiveViewWeb.Live.EchoLive do
2 | use WebrtcLiveViewWeb, :live_view
3 |
4 | alias Membrane.WebRTC.Live.{Capture, Player}
5 |
6 | def mount(_params, _session, socket) do
7 | socket =
8 | if connected?(socket) do
9 | ingress_signaling = Membrane.WebRTC.Signaling.new()
10 | egress_signaling = Membrane.WebRTC.Signaling.new()
11 |
12 | Membrane.Pipeline.start_link(WebRTCLiveView.Pipeline,
13 | ingress_signaling: ingress_signaling,
14 | egress_signaling: egress_signaling
15 | )
16 |
17 | socket
18 | |> Capture.attach(
19 | id: "mediaCapture",
20 | signaling: ingress_signaling,
21 | video?: true,
22 | audio?: false
23 | )
24 | |> Player.attach(
25 | id: "videoPlayer",
26 | signaling: egress_signaling
27 | )
28 | else
29 | socket
30 | end
31 |
32 | {:ok, socket}
33 | end
34 |
35 | def render(assigns) do
36 | ~H"""
37 |
Captured stream preview
38 |
39 |
Stream sent by the server
40 |
41 | """
42 | end
43 | end
44 |
--------------------------------------------------------------------------------
/examples/live_view/lib/webrtc_live_view_web/router.ex:
--------------------------------------------------------------------------------
1 | defmodule WebrtcLiveViewWeb.Router do
2 | use WebrtcLiveViewWeb, :router
3 |
4 | pipeline :browser do
5 | plug :accepts, ["html"]
6 | plug :fetch_session
7 | plug :fetch_live_flash
8 | plug :put_root_layout, html: {WebrtcLiveViewWeb.Layouts, :root}
9 | plug :protect_from_forgery
10 | plug :put_secure_browser_headers
11 | end
12 |
13 | pipeline :api do
14 | plug :accepts, ["json"]
15 | end
16 |
17 | scope "/", WebrtcLiveViewWeb do
18 | pipe_through :browser
19 |
20 | live "/", Live.EchoLive, :index
21 | end
22 |
23 | # Other scopes may use custom stacks.
24 | # scope "/api", WebrtcLiveViewWeb do
25 | # pipe_through :api
26 | # end
27 |
28 | # Enable LiveDashboard in development
29 | if Application.compile_env(:webrtc_live_view, :dev_routes) do
30 | # If you want to use the LiveDashboard in production, you should put
31 | # it behind authentication and allow only admins to access it.
32 | # If your application does not have an admins-only section yet,
33 | # you can use Plug.BasicAuth to set up some basic authentication
34 | # as long as you are also using SSL (which you should anyway).
35 | import Phoenix.LiveDashboard.Router
36 |
37 | scope "/dev" do
38 | pipe_through :browser
39 |
40 | live_dashboard "/dashboard", metrics: WebrtcLiveViewWeb.Telemetry
41 | end
42 | end
43 | end
44 |
--------------------------------------------------------------------------------
/assets/player.js:
--------------------------------------------------------------------------------
1 | export function createPlayerHook(iceServers = [{ urls: `stun:stun.l.google.com:19302` }]) {
2 | return {
3 | async mounted() {
4 | this.pc = new RTCPeerConnection({ iceServers: iceServers });
5 | this.el.srcObject = new MediaStream();
6 |
7 | this.pc.ontrack = (event) => {
8 | this.el.srcObject.addTrack(event.track);
9 | };
10 |
11 | this.pc.onicecandidate = (ev) => {
12 | console.log(`[${this.el.id}] Sent ICE candidate:`, ev.candidate);
13 | message = { type: `ice_candidate`, data: ev.candidate };
14 | this.pushEventTo(this.el, `webrtc_signaling`, message);
15 | };
16 |
17 | const eventName = `webrtc_signaling-${this.el.id}`;
18 | this.handleEvent(eventName, async (event) => {
19 | const { type, data } = event;
20 |
21 | switch (type) {
22 | case `sdp_offer`:
23 | console.log(`[${this.el.id}] Received SDP offer:`, data);
24 | await this.pc.setRemoteDescription(data);
25 |
26 | const answer = await this.pc.createAnswer();
27 | await this.pc.setLocalDescription(answer);
28 |
29 | message = { type: `sdp_answer`, data: answer };
30 | this.pushEventTo(this.el, `webrtc_signaling`, message);
31 | console.log(`[${this.el.id}] Sent SDP answer:`, answer);
32 |
33 | break;
34 | case `ice_candidate`:
35 | console.log(`[${this.el.id}] Received ICE candidate:`, data);
36 | await this.pc.addIceCandidate(data);
37 | }
38 | });
39 | },
40 | };
41 | }
42 |
--------------------------------------------------------------------------------
/examples/websocket_signaling/assets/file_to_browser/file_to_browser.js:
--------------------------------------------------------------------------------
1 | const videoPlayer = document.getElementById("videoPlayer");
2 | const pcConfig = { iceServers: [{ urls: "stun:stun.l.google.com:19302" }] };
3 | const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
4 | const ws = new WebSocket(`${proto}//${window.location.hostname}:8829`);
5 | ws.onopen = () => start_connection(ws);
6 | ws.onclose = (event) => console.log("WebSocket connection was terminated:", event);
7 |
8 | const start_connection = async (ws) => {
9 | videoPlayer.srcObject = new MediaStream();
10 |
11 | const pc = new RTCPeerConnection(pcConfig);
12 | pc.ontrack = (event) => videoPlayer.srcObject.addTrack(event.track);
13 | pc.onicecandidate = (event) => {
14 | if (event.candidate === null) return;
15 |
16 | console.log("Sent ICE candidate:", event.candidate);
17 | ws.send(JSON.stringify({ type: "ice_candidate", data: event.candidate }));
18 | };
19 |
20 | ws.onmessage = async (event) => {
21 | const { type, data } = JSON.parse(event.data);
22 |
23 | switch (type) {
24 | case "sdp_offer":
25 | console.log("Received SDP offer:", data);
26 | await pc.setRemoteDescription(data);
27 | const answer = await pc.createAnswer();
28 | await pc.setLocalDescription(answer);
29 | ws.send(JSON.stringify({ type: "sdp_answer", data: answer }));
30 | console.log("Sent SDP answer:", answer);
31 | break;
32 | case "ice_candidate":
33 | console.log("Received ICE candidate:", data);
34 | await pc.addIceCandidate(data);
35 | }
36 | };
37 | };
38 |
--------------------------------------------------------------------------------
/examples/websocket_signaling/assets/browser_to_file/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | Membrane WebRTC WHIP/WHEP Example
9 |
10 |
11 |
13 |
Membrane WebRTC WHIP/WHEP Example
14 |
Connecting...
15 |
33 |
34 |
35 |
--------------------------------------------------------------------------------
/examples/phoenix_signaling/config/config.exs:
--------------------------------------------------------------------------------
1 | # This file is responsible for configuring your application
2 | # and its dependencies with the aid of the Config module.
3 | #
4 | # This configuration file is loaded before any dependency and
5 | # is restricted to this project.
6 |
7 | # General application configuration
8 | import Config
9 |
10 | config :phoenix_signaling,
11 | generators: [timestamp_type: :utc_datetime]
12 |
13 | # Configures the endpoint
14 | config :phoenix_signaling, PhoenixSignalingWeb.Endpoint,
15 | url: [host: "localhost"],
16 | adapter: Bandit.PhoenixAdapter,
17 | render_errors: [
18 | formats: [html: PhoenixSignalingWeb.ErrorHTML, json: PhoenixSignalingWeb.ErrorJSON],
19 | layout: false
20 | ],
21 | pubsub_server: PhoenixSignaling.PubSub,
22 | live_view: [signing_salt: "rWTn+ozf"]
23 |
24 | # Configure esbuild (the version is required)
25 | config :esbuild,
26 | version: "0.17.11",
27 | phoenix_signaling: [
28 | args:
29 | ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*),
30 | cd: Path.expand("../assets", __DIR__),
31 | env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
32 | ]
33 |
34 | # Configures Elixir's Logger
35 | config :logger, :console,
36 | format: "$time $metadata[$level] $message\n",
37 | metadata: [:request_id]
38 |
39 | # Use Jason for JSON parsing in Phoenix
40 | config :phoenix, :json_library, Jason
41 |
42 | # Import environment specific config. This must remain at the bottom
43 | # of this file so it overrides the configuration defined above.
44 | import_config "#{config_env()}.exs"
45 |
--------------------------------------------------------------------------------
/examples/live_view/config/config.exs:
--------------------------------------------------------------------------------
1 | # This file is responsible for configuring your application
2 | # and its dependencies with the aid of the Config module.
3 | #
4 | # This configuration file is loaded before any dependency and
5 | # is restricted to this project.
6 |
7 | # General application configuration
8 | import Config
9 |
10 | config :webrtc_live_view,
11 | generators: [timestamp_type: :utc_datetime]
12 |
13 | # Configures the endpoint
14 | config :webrtc_live_view, WebrtcLiveViewWeb.Endpoint,
15 | url: [host: "localhost"],
16 | adapter: Bandit.PhoenixAdapter,
17 | render_errors: [
18 | formats: [html: WebrtcLiveViewWeb.ErrorHTML, json: WebrtcLiveViewWeb.ErrorJSON],
19 | layout: false
20 | ],
21 | pubsub_server: WebrtcLiveView.PubSub,
22 | live_view: [signing_salt: "X97dFT34"]
23 |
24 | # Configure esbuild (the version is required)
25 | config :esbuild,
26 | version: "0.17.11",
27 | webrtc_live_view: [
28 | args:
29 | ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*),
30 | cd: Path.expand("../assets", __DIR__),
31 | env: %{
32 | "NODE_PATH" => Enum.map_join(["../deps", "../../../.."], ":", &Path.expand(&1, __DIR__))
33 | }
34 | ]
35 |
36 | # Configures Elixir's Logger
37 | config :logger, :console,
38 | format: "$time $metadata[$level] $message\n",
39 | metadata: [:request_id],
40 | level: :info
41 |
42 | # Use Jason for JSON parsing in Phoenix
43 | config :phoenix, :json_library, Jason
44 |
45 | # Import environment specific config. This must remain at the bottom
46 | # of this file so it overrides the configuration defined above.
47 | import_config "#{config_env()}.exs"
48 |
--------------------------------------------------------------------------------
/lib/membrane_webrtc/phoenix_signaling.ex:
--------------------------------------------------------------------------------
1 | if Code.ensure_loaded?(Phoenix) do
2 | defmodule Membrane.WebRTC.PhoenixSignaling do
3 | @moduledoc """
4 | Provides signaling capabilities for WebRTC connections through Phoenix channels.
5 | """
6 | alias Membrane.WebRTC.PhoenixSignaling.Registry, as: SignalingRegistry
7 | alias Membrane.WebRTC.Signaling
8 |
9 | @typedoc """
10 | A type representing an unique identifier that is used to distinguish between different Phoenix Signaling
11 | instances.
12 | """
13 | @type signaling_id :: String.t()
14 |
15 | @doc """
16 | Returns an instance of a Phoenix Signaling associated with given signaling ID.
17 | """
18 | @spec new(signaling_id()) :: Signaling.t()
19 | def new(signaling_id) do
20 | SignalingRegistry.get_or_create(signaling_id)
21 | end
22 |
23 | @doc """
24 | Registers Phoenix.Channel process as WebRTC signaling peer
25 | so that it can send and receive signaling messages.
26 | """
27 | @spec register_channel(signaling_id(), pid() | nil) :: :ok
28 | def register_channel(signaling_id, channel_pid \\ nil) do
29 | channel_pid = channel_pid || self()
30 | signaling = SignalingRegistry.get_or_create(signaling_id)
31 | Signaling.register_peer(signaling, message_format: :json_data, pid: channel_pid)
32 | end
33 |
34 | @doc """
35 | Sends a signal message via the Phoenix Signaling instance associated with given signaling ID.
36 | """
37 | @spec signal(signaling_id(), Signaling.message_content()) :: :ok | no_return()
38 | def signal(signaling_id, msg) do
39 | signaling = SignalingRegistry.get!(signaling_id)
40 | Signaling.signal(signaling, msg)
41 | end
42 | end
43 | end
44 |
--------------------------------------------------------------------------------
/examples/live_view/lib/webrtc_live_view_web/endpoint.ex:
--------------------------------------------------------------------------------
1 | defmodule WebrtcLiveViewWeb.Endpoint do
2 | use Phoenix.Endpoint, otp_app: :webrtc_live_view
3 |
4 | # The session will be stored in the cookie and signed,
5 | # this means its contents can be read but not tampered with.
6 | # Set :encryption_salt if you would also like to encrypt it.
7 | @session_options [
8 | store: :cookie,
9 | key: "_webrtc_live_view_key",
10 | signing_salt: "RxBv85K8",
11 | same_site: "Lax"
12 | ]
13 |
14 | socket "/live", Phoenix.LiveView.Socket,
15 | websocket: [connect_info: [session: @session_options]],
16 | longpoll: [connect_info: [session: @session_options]]
17 |
18 | # Serve at "/" the static files from "priv/static" directory.
19 | #
20 | # You should set gzip to true if you are running phx.digest
21 | # when deploying your static files in production.
22 | plug Plug.Static,
23 | at: "/",
24 | from: :webrtc_live_view,
25 | gzip: false,
26 | only: WebrtcLiveViewWeb.static_paths()
27 |
28 | # Code reloading can be explicitly enabled under the
29 | # :code_reloader configuration of your endpoint.
30 | if code_reloading? do
31 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
32 | plug Phoenix.LiveReloader
33 | plug Phoenix.CodeReloader
34 | end
35 |
36 | plug Phoenix.LiveDashboard.RequestLogger,
37 | param_key: "request_logger",
38 | cookie_key: "request_logger"
39 |
40 | plug Plug.RequestId
41 | plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
42 |
43 | plug Plug.Parsers,
44 | parsers: [:urlencoded, :multipart, :json],
45 | pass: ["*/*"],
46 | json_decoder: Phoenix.json_library()
47 |
48 | plug Plug.MethodOverride
49 | plug Plug.Head
50 | plug Plug.Session, @session_options
51 | plug WebrtcLiveViewWeb.Router
52 | end
53 |
--------------------------------------------------------------------------------
/examples/phoenix_signaling/lib/phoenix_signaling_web/endpoint.ex:
--------------------------------------------------------------------------------
1 | defmodule PhoenixSignalingWeb.Endpoint do
2 | use Phoenix.Endpoint, otp_app: :phoenix_signaling
3 |
4 | # The session will be stored in the cookie and signed,
5 | # this means its contents can be read but not tampered with.
6 | # Set :encryption_salt if you would also like to encrypt it.
7 | @session_options [
8 | store: :cookie,
9 | key: "_phoenix_signaling_key",
10 | signing_salt: "TCDEzcXo",
11 | same_site: "Lax"
12 | ]
13 |
14 | # socket "/live", Phoenix.LiveView.Socket,
15 | # websocket: [connect_info: [session: @session_options]],
16 | # longpoll: [connect_info: [session: @session_options]]
17 |
18 | # Serve at "/" the static files from "priv/static" directory.
19 | #
20 | # You should set gzip to true if you are running phx.digest
21 | # when deploying your static files in production.
22 | plug(Plug.Static,
23 | at: "/",
24 | from: :phoenix_signaling,
25 | gzip: false,
26 | only: PhoenixSignalingWeb.static_paths()
27 | )
28 |
29 | # Code reloading can be explicitly enabled under the
30 | # :code_reloader configuration of your endpoint.
31 | if code_reloading? do
32 | socket("/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket)
33 | plug(Phoenix.LiveReloader)
34 | plug(Phoenix.CodeReloader)
35 | end
36 |
37 | plug(Plug.RequestId)
38 | plug(Plug.Telemetry, event_prefix: [:phoenix, :endpoint])
39 |
40 | plug(Plug.Parsers,
41 | parsers: [:urlencoded, :multipart, :json],
42 | pass: ["*/*"],
43 | json_decoder: Phoenix.json_library()
44 | )
45 |
46 | plug(Plug.MethodOverride)
47 | plug(Plug.Head)
48 | plug(Plug.Session, @session_options)
49 | plug(PhoenixSignalingWeb.Router)
50 |
51 | socket("/signaling", Membrane.WebRTC.PhoenixSignaling.Socket,
52 | websocket: true,
53 | longpoll: false
54 | )
55 | end
56 |
--------------------------------------------------------------------------------
/.github/workflows/fetch_changes.yml:
--------------------------------------------------------------------------------
1 | # This is a basic workflow to help you get started with Actions
2 |
3 | name: Fetch changes
4 |
5 | # Controls when the workflow will run
6 | on:
7 | # Trigger thrice a day
8 | schedule:
9 | - cron: '0 4,8,12 * * *'
10 |
11 | # Allows you to run this workflow manually from the Actions tab
12 | workflow_dispatch:
13 |
14 | jobs:
15 | # This workflow contains a single job called "build"
16 | build:
17 | # The type of runner that the job will run on
18 | runs-on: ubuntu-latest
19 |
20 | # Steps represent a sequence of tasks that will be executed as part of the job
21 | steps:
22 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
23 | - uses: actions/checkout@v3
24 | with:
25 | fetch-depth: '0'
26 |
27 | - name: webfactory/ssh-agent
28 | uses: webfactory/ssh-agent@v0.5.4
29 | with:
30 | ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}
31 |
32 | # Runs a set of commands using the runners shell
33 | - name: Add remote
34 | run: |
35 | git remote add source git@github.com:membraneframework/membrane_template_plugin.git
36 | git remote update
37 |
38 | echo "CURRENT_BRANCH=$(git branch --show-current)" >> $GITHUB_ENV
39 |
40 | - name: Check changes
41 | run: |
42 | echo ${{env.CURRENT_BRANCH}}
43 | echo "LOG_SIZE=$(git log origin/${{ env.CURRENT_BRANCH }}..source/${{ env.CURRENT_BRANCH }} | wc -l)"
44 |
45 | echo "LOG_SIZE=$(git log origin/${{ env.CURRENT_BRANCH }}..source/${{ env.CURRENT_BRANCH }} | wc -l)" >> $GITHUB_ENV
46 |
47 | - if: ${{ env.LOG_SIZE != '0'}}
48 | name: Merge changes
49 | run: |
50 | git config --global user.email "admin@membraneframework.com"
51 | git config --global user.name "MembraneFramework"
52 |
53 | git merge source/master
54 | git push origin master
55 |
--------------------------------------------------------------------------------
/lib/membrane_webrtc/phoenix_signaling/registry.ex:
--------------------------------------------------------------------------------
1 | if Code.ensure_loaded?(Phoenix) do
2 | defmodule Membrane.WebRTC.PhoenixSignaling.Registry do
3 | @moduledoc false
4 | use GenServer
5 | alias Membrane.WebRTC.PhoenixSignaling
6 | alias Membrane.WebRTC.Signaling
7 |
8 | @spec start(term()) :: GenServer.on_start()
9 | def start(args) do
10 | GenServer.start(__MODULE__, args, name: __MODULE__)
11 | end
12 |
13 | @spec start_link(term()) :: GenServer.on_start()
14 | def start_link(args) do
15 | GenServer.start_link(__MODULE__, args, name: __MODULE__)
16 | end
17 |
18 | @spec get_or_create(PhoenixSignaling.signaling_id()) :: Signaling.t()
19 | def get_or_create(signaling_id) do
20 | GenServer.call(__MODULE__, {:get_or_create, signaling_id})
21 | end
22 |
23 | @spec get(PhoenixSignaling.signaling_id()) :: Signaling.t() | nil
24 | def get(signaling_id) do
25 | GenServer.call(__MODULE__, {:get, signaling_id})
26 | end
27 |
28 | @spec get!(PhoenixSignaling.signaling_id()) :: Signaling.t() | no_return()
29 | def get!(signaling_id) do
30 | case get(signaling_id) do
31 | nil ->
32 | raise "Couldn't find signaling instance associated with signaling_id: #{inspect(signaling_id)}"
33 |
34 | signaling ->
35 | signaling
36 | end
37 | end
38 |
39 | @impl true
40 | def init(_args) do
41 | {:ok, %{signaling_map: %{}}}
42 | end
43 |
44 | @impl true
45 | def handle_call({:get_or_create, signaling_id}, _from, state) do
46 | case Map.get(state.signaling_map, signaling_id) do
47 | nil ->
48 | signaling = Signaling.new()
49 | state = put_in(state, [:signaling_map, signaling_id], signaling)
50 | {:reply, signaling, state}
51 |
52 | signaling ->
53 | {:reply, signaling, state}
54 | end
55 | end
56 |
57 | @impl true
58 | def handle_call({:get, signaling_id}, _from, state) do
59 | {:reply, Map.get(state.signaling_map, signaling_id), state}
60 | end
61 | end
62 | end
63 |
--------------------------------------------------------------------------------
/examples/live_view/assets/js/app.js:
--------------------------------------------------------------------------------
1 | // If you want to use Phoenix channels, run `mix help phx.gen.channel`
2 | // to get started and then uncomment the line below.
3 | // import "./user_socket.js"
4 |
5 | // You can include dependencies in two ways.
6 | //
7 | // The simplest option is to put them in assets/vendor and
8 | // import them using relative paths:
9 | //
10 | // import "../vendor/some-package.js"
11 | //
12 | // Alternatively, you can `npm install some-package --prefix assets` and import
13 | // them using a path starting with the package name:
14 | //
15 | // import "some-package"
16 | //
17 |
18 | // Include phoenix_html to handle method=PUT/DELETE in forms and buttons.
19 | import "phoenix_html";
20 | // Establish Phoenix Socket and LiveView configuration.
21 | import { Socket } from "phoenix";
22 | import { LiveSocket } from "phoenix_live_view";
23 | import topbar from "../vendor/topbar";
24 | import { createCaptureHook, createPlayerHook } from "membrane_webrtc_plugin";
25 |
26 | const iceServers = [{ urls: "stun:stun.l.google.com:19302" }];
27 |
28 | let hooks = {};
29 | hooks.Capture = createCaptureHook(iceServers);
30 | hooks.Player = createPlayerHook(iceServers);
31 |
32 | let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content");
33 | let liveSocket = new LiveSocket("/live", Socket, {
34 | longPollFallbackMs: 2500,
35 | params: { _csrf_token: csrfToken },
36 | hooks: hooks,
37 | });
38 |
39 | // Show progress bar on live navigation and form submits
40 | topbar.config({ barColors: { 0: "#29d" }, shadowColor: "rgba(0, 0, 0, .3)" });
41 | window.addEventListener("phx:page-loading-start", (_info) => topbar.show(300));
42 | window.addEventListener("phx:page-loading-stop", (_info) => topbar.hide());
43 |
44 | // connect if there are any LiveViews on the page
45 | liveSocket.connect();
46 |
47 | // expose liveSocket on window for web console debug logs and latency simulation:
48 | // >> liveSocket.enableDebug()
49 | // >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session
50 | // >> liveSocket.disableLatencySim()
51 | window.liveSocket = liveSocket;
52 |
--------------------------------------------------------------------------------
/examples/live_view/mix.exs:
--------------------------------------------------------------------------------
1 | defmodule WebrtcLiveView.MixProject do
2 | use Mix.Project
3 |
4 | def project do
5 | [
6 | app: :webrtc_live_view,
7 | version: "0.1.0",
8 | elixir: "~> 1.14",
9 | elixirc_paths: elixirc_paths(Mix.env()),
10 | start_permanent: Mix.env() == :prod,
11 | aliases: aliases(),
12 | deps: deps()
13 | ]
14 | end
15 |
16 | # Configuration for the OTP application.
17 | #
18 | # Type `mix help compile.app` for more information.
19 | def application do
20 | [
21 | mod: {WebrtcLiveView.Application, []},
22 | extra_applications: [:logger, :runtime_tools]
23 | ]
24 | end
25 |
26 | # Specifies which paths to compile per environment.
27 | defp elixirc_paths(:test), do: ["lib", "test/support"]
28 | defp elixirc_paths(_), do: ["lib"]
29 |
30 | # Specifies your project dependencies.
31 | #
32 | # Type `mix help deps` for examples and options.
33 | defp deps do
34 | [
35 | {:membrane_webrtc_plugin, path: "../.."},
36 | {:phoenix, "~> 1.7.21"},
37 | {:phoenix_html, "~> 4.1"},
38 | {:phoenix_live_reload, "~> 1.2", only: :dev},
39 | {:phoenix_live_view, "~> 1.0"},
40 | {:floki, ">= 0.30.0", only: :test},
41 | {:phoenix_live_dashboard, "~> 0.8.3"},
42 | {:esbuild, "~> 0.8", runtime: Mix.env() == :dev},
43 | {:telemetry_metrics, "~> 1.0"},
44 | {:telemetry_poller, "~> 1.0"},
45 | {:jason, "~> 1.2"},
46 | {:dns_cluster, "~> 0.1.1"},
47 | {:bandit, "~> 1.5"}
48 | ]
49 | end
50 |
51 | # Aliases are shortcuts or tasks specific to the current project.
52 | # For example, to install project dependencies and perform other setup tasks, run:
53 | #
54 | # $ mix setup
55 | #
56 | # See the documentation for `Mix` for more info on aliases.
57 | defp aliases do
58 | [
59 | setup: ["deps.get", "assets.setup", "assets.build"],
60 | "assets.setup": ["esbuild.install --if-missing"],
61 | "assets.build": ["esbuild webrtc_live_view"],
62 | "assets.deploy": [
63 | "esbuild webrtc_live_view --minify",
64 | "phx.digest"
65 | ]
66 | ]
67 | end
68 | end
69 |
--------------------------------------------------------------------------------
/examples/phoenix_signaling/mix.exs:
--------------------------------------------------------------------------------
1 | defmodule PhoenixSignaling.MixProject do
2 | use Mix.Project
3 |
4 | def project do
5 | [
6 | app: :phoenix_signaling,
7 | version: "0.1.0",
8 | elixir: "~> 1.14",
9 | elixirc_paths: elixirc_paths(Mix.env()),
10 | start_permanent: Mix.env() == :prod,
11 | aliases: aliases(),
12 | deps: deps()
13 | ]
14 | end
15 |
16 | # Configuration for the OTP application.
17 | #
18 | # Type `mix help compile.app` for more information.
19 | def application do
20 | [
21 | mod: {PhoenixSignaling.Application, []},
22 | extra_applications: [:logger, :runtime_tools]
23 | ]
24 | end
25 |
26 | # Specifies which paths to compile per environment.
27 | defp elixirc_paths(:test), do: ["lib", "test/support"]
28 | defp elixirc_paths(_), do: ["lib"]
29 |
30 | # Specifies your project dependencies.
31 | #
32 | # Type `mix help deps` for examples and options.
33 | defp deps do
34 | [
35 | {:phoenix, "~> 1.7.20"},
36 | {:phoenix_html, "~> 4.1"},
37 | {:phoenix_live_reload, "~> 1.2", only: :dev},
38 | {:phoenix_live_view, "~> 1.0.0"},
39 | {:floki, ">= 0.30.0", only: :test},
40 | {:esbuild, "~> 0.8", runtime: Mix.env() == :dev},
41 | {:telemetry_metrics, "~> 1.0"},
42 | {:telemetry_poller, "~> 1.0"},
43 | {:jason, "~> 1.2"},
44 | {:dns_cluster, "~> 0.1.1"},
45 | {:bandit, "~> 1.5"},
46 | {:boombox, github: "membraneframework/boombox"},
47 | {:membrane_webrtc_plugin, path: "#{__DIR__}/../..", override: true},
48 | {:uuid, "~> 1.1"}
49 | ]
50 | end
51 |
52 | # Aliases are shortcuts or tasks specific to the current project.
53 | # For example, to install project dependencies and perform other setup tasks, run:
54 | #
55 | # $ mix setup
56 | #
57 | # See the documentation for `Mix` for more info on aliases.
58 | defp aliases do
59 | [
60 | setup: ["deps.get", "assets.setup", "assets.build"],
61 | "assets.setup": ["esbuild.install --if-missing"],
62 | "assets.build": ["esbuild phoenix_signaling"],
63 | "assets.deploy": [
64 | "esbuild phoenix_signaling --minify",
65 | "phx.digest"
66 | ]
67 | ]
68 | end
69 | end
70 |
--------------------------------------------------------------------------------
/assets/capture.js:
--------------------------------------------------------------------------------
1 | export function createCaptureHook(iceServers = [{ urls: `stun:stun.l.google.com:19302` }]) {
2 | return {
3 | async mounted() {
4 | this.handleEvent(`media_constraints-${this.el.id}`, async (mediaConstraints) => {
5 | console.log(`[${this.el.id}] Received media constraints:`, mediaConstraints);
6 |
7 | const localStream = await navigator.mediaDevices.getUserMedia(mediaConstraints);
8 | const pcConfig = { iceServers: iceServers };
9 | this.pc = new RTCPeerConnection(pcConfig);
10 |
11 | this.pc.onicecandidate = (event) => {
12 | if (event.candidate === null) return;
13 | console.log(`[${this.el.id}] Sent ICE candidate:`, event.candidate);
14 | message = { type: `ice_candidate`, data: event.candidate };
15 | this.pushEventTo(this.el, `webrtc_signaling`, message);
16 | };
17 |
18 | this.pc.onconnectionstatechange = () => {
19 | console.log(
20 | `[${this.el.id}] RTCPeerConnection state changed to`,
21 | this.pc.connectionState
22 | );
23 | };
24 |
25 | this.el.srcObject = new MediaStream();
26 |
27 | for (const track of localStream.getTracks()) {
28 | this.pc.addTrack(track, localStream);
29 | this.el.srcObject.addTrack(track);
30 | }
31 |
32 | this.el.play();
33 |
34 | this.handleEvent(`webrtc_signaling-${this.el.id}`, async (event) => {
35 | const { type, data } = event;
36 |
37 | switch (type) {
38 | case `sdp_answer`:
39 | console.log(`[${this.el.id}] Received SDP answer:`, data);
40 | await this.pc.setRemoteDescription(data);
41 | break;
42 | case `ice_candidate`:
43 | console.log(`[${this.el.id}] Received ICE candidate:`, data);
44 | await this.pc.addIceCandidate(data);
45 | break;
46 | }
47 | });
48 |
49 | const offer = await this.pc.createOffer();
50 | await this.pc.setLocalDescription(offer);
51 | console.log(`[${this.el.id}] Sent SDP offer:`, offer);
52 | message = { type: `sdp_offer`, data: offer };
53 | this.pushEventTo(this.el, `webrtc_signaling`, message);
54 | });
55 | },
56 | };
57 | }
58 |
--------------------------------------------------------------------------------
/examples/websocket_signaling/browser_to_file.exs:
--------------------------------------------------------------------------------
1 | # This example receives audio and video from a browser via WebRTC
2 | # and saves it to a `recording.mkv` file.
3 | # To run it, type `elixir browser_to_file.exs` and open
4 | # http://localhost:8000/index.html in your browser. To finish recording,
5 | # click the `disconnect` button or close the tab.
6 |
7 | require Logger
8 | Logger.configure(level: :info)
9 |
10 | Mix.install([
11 | {:membrane_webrtc_plugin, path: "#{__DIR__}/../.."},
12 | :membrane_file_plugin,
13 | :membrane_realtimer_plugin,
14 | :membrane_matroska_plugin,
15 | :membrane_opus_plugin,
16 | :membrane_h264_plugin
17 | ])
18 |
19 | defmodule Example.Pipeline do
20 | use Membrane.Pipeline
21 |
22 | alias Membrane.WebRTC
23 |
24 | @impl true
25 | def handle_init(_ctx, opts) do
26 | spec =
27 | [
28 | child(:webrtc, %WebRTC.Source{
29 | signaling: {
30 | :whip,
31 | token: "whip_it!",
32 | port: opts[:port],
33 | ip: :any,
34 | serve_static: "#{__DIR__}/assets/browser_to_file"
35 | }
36 | }),
37 | child(:matroska, Membrane.Matroska.Muxer),
38 | get_child(:webrtc)
39 | |> via_out(:output, options: [kind: :audio])
40 | |> child(Membrane.Opus.Parser)
41 | |> get_child(:matroska),
42 | get_child(:webrtc)
43 | |> via_out(:output, options: [kind: :video])
44 | |> get_child(:matroska),
45 | get_child(:matroska)
46 | |> child(:sink, %Membrane.File.Sink{location: "recording.mkv"})
47 | ]
48 |
49 | {[spec: spec], %{}}
50 | end
51 |
52 | @impl true
53 | def handle_element_end_of_stream(:sink, :input, _ctx, state) do
54 | {[terminate: :normal], state}
55 | end
56 |
57 | @impl true
58 | def handle_element_end_of_stream(_element, _pad, _ctx, state) do
59 | {[], state}
60 | end
61 | end
62 |
63 | port = 8829
64 | {:ok, supervisor, _pipeline} = Membrane.Pipeline.start_link(Example.Pipeline, port: port)
65 | Process.monitor(supervisor)
66 |
67 | Logger.info("""
68 | Visit http://localhost:#{port}/static/index.html to start the stream. To finish the recording properly,
69 | don't terminate this script - instead click 'disconnect' in the website or close the browser tab.
70 | """)
71 |
72 | receive do
73 | {:DOWN, _ref, :process, ^supervisor, _reason} -> :ok
74 | end
75 |
--------------------------------------------------------------------------------
/lib/membrane_webrtc/phoenix_signaling/socket.ex:
--------------------------------------------------------------------------------
1 | if Code.ensure_loaded?(Phoenix) do
2 | defmodule Membrane.WebRTC.PhoenixSignaling.Socket do
3 | @moduledoc """
4 | Phoenix Socket implementation which redirects all topics to a
5 | Phoenix Channel capable of processing WebRTC signaling messages.
6 |
7 | *Note:* This module will be available in your code only if you add `:phoenix`
8 | to the dependencies of of your root project.
9 |
10 | To use PhoenixSignaling, you need to:
11 | 1. Create Socket in your application endpoint, for instance:
12 | ```
13 | socket "/signaling", Membrane.WebRTC.PhoenixSignaling.Socket,
14 | websocket: true,
15 | longpoll: false
16 | ```
17 | 2. Create a Phoenix signaling channel with desired signaling ID and use it as `Membrane.WebRTC.Signaling.t()`
18 | for `Membrane.WebRTC.Source`, `Membrane.WebRTC.Sink` or [`Boombox`](https://github.com/membraneframework/boombox):
19 | ```
20 | signaling = Membrane.WebRTC.PhoenixSignaling.new("")
21 |
22 | # use it with Membrane.WebRTC.Source:
23 | child(:webrtc_source, %Membrane.WebRTC.Source{signaling: signaling})
24 | |> ...
25 |
26 | # or with Membrane.WebRTC.Sink:
27 | ...
28 | |> child(:webrtc_sink, %Membrane.WebRTC.Sink{signaling: signaling})
29 |
30 | # or with Boombox:
31 | Boombox.run(
32 | input: {:webrtc, signaling},
33 | output: ...
34 | )
35 | ```
36 | 2. Create signaling channel with desired signaling ID:
37 | ```
38 | signaling = PhoenixSignaling.new("signaling_id")
39 | ```
40 | 3. Use the Phoenix Socket to exchange WebRTC signaling data:
41 | ```
42 | let socket = new Socket("/singaling", {params: {token: window.userToken}})
43 | socket.connect()
44 | let channel = socket.channel('signaling_id')
45 | channel.join()
46 | .receive("ok", resp => { console.log("Joined successfully", resp)
47 | // here you can exchange WebRTC data
48 | })
49 | .receive("error", resp => { console.log("Unable to join", resp) })
50 |
51 | ```
52 | """
53 | use Phoenix.Socket
54 |
55 | channel("*", Membrane.WebRTC.PhoenixSignaling.Channel)
56 |
57 | @impl true
58 | def connect(_params, socket, _connect_info) do
59 | {:ok, socket}
60 | end
61 |
62 | @impl true
63 | def id(_socket), do: nil
64 | end
65 | end
66 |
--------------------------------------------------------------------------------
/lib/membrane_webrtc/ex_webrtc/utils.ex:
--------------------------------------------------------------------------------
1 | defmodule Membrane.WebRTC.ExWebRTCUtils do
2 | @moduledoc false
3 |
4 | alias ExWebRTC.RTPCodecParameters
5 |
6 | @type codec :: :opus | :h264 | :vp8
7 | @type codec_or_codecs :: codec() | [codec()]
8 |
9 | @spec codec_params(codec_or_codecs()) :: [RTPCodecParameters.t()]
10 | def codec_params(:opus),
11 | do: [
12 | %RTPCodecParameters{
13 | payload_type: 111,
14 | mime_type: "audio/opus",
15 | clock_rate: codec_clock_rate(:opus),
16 | channels: 2
17 | }
18 | ]
19 |
20 | def codec_params(:h264) do
21 | [
22 | %RTPCodecParameters{
23 | payload_type: 96,
24 | mime_type: "video/H264",
25 | clock_rate: codec_clock_rate(:h264),
26 | sdp_fmtp_line: %ExSDP.Attribute.FMTP{
27 | pt: 96,
28 | level_asymmetry_allowed: true,
29 | packetization_mode: 1,
30 | profile_level_id: 0x42E01F
31 | }
32 | }
33 | ]
34 | end
35 |
36 | def codec_params(:vp8) do
37 | [
38 | %RTPCodecParameters{
39 | payload_type: 102,
40 | mime_type: "video/VP8",
41 | clock_rate: codec_clock_rate(:vp8)
42 | }
43 | ]
44 | end
45 |
46 | def codec_params(codecs) when is_list(codecs) do
47 | codecs |> Enum.flat_map(&codec_params/1)
48 | end
49 |
50 | @spec codec_clock_rate(codec_or_codecs()) :: pos_integer()
51 | def codec_clock_rate(:opus), do: 48_000
52 | def codec_clock_rate(:vp8), do: 90_000
53 | def codec_clock_rate(:h264), do: 90_000
54 |
55 | def codec_clock_rate(codecs) when is_list(codecs) do
56 | cond do
57 | codecs == [:opus] ->
58 | 48_000
59 |
60 | codecs != [] and Enum.all?(codecs, &(&1 in [:vp8, :h264])) ->
61 | 90_000
62 | end
63 | end
64 |
65 | @spec get_video_codecs_from_sdp(ExWebRTC.SessionDescription.t()) :: [:h264 | :vp8]
66 | def get_video_codecs_from_sdp(%ExWebRTC.SessionDescription{sdp: sdp}) do
67 | ex_sdp = ExSDP.parse!(sdp)
68 |
69 | ex_sdp.media
70 | |> Enum.flat_map(fn
71 | %{type: :video, attributes: attributes} -> attributes
72 | _media -> []
73 | end)
74 | |> Enum.flat_map(fn
75 | %ExSDP.Attribute.RTPMapping{encoding: "H264"} -> [:h264]
76 | %ExSDP.Attribute.RTPMapping{encoding: "VP8"} -> [:vp8]
77 | _attribute -> []
78 | end)
79 | |> Enum.uniq()
80 | end
81 | end
82 |
--------------------------------------------------------------------------------
/examples/live_view/lib/webrtc_live_view_web/telemetry.ex:
--------------------------------------------------------------------------------
1 | defmodule WebrtcLiveViewWeb.Telemetry do
2 | use Supervisor
3 | import Telemetry.Metrics
4 |
5 | def start_link(arg) do
6 | Supervisor.start_link(__MODULE__, arg, name: __MODULE__)
7 | end
8 |
9 | @impl true
10 | def init(_arg) do
11 | children = [
12 | # Telemetry poller will execute the given period measurements
13 | # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics
14 | {:telemetry_poller, measurements: periodic_measurements(), period: 10_000}
15 | # Add reporters as children of your supervision tree.
16 | # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()}
17 | ]
18 |
19 | Supervisor.init(children, strategy: :one_for_one)
20 | end
21 |
22 | def metrics do
23 | [
24 | # Phoenix Metrics
25 | summary("phoenix.endpoint.start.system_time",
26 | unit: {:native, :millisecond}
27 | ),
28 | summary("phoenix.endpoint.stop.duration",
29 | unit: {:native, :millisecond}
30 | ),
31 | summary("phoenix.router_dispatch.start.system_time",
32 | tags: [:route],
33 | unit: {:native, :millisecond}
34 | ),
35 | summary("phoenix.router_dispatch.exception.duration",
36 | tags: [:route],
37 | unit: {:native, :millisecond}
38 | ),
39 | summary("phoenix.router_dispatch.stop.duration",
40 | tags: [:route],
41 | unit: {:native, :millisecond}
42 | ),
43 | summary("phoenix.socket_connected.duration",
44 | unit: {:native, :millisecond}
45 | ),
46 | sum("phoenix.socket_drain.count"),
47 | summary("phoenix.channel_joined.duration",
48 | unit: {:native, :millisecond}
49 | ),
50 | summary("phoenix.channel_handled_in.duration",
51 | tags: [:event],
52 | unit: {:native, :millisecond}
53 | ),
54 |
55 | # VM Metrics
56 | summary("vm.memory.total", unit: {:byte, :kilobyte}),
57 | summary("vm.total_run_queue_lengths.total"),
58 | summary("vm.total_run_queue_lengths.cpu"),
59 | summary("vm.total_run_queue_lengths.io")
60 | ]
61 | end
62 |
63 | defp periodic_measurements do
64 | [
65 | # A module, function and arguments to be invoked periodically.
66 | # This function must call :telemetry.execute/3 and a metric must be added above.
67 | # {WebrtcLiveViewWeb, :count_users, []}
68 | ]
69 | end
70 | end
71 |
--------------------------------------------------------------------------------
/examples/phoenix_signaling/lib/phoenix_signaling_web/telemetry.ex:
--------------------------------------------------------------------------------
1 | defmodule PhoenixSignalingWeb.Telemetry do
2 | use Supervisor
3 | import Telemetry.Metrics
4 |
5 | def start_link(arg) do
6 | Supervisor.start_link(__MODULE__, arg, name: __MODULE__)
7 | end
8 |
9 | @impl true
10 | def init(_arg) do
11 | children = [
12 | # Telemetry poller will execute the given period measurements
13 | # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics
14 | {:telemetry_poller, measurements: periodic_measurements(), period: 10_000}
15 | # Add reporters as children of your supervision tree.
16 | # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()}
17 | ]
18 |
19 | Supervisor.init(children, strategy: :one_for_one)
20 | end
21 |
22 | def metrics do
23 | [
24 | # Phoenix Metrics
25 | summary("phoenix.endpoint.start.system_time",
26 | unit: {:native, :millisecond}
27 | ),
28 | summary("phoenix.endpoint.stop.duration",
29 | unit: {:native, :millisecond}
30 | ),
31 | summary("phoenix.router_dispatch.start.system_time",
32 | tags: [:route],
33 | unit: {:native, :millisecond}
34 | ),
35 | summary("phoenix.router_dispatch.exception.duration",
36 | tags: [:route],
37 | unit: {:native, :millisecond}
38 | ),
39 | summary("phoenix.router_dispatch.stop.duration",
40 | tags: [:route],
41 | unit: {:native, :millisecond}
42 | ),
43 | summary("phoenix.socket_connected.duration",
44 | unit: {:native, :millisecond}
45 | ),
46 | sum("phoenix.socket_drain.count"),
47 | summary("phoenix.channel_joined.duration",
48 | unit: {:native, :millisecond}
49 | ),
50 | summary("phoenix.channel_handled_in.duration",
51 | tags: [:event],
52 | unit: {:native, :millisecond}
53 | ),
54 |
55 | # VM Metrics
56 | summary("vm.memory.total", unit: {:byte, :kilobyte}),
57 | summary("vm.total_run_queue_lengths.total"),
58 | summary("vm.total_run_queue_lengths.cpu"),
59 | summary("vm.total_run_queue_lengths.io")
60 | ]
61 | end
62 |
63 | defp periodic_measurements do
64 | [
65 | # A module, function and arguments to be invoked periodically.
66 | # This function must call :telemetry.execute/3 and a metric must be added above.
67 | # {PhoenixSignalingWeb, :count_users, []}
68 | ]
69 | end
70 | end
71 |
--------------------------------------------------------------------------------
/examples/live_view/config/dev.exs:
--------------------------------------------------------------------------------
1 | import Config
2 |
3 | # For development, we disable any cache and enable
4 | # debugging and code reloading.
5 | #
6 | # The watchers configuration can be used to run external
7 | # watchers to your application. For example, we can use it
8 | # to bundle .js and .css sources.
9 | config :webrtc_live_view, WebrtcLiveViewWeb.Endpoint,
10 | # Binding to loopback ipv4 address prevents access from other machines.
11 | # Change to `ip: {0, 0, 0, 0}` to allow access from other machines.
12 | http: [ip: {127, 0, 0, 1}, port: 4000],
13 | check_origin: false,
14 | code_reloader: true,
15 | debug_errors: true,
16 | secret_key_base: "hvkMsB0coySlkK38GdeYwpOMBEFJFmK/ogj8SD791OFVAxlk89y1fOGkumXlYgIH",
17 | watchers: [
18 | esbuild: {Esbuild, :install_and_run, [:webrtc_live_view, ~w(--sourcemap=inline --watch)]}
19 | ]
20 |
21 | # ## SSL Support
22 | #
23 | # In order to use HTTPS in development, a self-signed
24 | # certificate can be generated by running the following
25 | # Mix task:
26 | #
27 | # mix phx.gen.cert
28 | #
29 | # Run `mix help phx.gen.cert` for more information.
30 | #
31 | # The `http:` config above can be replaced with:
32 | #
33 | # https: [
34 | # port: 4001,
35 | # cipher_suite: :strong,
36 | # keyfile: "priv/cert/selfsigned_key.pem",
37 | # certfile: "priv/cert/selfsigned.pem"
38 | # ],
39 | #
40 | # If desired, both `http:` and `https:` keys can be
41 | # configured to run both http and https servers on
42 | # different ports.
43 |
44 | # Watch static and templates for browser reloading.
45 | config :webrtc_live_view, WebrtcLiveViewWeb.Endpoint,
46 | live_reload: [
47 | patterns: [
48 | ~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg)$",
49 | ~r"lib/webrtc_live_view_web/(controllers|live|components)/.*(ex|heex)$"
50 | ]
51 | ]
52 |
53 | # Enable dev routes for dashboard and mailbox
54 | config :webrtc_live_view, dev_routes: true
55 |
56 | # Do not include metadata nor timestamps in development logs
57 | config :logger, :console, format: "[$level] $message\n"
58 |
59 | # Set a higher stacktrace during development. Avoid configuring such
60 | # in production as building large stacktraces may be expensive.
61 | config :phoenix, :stacktrace_depth, 20
62 |
63 | # Initialize plugs at runtime for faster development compilation
64 | config :phoenix, :plug_init_mode, :runtime
65 |
66 | config :phoenix_live_view,
67 | # Include HEEx debug annotations as HTML comments in rendered markup
68 | debug_heex_annotations: true,
69 | # Enable helpful, but potentially expensive runtime checks
70 | enable_expensive_runtime_checks: true
71 |
--------------------------------------------------------------------------------
/examples/phoenix_signaling/config/dev.exs:
--------------------------------------------------------------------------------
1 | import Config
2 |
3 | # For development, we disable any cache and enable
4 | # debugging and code reloading.
5 | #
6 | # The watchers configuration can be used to run external
7 | # watchers to your application. For example, we can use it
8 | # to bundle .js and .css sources.
9 | # Binding to loopback ipv4 address prevents access from other machines.
10 | config :phoenix_signaling, PhoenixSignalingWeb.Endpoint,
11 | # Change to `ip: {0, 0, 0, 0}` to allow access from other machines.
12 | http: [ip: {127, 0, 0, 1}, port: 4000],
13 | check_origin: false,
14 | code_reloader: true,
15 | debug_errors: true,
16 | secret_key_base: "ww7+NbKIOykx/9RbfIWdgDlgjsPTX84lIGmqiwuxMeyTakzcYsCbb7XgPDKv1/jy",
17 | watchers: [
18 | esbuild: {Esbuild, :install_and_run, [:phoenix_signaling, ~w(--sourcemap=inline --watch)]}
19 | ]
20 |
21 | # ## SSL Support
22 | #
23 | # In order to use HTTPS in development, a self-signed
24 | # certificate can be generated by running the following
25 | # Mix task:
26 | #
27 | # mix phx.gen.cert
28 | #
29 | # Run `mix help phx.gen.cert` for more information.
30 | #
31 | # The `http:` config above can be replaced with:
32 | #
33 | # https: [
34 | # port: 4001,
35 | # cipher_suite: :strong,
36 | # keyfile: "priv/cert/selfsigned_key.pem",
37 | # certfile: "priv/cert/selfsigned.pem"
38 | # ],
39 | #
40 | # If desired, both `http:` and `https:` keys can be
41 | # configured to run both http and https servers on
42 | # different ports.
43 |
44 | # Watch static and templates for browser reloading.
45 | config :phoenix_signaling, PhoenixSignalingWeb.Endpoint,
46 | live_reload: [
47 | patterns: [
48 | ~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg)$",
49 | ~r"lib/phoenix_signaling_web/(controllers|live|components)/.*(ex|heex)$"
50 | ]
51 | ]
52 |
53 | # Enable dev routes for dashboard and mailbox
54 | config :phoenix_signaling, dev_routes: true
55 |
56 | # Do not include metadata nor timestamps in development logs
57 | config :logger, :console, format: "[$level] $message\n"
58 |
59 | # Set a higher stacktrace during development. Avoid configuring such
60 | # in production as building large stacktraces may be expensive.
61 | config :phoenix, :stacktrace_depth, 20
62 |
63 | # Initialize plugs at runtime for faster development compilation
64 | config :phoenix, :plug_init_mode, :runtime
65 |
66 | config :phoenix_live_view,
67 | # Include HEEx debug annotations as HTML comments in rendered markup
68 | debug_heex_annotations: true,
69 | # Enable helpful, but potentially expensive runtime checks
70 | enable_expensive_runtime_checks: true
71 |
--------------------------------------------------------------------------------
/examples/live_view/priv/static/images/logo.svg:
--------------------------------------------------------------------------------
1 |
7 |
--------------------------------------------------------------------------------
/examples/phoenix_signaling/priv/static/images/logo.svg:
--------------------------------------------------------------------------------
1 |
7 |
--------------------------------------------------------------------------------
/examples/live_view/lib/webrtc_live_view_web.ex:
--------------------------------------------------------------------------------
1 | defmodule WebrtcLiveViewWeb do
2 | @moduledoc """
3 | The entrypoint for defining your web interface, such
4 | as controllers, components, channels, and so on.
5 |
6 | This can be used in your application as:
7 |
8 | use WebrtcLiveViewWeb, :controller
9 | use WebrtcLiveViewWeb, :html
10 |
11 | The definitions below will be executed for every controller,
12 | component, etc, so keep them short and clean, focused
13 | on imports, uses and aliases.
14 |
15 | Do NOT define functions inside the quoted expressions
16 | below. Instead, define additional modules and import
17 | those modules here.
18 | """
19 |
20 | def static_paths, do: ~w(assets fonts images favicon.ico robots.txt)
21 |
22 | def router do
23 | quote do
24 | use Phoenix.Router, helpers: false
25 |
26 | # Import common connection and controller functions to use in pipelines
27 | import Plug.Conn
28 | import Phoenix.Controller
29 | import Phoenix.LiveView.Router
30 | end
31 | end
32 |
33 | def channel do
34 | quote do
35 | use Phoenix.Channel
36 | end
37 | end
38 |
39 | def controller do
40 | quote do
41 | use Phoenix.Controller,
42 | formats: [:html, :json],
43 | layouts: [html: WebrtcLiveViewWeb.Layouts]
44 |
45 | import Plug.Conn
46 |
47 | unquote(verified_routes())
48 | end
49 | end
50 |
51 | def live_view do
52 | quote do
53 | use Phoenix.LiveView,
54 | layout: {WebrtcLiveViewWeb.Layouts, :app}
55 |
56 | unquote(html_helpers())
57 | end
58 | end
59 |
60 | def live_component do
61 | quote do
62 | use Phoenix.LiveComponent
63 |
64 | unquote(html_helpers())
65 | end
66 | end
67 |
68 | def html do
69 | quote do
70 | use Phoenix.Component
71 |
72 | # Import convenience functions from controllers
73 | import Phoenix.Controller,
74 | only: [get_csrf_token: 0, view_module: 1, view_template: 1]
75 |
76 | # Include general helpers for rendering HTML
77 | unquote(html_helpers())
78 | end
79 | end
80 |
81 | defp html_helpers do
82 | quote do
83 | # HTML escaping functionality
84 | import Phoenix.HTML
85 | # Core UI components
86 | import WebrtcLiveViewWeb.CoreComponents
87 |
88 | # Shortcut for generating JS commands
89 | alias Phoenix.LiveView.JS
90 |
91 | # Routes generation with the ~p sigil
92 | unquote(verified_routes())
93 | end
94 | end
95 |
96 | def verified_routes do
97 | quote do
98 | use Phoenix.VerifiedRoutes,
99 | endpoint: WebrtcLiveViewWeb.Endpoint,
100 | router: WebrtcLiveViewWeb.Router,
101 | statics: WebrtcLiveViewWeb.static_paths()
102 | end
103 | end
104 |
105 | @doc """
106 | When used, dispatch to the appropriate controller/live_view/etc.
107 | """
108 | defmacro __using__(which) when is_atom(which) do
109 | apply(__MODULE__, which, [])
110 | end
111 | end
112 |
--------------------------------------------------------------------------------
/examples/phoenix_signaling/lib/phoenix_signaling_web.ex:
--------------------------------------------------------------------------------
1 | defmodule PhoenixSignalingWeb do
2 | @moduledoc """
3 | The entrypoint for defining your web interface, such
4 | as controllers, components, channels, and so on.
5 |
6 | This can be used in your application as:
7 |
8 | use PhoenixSignalingWeb, :controller
9 | use PhoenixSignalingWeb, :html
10 |
11 | The definitions below will be executed for every controller,
12 | component, etc, so keep them short and clean, focused
13 | on imports, uses and aliases.
14 |
15 | Do NOT define functions inside the quoted expressions
16 | below. Instead, define additional modules and import
17 | those modules here.
18 | """
19 |
20 | def static_paths, do: ~w(assets fonts images favicon.ico robots.txt)
21 |
22 | def router do
23 | quote do
24 | use Phoenix.Router, helpers: false
25 |
26 | # Import common connection and controller functions to use in pipelines
27 | import Plug.Conn
28 | import Phoenix.Controller
29 | import Phoenix.LiveView.Router
30 | end
31 | end
32 |
33 | def channel do
34 | quote do
35 | use Phoenix.Channel
36 | end
37 | end
38 |
39 | def controller do
40 | quote do
41 | use Phoenix.Controller,
42 | formats: [:html, :json],
43 | layouts: [html: PhoenixSignalingWeb.Layouts]
44 |
45 | import Plug.Conn
46 |
47 | unquote(verified_routes())
48 | end
49 | end
50 |
51 | def live_view do
52 | quote do
53 | use Phoenix.LiveView,
54 | layout: {PhoenixSignalingWeb.Layouts, :app}
55 |
56 | unquote(html_helpers())
57 | end
58 | end
59 |
60 | def live_component do
61 | quote do
62 | use Phoenix.LiveComponent
63 |
64 | unquote(html_helpers())
65 | end
66 | end
67 |
68 | def html do
69 | quote do
70 | use Phoenix.Component
71 |
72 | # Import convenience functions from controllers
73 | import Phoenix.Controller,
74 | only: [get_csrf_token: 0, view_module: 1, view_template: 1]
75 |
76 | # Include general helpers for rendering HTML
77 | unquote(html_helpers())
78 | end
79 | end
80 |
81 | defp html_helpers do
82 | quote do
83 | # HTML escaping functionality
84 | import Phoenix.HTML
85 | # Core UI components
86 | import PhoenixSignalingWeb.CoreComponents
87 |
88 | # Shortcut for generating JS commands
89 | alias Phoenix.LiveView.JS
90 |
91 | # Routes generation with the ~p sigil
92 | unquote(verified_routes())
93 | end
94 | end
95 |
96 | def verified_routes do
97 | quote do
98 | use Phoenix.VerifiedRoutes,
99 | endpoint: PhoenixSignalingWeb.Endpoint,
100 | router: PhoenixSignalingWeb.Router,
101 | statics: PhoenixSignalingWeb.static_paths()
102 | end
103 | end
104 |
105 | @doc """
106 | When used, dispatch to the appropriate controller/live_view/etc.
107 | """
108 | defmacro __using__(which) when is_atom(which) do
109 | apply(__MODULE__, which, [])
110 | end
111 | end
112 |
--------------------------------------------------------------------------------
/mix.exs:
--------------------------------------------------------------------------------
1 | defmodule Membrane.WebRTC.Plugin.Mixfile do
2 | use Mix.Project
3 |
4 | @version "0.26.1"
5 | @github_url "https://github.com/membraneframework/membrane_webrtc_plugin"
6 |
7 | def project do
8 | [
9 | app: :membrane_webrtc_plugin,
10 | version: @version,
11 | elixir: "~> 1.13",
12 | elixirc_paths: elixirc_paths(Mix.env()),
13 | start_permanent: Mix.env() == :prod,
14 | deps: deps(),
15 | dialyzer: dialyzer(),
16 |
17 | # hex
18 | description: "Membrane WebRTC plugin",
19 | package: package(),
20 |
21 | # docs
22 | name: "Membrane WebRTC plugin",
23 | source_url: @github_url,
24 | docs: docs()
25 | ]
26 | end
27 |
28 | def application do
29 | [
30 | mod: {Membrane.WebRTC.App, []},
31 | extra_applications: []
32 | ]
33 | end
34 |
35 | defp elixirc_paths(:test), do: ["lib", "test/support"]
36 | defp elixirc_paths(_env), do: ["lib"]
37 |
38 | defp deps do
39 | [
40 | # Phoenix
41 | {:phoenix, ">= 0.0.0", optional: true},
42 | {:phoenix_live_view, "~> 1.0", optional: true},
43 |
44 | # Membrane
45 | {:membrane_core, "~> 1.2 and >= 1.2.2"},
46 | {:membrane_rtp_plugin, "~> 0.31.1"},
47 | {:membrane_rtp_h264_plugin, "~> 0.20.1"},
48 | {:membrane_rtp_vp8_plugin, "~> 0.9.4"},
49 | {:membrane_rtp_opus_plugin, "~> 0.10.0"},
50 |
51 | # Other dependencies
52 | {:ex_webrtc, "~> 0.15.0"},
53 | {:corsica, "~> 2.0"},
54 | {:bandit, "~> 1.2"},
55 | {:websock_adapter, "~> 0.5.0"},
56 | {:req, "~> 0.5"},
57 | {:membrane_matroska_plugin, "~> 0.5.0", only: :test},
58 | {:membrane_mp4_plugin, "~> 0.35.2", only: :test},
59 | {:membrane_h26x_plugin, "~> 0.10.2", only: :test},
60 | {:membrane_file_plugin, "~> 0.17.0", only: :test},
61 | {:membrane_realtimer_plugin, "~> 0.10.0", only: :test},
62 | {:membrane_opus_plugin, "~> 0.20.0", only: :test},
63 | {:ex_doc, ">= 0.0.0", only: :dev, runtime: false},
64 | {:dialyxir, ">= 0.0.0", only: :dev, runtime: false},
65 | {:credo, ">= 0.0.0", only: :dev, runtime: false}
66 | ]
67 | end
68 |
69 | defp dialyzer() do
70 | opts = [
71 | flags: [:error_handling]
72 | ]
73 |
74 | if System.get_env("CI") == "true" do
75 | # Store PLTs in cacheable directory for CI
76 | [plt_local_path: "priv/plts", plt_core_path: "priv/plts"] ++ opts
77 | else
78 | opts
79 | end
80 | end
81 |
82 | defp package do
83 | [
84 | maintainers: ["Membrane Team"],
85 | files: ~w(mix.exs lib assets package.json README.md LICENSE),
86 | licenses: ["Apache-2.0"],
87 | links: %{
88 | "GitHub" => @github_url,
89 | "Membrane Framework Homepage" => "https://membrane.stream"
90 | }
91 | ]
92 | end
93 |
94 | defp docs do
95 | [
96 | main: "readme",
97 | extras: ["README.md", "LICENSE"],
98 | formatters: ["html"],
99 | source_ref: "v#{@version}",
100 | nest_modules_by_prefix: [Membrane.WebRTC]
101 | ]
102 | end
103 | end
104 |
--------------------------------------------------------------------------------
/examples/websocket_signaling/file_to_browser.exs:
--------------------------------------------------------------------------------
1 | # This example reads a short part of the Big Buck Bunny movie
2 | # from an `.mkv` file and streams it to a browser.
3 | # To run it, type `elixir file_to_browser.exs` and open
4 | # http://localhost:8000/index.html in your browser.
5 | # Note that due to browsers' policy, you need to manually unmute
6 | # audio in the player to hear the sound.
7 |
8 | require Logger
9 | Logger.configure(level: :info)
10 |
11 | Mix.install([
12 | {:membrane_webrtc_plugin, path: "#{__DIR__}/../.."},
13 | :membrane_file_plugin,
14 | :membrane_realtimer_plugin,
15 | :membrane_matroska_plugin,
16 | :membrane_opus_plugin
17 | ])
18 |
19 | defmodule Example.Pipeline do
20 | use Membrane.Pipeline
21 |
22 | alias Membrane.WebRTC
23 |
24 | @impl true
25 | def handle_init(_ctx, opts) do
26 | spec =
27 | child(%Membrane.File.Source{location: "#{__DIR__}/assets/bbb_vp8.mkv"})
28 | |> child(:demuxer, Membrane.Matroska.Demuxer)
29 |
30 | {[spec: spec], %{audio_track: nil, video_track: nil, port: opts[:port]}}
31 | end
32 |
33 | @impl true
34 | def handle_child_notification({:new_track, {id, info}}, :demuxer, _ctx, state) do
35 | state =
36 | case info.codec do
37 | :opus -> %{state | audio_track: id}
38 | :h264 -> %{state | video_track: id}
39 | :vp8 -> %{state | video_track: id}
40 | end
41 |
42 | if state.audio_track && state.video_track do
43 | spec = [
44 | child(:webrtc, %WebRTC.Sink{signaling: {:whip, uri: "http://localhost:8888"}}),
45 | get_child(:demuxer)
46 | |> via_out(Pad.ref(:output, state.video_track))
47 | |> child({:realtimer, :video_track}, Membrane.Realtimer)
48 | |> via_in(Pad.ref(:input, :video_track), options: [kind: :video])
49 | |> get_child(:webrtc),
50 | get_child(:demuxer)
51 | |> via_out(Pad.ref(:output, state.audio_track))
52 | |> child({:realtimer, :audio_track}, Membrane.Realtimer)
53 | |> via_in(Pad.ref(:input, :audio_track), options: [kind: :audio])
54 | |> get_child(:webrtc)
55 | ]
56 |
57 | {[spec: spec], state}
58 | else
59 | {[], state}
60 | end
61 | end
62 |
63 | @impl true
64 | def handle_child_notification({:end_of_stream, track}, :webrtc, _ctx, state) do
65 | state = %{state | track => nil}
66 |
67 | if !state.audio_track && !state.video_track do
68 | {[terminate: :normal], state}
69 | else
70 | {[], state}
71 | end
72 | end
73 |
74 | @impl true
75 | def handle_child_notification(_notification, _child, _ctx, state) do
76 | {[], state}
77 | end
78 | end
79 |
80 | {:ok, supervisor, _pipeline} = Membrane.Pipeline.start_link(Example.Pipeline, port: 8829)
81 | Process.monitor(supervisor)
82 | :ok = :inets.start()
83 |
84 | {:ok, _server} =
85 | :inets.start(:httpd,
86 | bind_address: ~c"localhost",
87 | port: 8000,
88 | document_root: ~c"#{__DIR__}/assets/file_to_browser",
89 | server_name: ~c"webrtc",
90 | server_root: "/tmp"
91 | )
92 |
93 | Logger.info("""
94 | The stream is available at http://localhost:8000/index.html.
95 | """)
96 |
97 | receive do
98 | {:DOWN, _ref, :process, ^supervisor, _reason} -> :ok
99 | end
100 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | compile_commands.json
2 | .gdb_history
3 | bundlex.sh
4 | bundlex.bat
5 |
6 | # Dir generated by tmp_dir ExUnit tag
7 | /tmp/
8 |
9 | # Created by https://www.gitignore.io/api/c,vim,linux,macos,elixir,windows,visualstudiocode
10 | # Edit at https://www.gitignore.io/?templates=c,vim,linux,macos,elixir,windows,visualstudiocode
11 |
12 | ### C ###
13 | # Prerequisites
14 | *.d
15 |
16 | # Object files
17 | *.o
18 | *.ko
19 | *.obj
20 | *.elf
21 |
22 | # Linker output
23 | *.ilk
24 | *.map
25 | *.exp
26 |
27 | # Precompiled Headers
28 | *.gch
29 | *.pch
30 |
31 | # Libraries
32 | *.lib
33 | *.a
34 | *.la
35 | *.lo
36 |
37 | # Shared objects (inc. Windows DLLs)
38 | *.dll
39 | *.so
40 | *.so.*
41 | *.dylib
42 |
43 | # Executables
44 | *.exe
45 | *.out
46 | *.app
47 | *.i*86
48 | *.x86_64
49 | *.hex
50 |
51 | # Debug files
52 | *.dSYM/
53 | *.su
54 | *.idb
55 | *.pdb
56 |
57 | # Kernel Module Compile Results
58 | *.mod*
59 | *.cmd
60 | .tmp_versions/
61 | modules.order
62 | Module.symvers
63 | Mkfile.old
64 | dkms.conf
65 |
66 | ### Elixir ###
67 | /_build
68 | /cover
69 | /deps
70 | /doc
71 | /.fetch
72 | erl_crash.dump
73 | *.ez
74 | *.beam
75 | /config/*.secret.exs
76 | .elixir_ls/
77 |
78 | ### Elixir Patch ###
79 |
80 | ### Linux ###
81 | *~
82 |
83 | # temporary files which can be created if a process still has a handle open of a deleted file
84 | .fuse_hidden*
85 |
86 | # KDE directory preferences
87 | .directory
88 |
89 | # Linux trash folder which might appear on any partition or disk
90 | .Trash-*
91 |
92 | # .nfs files are created when an open file is removed but is still being accessed
93 | .nfs*
94 |
95 | ### macOS ###
96 | # General
97 | .DS_Store
98 | .AppleDouble
99 | .LSOverride
100 |
101 | # Icon must end with two \r
102 | Icon
103 |
104 | # Thumbnails
105 | ._*
106 |
107 | # Files that might appear in the root of a volume
108 | .DocumentRevisions-V100
109 | .fseventsd
110 | .Spotlight-V100
111 | .TemporaryItems
112 | .Trashes
113 | .VolumeIcon.icns
114 | .com.apple.timemachine.donotpresent
115 |
116 | # Directories potentially created on remote AFP share
117 | .AppleDB
118 | .AppleDesktop
119 | Network Trash Folder
120 | Temporary Items
121 | .apdisk
122 |
123 | ### Vim ###
124 | # Swap
125 | [._]*.s[a-v][a-z]
126 | [._]*.sw[a-p]
127 | [._]s[a-rt-v][a-z]
128 | [._]ss[a-gi-z]
129 | [._]sw[a-p]
130 |
131 | # Session
132 | Session.vim
133 | Sessionx.vim
134 |
135 | # Temporary
136 | .netrwhist
137 | # Auto-generated tag files
138 | tags
139 | # Persistent undo
140 | [._]*.un~
141 |
142 | ### VisualStudioCode ###
143 | .vscode/*
144 | !.vscode/settings.json
145 | !.vscode/tasks.json
146 | !.vscode/launch.json
147 | !.vscode/extensions.json
148 |
149 | ### VisualStudioCode Patch ###
150 | # Ignore all local history of files
151 | .history
152 |
153 | ### Windows ###
154 | # Windows thumbnail cache files
155 | Thumbs.db
156 | Thumbs.db:encryptable
157 | ehthumbs.db
158 | ehthumbs_vista.db
159 |
160 | # Dump file
161 | *.stackdump
162 |
163 | # Folder config file
164 | [Dd]esktop.ini
165 |
166 | # Recycle Bin used on file shares
167 | $RECYCLE.BIN/
168 |
169 | # Windows Installer files
170 | *.cab
171 | *.msi
172 | *.msix
173 | *.msm
174 | *.msp
175 |
176 | # Windows shortcuts
177 | *.lnk
178 |
179 | # End of https://www.gitignore.io/api/c,vim,linux,macos,elixir,windows,visualstudiocode
180 |
--------------------------------------------------------------------------------
/examples/live_view/config/runtime.exs:
--------------------------------------------------------------------------------
1 | import Config
2 |
3 | # config/runtime.exs is executed for all environments, including
4 | # during releases. It is executed after compilation and before the
5 | # system starts, so it is typically used to load production configuration
6 | # and secrets from environment variables or elsewhere. Do not define
7 | # any compile-time configuration in here, as it won't be applied.
8 | # The block below contains prod specific runtime configuration.
9 |
10 | # ## Using releases
11 | #
12 | # If you use `mix release`, you need to explicitly enable the server
13 | # by passing the PHX_SERVER=true when you start it:
14 | #
15 | # PHX_SERVER=true bin/webrtc_live_view start
16 | #
17 | # Alternatively, you can use `mix phx.gen.release` to generate a `bin/server`
18 | # script that automatically sets the env var above.
19 | if System.get_env("PHX_SERVER") do
20 | config :webrtc_live_view, WebrtcLiveViewWeb.Endpoint, server: true
21 | end
22 |
23 | if config_env() == :prod do
24 | # The secret key base is used to sign/encrypt cookies and other secrets.
25 | # A default value is used in config/dev.exs and config/test.exs but you
26 | # want to use a different value for prod and you most likely don't want
27 | # to check this value into version control, so we use an environment
28 | # variable instead.
29 | secret_key_base =
30 | System.get_env("SECRET_KEY_BASE") ||
31 | raise """
32 | environment variable SECRET_KEY_BASE is missing.
33 | You can generate one by calling: mix phx.gen.secret
34 | """
35 |
36 | host = System.get_env("PHX_HOST") || "example.com"
37 | port = String.to_integer(System.get_env("PORT") || "4000")
38 |
39 | config :webrtc_live_view, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
40 |
41 | config :webrtc_live_view, WebrtcLiveViewWeb.Endpoint,
42 | url: [host: host, port: 443, scheme: "https"],
43 | http: [
44 | # Enable IPv6 and bind on all interfaces.
45 | # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access.
46 | # See the documentation on https://hexdocs.pm/bandit/Bandit.html#t:options/0
47 | # for details about using IPv6 vs IPv4 and loopback vs public addresses.
48 | ip: {0, 0, 0, 0, 0, 0, 0, 0},
49 | port: port
50 | ],
51 | secret_key_base: secret_key_base
52 |
53 | # ## SSL Support
54 | #
55 | # To get SSL working, you will need to add the `https` key
56 | # to your endpoint configuration:
57 | #
58 | # config :webrtc_live_view, WebrtcLiveViewWeb.Endpoint,
59 | # https: [
60 | # ...,
61 | # port: 443,
62 | # cipher_suite: :strong,
63 | # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"),
64 | # certfile: System.get_env("SOME_APP_SSL_CERT_PATH")
65 | # ]
66 | #
67 | # The `cipher_suite` is set to `:strong` to support only the
68 | # latest and more secure SSL ciphers. This means old browsers
69 | # and clients may not be supported. You can set it to
70 | # `:compatible` for wider support.
71 | #
72 | # `:keyfile` and `:certfile` expect an absolute path to the key
73 | # and cert in disk or a relative path inside priv, for example
74 | # "priv/ssl/server.key". For all supported SSL configuration
75 | # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1
76 | #
77 | # We also recommend setting `force_ssl` in your config/prod.exs,
78 | # ensuring no data is ever sent via http, always redirecting to https:
79 | #
80 | # config :webrtc_live_view, WebrtcLiveViewWeb.Endpoint,
81 | # force_ssl: [hsts: true]
82 | #
83 | # Check `Plug.SSL` for all available options in `force_ssl`.
84 | end
85 |
--------------------------------------------------------------------------------
/examples/phoenix_signaling/config/runtime.exs:
--------------------------------------------------------------------------------
1 | import Config
2 |
3 | # config/runtime.exs is executed for all environments, including
4 | # during releases. It is executed after compilation and before the
5 | # system starts, so it is typically used to load production configuration
6 | # and secrets from environment variables or elsewhere. Do not define
7 | # any compile-time configuration in here, as it won't be applied.
8 | # The block below contains prod specific runtime configuration.
9 |
10 | # ## Using releases
11 | #
12 | # If you use `mix release`, you need to explicitly enable the server
13 | # by passing the PHX_SERVER=true when you start it:
14 | #
15 | # PHX_SERVER=true bin/phoenix_signaling start
16 | #
17 | # Alternatively, you can use `mix phx.gen.release` to generate a `bin/server`
18 | # script that automatically sets the env var above.
19 | if System.get_env("PHX_SERVER") do
20 | config :phoenix_signaling, PhoenixSignalingWeb.Endpoint, server: true
21 | end
22 |
23 | if config_env() == :prod do
24 | # The secret key base is used to sign/encrypt cookies and other secrets.
25 | # A default value is used in config/dev.exs and config/test.exs but you
26 | # want to use a different value for prod and you most likely don't want
27 | # to check this value into version control, so we use an environment
28 | # variable instead.
29 | secret_key_base =
30 | System.get_env("SECRET_KEY_BASE") ||
31 | raise """
32 | environment variable SECRET_KEY_BASE is missing.
33 | You can generate one by calling: mix phx.gen.secret
34 | """
35 |
36 | host = System.get_env("PHX_HOST") || "example.com"
37 | port = String.to_integer(System.get_env("PORT") || "4000")
38 |
39 | config :phoenix_signaling, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
40 |
41 | config :phoenix_signaling, PhoenixSignalingWeb.Endpoint,
42 | url: [host: host, port: 443, scheme: "https"],
43 | http: [
44 | # Enable IPv6 and bind on all interfaces.
45 | # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access.
46 | # See the documentation on https://hexdocs.pm/bandit/Bandit.html#t:options/0
47 | # for details about using IPv6 vs IPv4 and loopback vs public addresses.
48 | ip: {0, 0, 0, 0, 0, 0, 0, 0},
49 | port: port
50 | ],
51 | secret_key_base: secret_key_base
52 |
53 | # ## SSL Support
54 | #
55 | # To get SSL working, you will need to add the `https` key
56 | # to your endpoint configuration:
57 | #
58 | # config :phoenix_signaling, PhoenixSignalingWeb.Endpoint,
59 | # https: [
60 | # ...,
61 | # port: 443,
62 | # cipher_suite: :strong,
63 | # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"),
64 | # certfile: System.get_env("SOME_APP_SSL_CERT_PATH")
65 | # ]
66 | #
67 | # The `cipher_suite` is set to `:strong` to support only the
68 | # latest and more secure SSL ciphers. This means old browsers
69 | # and clients may not be supported. You can set it to
70 | # `:compatible` for wider support.
71 | #
72 | # `:keyfile` and `:certfile` expect an absolute path to the key
73 | # and cert in disk or a relative path inside priv, for example
74 | # "priv/ssl/server.key". For all supported SSL configuration
75 | # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1
76 | #
77 | # We also recommend setting `force_ssl` in your config/prod.exs,
78 | # ensuring no data is ever sent via http, always redirecting to https:
79 | #
80 | # config :phoenix_signaling, PhoenixSignalingWeb.Endpoint,
81 | # force_ssl: [hsts: true]
82 | #
83 | # Check `Plug.SSL` for all available options in `force_ssl`.
84 | end
85 |
--------------------------------------------------------------------------------
/lib/membrane_webrtc/simple_websocket_server.ex:
--------------------------------------------------------------------------------
1 | defmodule Membrane.WebRTC.SimpleWebSocketServer do
2 | @moduledoc """
3 | A simple WebSocket server spawned by `Membrane.WebRTC.Source`
4 | and `Membrane.WebRTC.Sink`. It accepts a single connection
5 | and passes the messages between the client and a Membrane
6 | element.
7 |
8 | The messages sent and received by the server are JSON-encoded
9 | `t:Membrane.WebRTC.Signaling.json_data_message/0`.
10 | Additionally, the server sends a `{type: "keep_alive", data: ""}`
11 | messages to prevent the WebSocket from being closed.
12 |
13 | Examples of configuring and interacting with the server can
14 | be found in the `examples` directory.
15 | """
16 |
17 | alias Membrane.WebRTC.Signaling
18 |
19 | @typedoc """
20 | Options for the server.
21 |
22 | The port is required, while the IP address defaults to `{127, 0, 0, 1}`.
23 | """
24 | @type options :: [ip: :inet.ip_address(), port: :inet.port_number()]
25 |
26 | @doc false
27 | @spec child_spec({options, Signaling.t()}) :: Supervisor.child_spec()
28 | def child_spec({opts, signaling}) do
29 | opts = opts |> validate_options!() |> Map.new()
30 |
31 | Supervisor.child_spec(
32 | {Bandit,
33 | plug: {__MODULE__.Router, %{conn_cnt: :atomics.new(1, []), signaling: signaling}},
34 | ip: opts.ip,
35 | port: opts.port},
36 | []
37 | )
38 | end
39 |
40 | @spec validate_options!(options()) :: options() | no_return()
41 | def validate_options!(options), do: Keyword.validate!(options, [:port, ip: {127, 0, 0, 1}])
42 |
43 | @doc false
44 | @spec start_link_supervised(pid, options) :: Signaling.t()
45 | def start_link_supervised(utility_supervisor, opts) do
46 | signaling = Signaling.new()
47 |
48 | {:ok, _pid} =
49 | Membrane.UtilitySupervisor.start_link_child(
50 | utility_supervisor,
51 | {__MODULE__, {opts, signaling}}
52 | )
53 |
54 | signaling
55 | end
56 |
57 | defmodule Router do
58 | @moduledoc false
59 | use Plug.Router
60 |
61 | plug(:match)
62 | plug(:dispatch)
63 |
64 | get "/" do
65 | conn_cnt = :atomics.add_get(conn.private.conn_cnt, 1, 1)
66 |
67 | if conn_cnt == 1 do
68 | WebSockAdapter.upgrade(
69 | conn,
70 | Membrane.WebRTC.SimpleWebSocketServer.PeerHandler,
71 | %{signaling: conn.private.signaling},
72 | []
73 | )
74 | else
75 | send_resp(conn, 429, "already connected")
76 | end
77 | end
78 |
79 | match _ do
80 | send_resp(conn, 404, "not found")
81 | end
82 |
83 | @impl true
84 | def call(conn, opts) do
85 | conn
86 | |> put_private(:conn_cnt, opts.conn_cnt)
87 | |> put_private(:signaling, opts.signaling)
88 | |> super(opts)
89 | end
90 | end
91 |
92 | defmodule PeerHandler do
93 | @moduledoc false
94 |
95 | @behaviour WebSock
96 |
97 | require Logger
98 |
99 | alias Membrane.WebRTC.Signaling
100 |
101 | @impl true
102 | def init(opts) do
103 | Signaling.register_peer(opts.signaling, message_format: :json_data)
104 | Process.send_after(self(), :keep_alive, 30_000)
105 | {:ok, %{signaling: opts.signaling}}
106 | end
107 |
108 | @impl true
109 | def handle_in({message, opcode: :text}, state) do
110 | Signaling.signal(state.signaling, Jason.decode!(message))
111 | {:ok, state}
112 | end
113 |
114 | @impl true
115 | def handle_info({:membrane_webrtc_signaling, _pid, message, _metadata}, state) do
116 | {:push, {:text, Jason.encode!(message)}, state}
117 | end
118 |
119 | @impl true
120 | def handle_info(:keep_alive, state) do
121 | Process.send_after(self(), :keep_alive, 30_000)
122 | {:push, {:text, Jason.encode!(%{type: "keep_alive", data: ""})}, state}
123 | end
124 |
125 | @impl true
126 | def handle_info(message, state) do
127 | Logger.debug(
128 | "#{inspect(__MODULE__)} process ignores unsupported message #{inspect(message)}"
129 | )
130 |
131 | {:ok, state}
132 | end
133 | end
134 | end
135 |
--------------------------------------------------------------------------------
/lib/membrane_webrtc/whip_client.ex:
--------------------------------------------------------------------------------
1 | defmodule Membrane.WebRTC.WhipClient do
2 | @moduledoc """
3 | WebRTC WHIP Client.
4 |
5 | Accepts the following options:
6 | - `uri` - address of a WHIP server
7 | - `signaling` - the signaling channel - pass the same signaling channel to `Membrane.WebRTC.Sink`
8 | to connect it with the WHIP client
9 | - `token` - token to authenticate in the server, defaults to an empty string
10 | """
11 | use GenServer
12 |
13 | require Logger
14 |
15 | alias ExWebRTC.{ICECandidate, SessionDescription}
16 | alias Membrane.WebRTC.Signaling
17 |
18 | @spec start_link([
19 | {:signaling, Signaling.t()} | {:uri, String.t()} | {:token, String.t()}
20 | ]) ::
21 | {:ok, pid()}
22 | def start_link(opts) do
23 | enforce_keys = [:signaling, :uri]
24 | opts = Keyword.validate!(opts, enforce_keys ++ [token: ""]) |> Map.new()
25 | missing_keys = Enum.reject(enforce_keys, &is_map_key(opts, &1))
26 |
27 | unless missing_keys == [],
28 | do: raise(ArgumentError, "Missing option #{Enum.join(missing_keys, ", ")}")
29 |
30 | GenServer.start_link(__MODULE__, opts)
31 | end
32 |
33 | @impl true
34 | def init(opts) do
35 | Signaling.register_peer(opts.signaling)
36 | Process.monitor(opts.signaling.pid)
37 | {:ok, Map.merge(opts, %{resource_uri: nil})}
38 | end
39 |
40 | @impl true
41 | def handle_info(
42 | {:membrane_webrtc_signaling, pid, %SessionDescription{type: :offer, sdp: offer_sdp},
43 | _metadata},
44 | %{signaling: signaling} = state
45 | )
46 | when signaling.pid == pid do
47 | resp =
48 | Req.post!(state.uri,
49 | headers: [
50 | Accept: "application/sdp",
51 | "Content-Type": "application/sdp",
52 | authorization: "Bearer #{state.token}"
53 | ],
54 | body: offer_sdp
55 | )
56 |
57 | %Req.Response{status: status, body: answer_sdp} = resp
58 | unless status in 200..299, do: raise("Invalid WHIP answer response status: #{status}")
59 |
60 | resource_id =
61 | case Req.Response.get_header(resp, "location") do
62 | [resource_id] -> resource_id
63 | _other -> raise "Invalid WHEP answer location header"
64 | end
65 |
66 | %URI{} = uri = URI.parse(state.uri)
67 | resource_uri = %URI{uri | path: resource_id} |> URI.to_string()
68 |
69 | pid = self()
70 |
71 | Task.start(fn ->
72 | monitor = Process.monitor(pid)
73 |
74 | receive do
75 | {:DOWN, ^monitor, _pid, _type, _reason} -> :ok
76 | end
77 |
78 | %Req.Response{status: status} = Req.delete!(resource_uri)
79 |
80 | unless status in 200..299,
81 | do: Logger.warning("Failed to send delete request to #{resource_uri}")
82 | end)
83 |
84 | Signaling.signal(signaling, %SessionDescription{type: :answer, sdp: answer_sdp})
85 | {:noreply, %{state | resource_uri: resource_uri}}
86 | end
87 |
88 | @impl true
89 | def handle_info(
90 | {:membrane_webrtc_signaling, pid, %ICECandidate{} = candidate, _metadata},
91 | %{signaling: signaling} = state
92 | )
93 | when signaling.pid == pid do
94 | # It's not necessarily the mline that was in the SDP
95 | # but it shouldn't matter
96 | media =
97 | ExSDP.Media.new(:audio, 9, "UDP/TLS/RTP/SAVPF", 0)
98 | |> ExSDP.add_attribute({"candidate", candidate.candidate})
99 | |> ExSDP.add_attribute({:mid, candidate.sdp_mid})
100 |
101 | sdp =
102 | ExSDP.new()
103 | |> ExSDP.add_media(media)
104 | |> ExSDP.add_attribute({:ice_ufrag, candidate.username_fragment})
105 |
106 | %Req.Response{status: status} =
107 | Req.patch!(state.resource_uri,
108 | headers: ["Content-Type": "application/trickle-ice-sdpfrag"],
109 | body: to_string(sdp)
110 | )
111 |
112 | unless status in 200..299,
113 | do: Logger.error("Failed to send candindate to #{state.resource_uri}")
114 |
115 | {:noreply, state}
116 | end
117 |
118 | @impl true
119 | def handle_info(
120 | {:DOWN, _monitor, _type, pid, _reason},
121 | %{signaling: %Signaling{pid: pid}} = state
122 | ) do
123 | {:stop, :normal, state}
124 | end
125 | end
126 |
--------------------------------------------------------------------------------
/examples/phoenix_signaling/assets/js/signaling.js:
--------------------------------------------------------------------------------
1 | import { Socket } from "phoenix";
2 |
3 | async function startEgressConnection(channel, topic) {
4 | const pcConfig = { iceServers: [{ urls: "stun:stun.l.google.com:19302" }] };
5 | const mediaConstraints = { video: true, audio: true };
6 |
7 | const connStatus = document.getElementById("status");
8 | const localStream = await navigator.mediaDevices.getUserMedia(mediaConstraints);
9 | const pc = new RTCPeerConnection(pcConfig);
10 |
11 | pc.onicecandidate = (event) => {
12 | if (event.candidate === null) return;
13 | console.log("Sent ICE candidate:", event.candidate);
14 | channel.push(topic, JSON.stringify({ type: "ice_candidate", data: event.candidate }));
15 | };
16 |
17 | pc.onconnectionstatechange = () => {
18 | if (pc.connectionState == "connected") {
19 | const button = document.createElement("button");
20 | button.innerHTML = "Disconnect";
21 | button.onclick = () => {
22 | localStream.getTracks().forEach((track) => track.stop());
23 | };
24 | connStatus.innerHTML = "Connected ";
25 | connStatus.appendChild(button);
26 | }
27 | };
28 |
29 | for (const track of localStream.getTracks()) {
30 | pc.addTrack(track, localStream);
31 | }
32 |
33 | channel.on(topic, async (payload) => {
34 | type = payload.type;
35 | data = payload.data;
36 |
37 | switch (type) {
38 | case "sdp_answer":
39 | console.log("Received SDP answer:", data);
40 | await pc.setRemoteDescription(data);
41 | break;
42 | case "ice_candidate":
43 | console.log("Received ICE candidate:", data);
44 | await pc.addIceCandidate(data);
45 | break;
46 | }
47 | });
48 |
49 | const offer = await pc.createOffer();
50 | await pc.setLocalDescription(offer);
51 | console.log("Sent SDP offer:", offer);
52 | channel.push(topic, JSON.stringify({ type: "sdp_offer", data: offer }));
53 | }
54 |
55 | async function startIngressConnection(channel, topic) {
56 | videoPlayer.srcObject = new MediaStream();
57 |
58 | const pc = new RTCPeerConnection(pcConfig);
59 | pc.ontrack = (event) => videoPlayer.srcObject.addTrack(event.track);
60 | pc.onicecandidate = (event) => {
61 | if (event.candidate === null) return;
62 |
63 | console.log("Sent ICE candidate:", event.candidate);
64 | channel.push(topic, JSON.stringify({ type: "ice_candidate", data: event.candidate }));
65 | };
66 |
67 | channel.on(topic, async (payload) => {
68 | type = payload.type;
69 | data = payload.data;
70 |
71 | switch (type) {
72 | case "sdp_offer":
73 | console.log("Received SDP offer:", data);
74 | await pc.setRemoteDescription(data);
75 | const answer = await pc.createAnswer();
76 | await pc.setLocalDescription(answer);
77 | channel.push(topic, JSON.stringify({ type: "sdp_answer", data: answer }));
78 | console.log("Sent SDP answer:", answer);
79 | break;
80 | case "ice_candidate":
81 | console.log("Received ICE candidate:", data);
82 | await pc.addIceCandidate(data);
83 | }
84 | });
85 | }
86 | const videoPlayer = document.getElementById("videoPlayer");
87 | const signalingId = videoPlayer.getAttribute("signaling_id");
88 |
89 | let socket = new Socket("/signaling", { params: { token: window.userToken } });
90 | socket.connect();
91 | let egressChannel = socket.channel(`${signalingId}_egress`);
92 | egressChannel
93 | .join()
94 | .receive("ok", (resp) => {
95 | console.log("Joined successfully to egress signaling socket", resp);
96 | startEgressConnection(egressChannel, `${signalingId}_egress`);
97 | })
98 | .receive("error", (resp) => {
99 | console.log("Unable to join egress signaling socket", resp);
100 | });
101 |
102 | const pcConfig = { iceServers: [{ urls: "stun:stun.l.google.com:19302" }] };
103 |
104 | let ingressChannel = socket.channel(`${signalingId}_ingress`);
105 | ingressChannel
106 | .join()
107 | .receive("ok", (resp) => {
108 | console.log("Joined successfully to ingress signaling socket", resp);
109 | startIngressConnection(ingressChannel, `${signalingId}_ingress`);
110 | })
111 | .receive("error", (resp) => {
112 | console.log("Unable to join ingress signaling socket", resp);
113 | });
114 |
--------------------------------------------------------------------------------
/examples/live_view/assets/vendor/topbar.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @license MIT
3 | * topbar 2.0.0, 2023-02-04
4 | * https://buunguyen.github.io/topbar
5 | * Copyright (c) 2021 Buu Nguyen
6 | */
7 | (function (window, document) {
8 | "use strict";
9 |
10 | // https://gist.github.com/paulirish/1579671
11 | (function () {
12 | var lastTime = 0;
13 | var vendors = ["ms", "moz", "webkit", "o"];
14 | for (var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) {
15 | window.requestAnimationFrame =
16 | window[vendors[x] + "RequestAnimationFrame"];
17 | window.cancelAnimationFrame =
18 | window[vendors[x] + "CancelAnimationFrame"] ||
19 | window[vendors[x] + "CancelRequestAnimationFrame"];
20 | }
21 | if (!window.requestAnimationFrame)
22 | window.requestAnimationFrame = function (callback, element) {
23 | var currTime = new Date().getTime();
24 | var timeToCall = Math.max(0, 16 - (currTime - lastTime));
25 | var id = window.setTimeout(function () {
26 | callback(currTime + timeToCall);
27 | }, timeToCall);
28 | lastTime = currTime + timeToCall;
29 | return id;
30 | };
31 | if (!window.cancelAnimationFrame)
32 | window.cancelAnimationFrame = function (id) {
33 | clearTimeout(id);
34 | };
35 | })();
36 |
37 | var canvas,
38 | currentProgress,
39 | showing,
40 | progressTimerId = null,
41 | fadeTimerId = null,
42 | delayTimerId = null,
43 | addEvent = function (elem, type, handler) {
44 | if (elem.addEventListener) elem.addEventListener(type, handler, false);
45 | else if (elem.attachEvent) elem.attachEvent("on" + type, handler);
46 | else elem["on" + type] = handler;
47 | },
48 | options = {
49 | autoRun: true,
50 | barThickness: 3,
51 | barColors: {
52 | 0: "rgba(26, 188, 156, .9)",
53 | ".25": "rgba(52, 152, 219, .9)",
54 | ".50": "rgba(241, 196, 15, .9)",
55 | ".75": "rgba(230, 126, 34, .9)",
56 | "1.0": "rgba(211, 84, 0, .9)",
57 | },
58 | shadowBlur: 10,
59 | shadowColor: "rgba(0, 0, 0, .6)",
60 | className: null,
61 | },
62 | repaint = function () {
63 | canvas.width = window.innerWidth;
64 | canvas.height = options.barThickness * 5; // need space for shadow
65 |
66 | var ctx = canvas.getContext("2d");
67 | ctx.shadowBlur = options.shadowBlur;
68 | ctx.shadowColor = options.shadowColor;
69 |
70 | var lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0);
71 | for (var stop in options.barColors)
72 | lineGradient.addColorStop(stop, options.barColors[stop]);
73 | ctx.lineWidth = options.barThickness;
74 | ctx.beginPath();
75 | ctx.moveTo(0, options.barThickness / 2);
76 | ctx.lineTo(
77 | Math.ceil(currentProgress * canvas.width),
78 | options.barThickness / 2
79 | );
80 | ctx.strokeStyle = lineGradient;
81 | ctx.stroke();
82 | },
83 | createCanvas = function () {
84 | canvas = document.createElement("canvas");
85 | var style = canvas.style;
86 | style.position = "fixed";
87 | style.top = style.left = style.right = style.margin = style.padding = 0;
88 | style.zIndex = 100001;
89 | style.display = "none";
90 | if (options.className) canvas.classList.add(options.className);
91 | document.body.appendChild(canvas);
92 | addEvent(window, "resize", repaint);
93 | },
94 | topbar = {
95 | config: function (opts) {
96 | for (var key in opts)
97 | if (options.hasOwnProperty(key)) options[key] = opts[key];
98 | },
99 | show: function (delay) {
100 | if (showing) return;
101 | if (delay) {
102 | if (delayTimerId) return;
103 | delayTimerId = setTimeout(() => topbar.show(), delay);
104 | } else {
105 | showing = true;
106 | if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId);
107 | if (!canvas) createCanvas();
108 | canvas.style.opacity = 1;
109 | canvas.style.display = "block";
110 | topbar.progress(0);
111 | if (options.autoRun) {
112 | (function loop() {
113 | progressTimerId = window.requestAnimationFrame(loop);
114 | topbar.progress(
115 | "+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2)
116 | );
117 | })();
118 | }
119 | }
120 | },
121 | progress: function (to) {
122 | if (typeof to === "undefined") return currentProgress;
123 | if (typeof to === "string") {
124 | to =
125 | (to.indexOf("+") >= 0 || to.indexOf("-") >= 0
126 | ? currentProgress
127 | : 0) + parseFloat(to);
128 | }
129 | currentProgress = to > 1 ? 1 : to;
130 | repaint();
131 | return currentProgress;
132 | },
133 | hide: function () {
134 | clearTimeout(delayTimerId);
135 | delayTimerId = null;
136 | if (!showing) return;
137 | showing = false;
138 | if (progressTimerId != null) {
139 | window.cancelAnimationFrame(progressTimerId);
140 | progressTimerId = null;
141 | }
142 | (function loop() {
143 | if (topbar.progress("+.1") >= 1) {
144 | canvas.style.opacity -= 0.05;
145 | if (canvas.style.opacity <= 0.05) {
146 | canvas.style.display = "none";
147 | fadeTimerId = null;
148 | return;
149 | }
150 | }
151 | fadeTimerId = window.requestAnimationFrame(loop);
152 | })();
153 | },
154 | };
155 |
156 | if (typeof module === "object" && typeof module.exports === "object") {
157 | module.exports = topbar;
158 | } else if (typeof define === "function" && define.amd) {
159 | define(function () {
160 | return topbar;
161 | });
162 | } else {
163 | this.topbar = topbar;
164 | }
165 | }.call(this, window, document));
166 |
--------------------------------------------------------------------------------
/examples/phoenix_signaling/assets/vendor/topbar.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @license MIT
3 | * topbar 2.0.0, 2023-02-04
4 | * https://buunguyen.github.io/topbar
5 | * Copyright (c) 2021 Buu Nguyen
6 | */
7 | (function (window, document) {
8 | "use strict";
9 |
10 | // https://gist.github.com/paulirish/1579671
11 | (function () {
12 | var lastTime = 0;
13 | var vendors = ["ms", "moz", "webkit", "o"];
14 | for (var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) {
15 | window.requestAnimationFrame =
16 | window[vendors[x] + "RequestAnimationFrame"];
17 | window.cancelAnimationFrame =
18 | window[vendors[x] + "CancelAnimationFrame"] ||
19 | window[vendors[x] + "CancelRequestAnimationFrame"];
20 | }
21 | if (!window.requestAnimationFrame)
22 | window.requestAnimationFrame = function (callback, element) {
23 | var currTime = new Date().getTime();
24 | var timeToCall = Math.max(0, 16 - (currTime - lastTime));
25 | var id = window.setTimeout(function () {
26 | callback(currTime + timeToCall);
27 | }, timeToCall);
28 | lastTime = currTime + timeToCall;
29 | return id;
30 | };
31 | if (!window.cancelAnimationFrame)
32 | window.cancelAnimationFrame = function (id) {
33 | clearTimeout(id);
34 | };
35 | })();
36 |
37 | var canvas,
38 | currentProgress,
39 | showing,
40 | progressTimerId = null,
41 | fadeTimerId = null,
42 | delayTimerId = null,
43 | addEvent = function (elem, type, handler) {
44 | if (elem.addEventListener) elem.addEventListener(type, handler, false);
45 | else if (elem.attachEvent) elem.attachEvent("on" + type, handler);
46 | else elem["on" + type] = handler;
47 | },
48 | options = {
49 | autoRun: true,
50 | barThickness: 3,
51 | barColors: {
52 | 0: "rgba(26, 188, 156, .9)",
53 | ".25": "rgba(52, 152, 219, .9)",
54 | ".50": "rgba(241, 196, 15, .9)",
55 | ".75": "rgba(230, 126, 34, .9)",
56 | "1.0": "rgba(211, 84, 0, .9)",
57 | },
58 | shadowBlur: 10,
59 | shadowColor: "rgba(0, 0, 0, .6)",
60 | className: null,
61 | },
62 | repaint = function () {
63 | canvas.width = window.innerWidth;
64 | canvas.height = options.barThickness * 5; // need space for shadow
65 |
66 | var ctx = canvas.getContext("2d");
67 | ctx.shadowBlur = options.shadowBlur;
68 | ctx.shadowColor = options.shadowColor;
69 |
70 | var lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0);
71 | for (var stop in options.barColors)
72 | lineGradient.addColorStop(stop, options.barColors[stop]);
73 | ctx.lineWidth = options.barThickness;
74 | ctx.beginPath();
75 | ctx.moveTo(0, options.barThickness / 2);
76 | ctx.lineTo(
77 | Math.ceil(currentProgress * canvas.width),
78 | options.barThickness / 2
79 | );
80 | ctx.strokeStyle = lineGradient;
81 | ctx.stroke();
82 | },
83 | createCanvas = function () {
84 | canvas = document.createElement("canvas");
85 | var style = canvas.style;
86 | style.position = "fixed";
87 | style.top = style.left = style.right = style.margin = style.padding = 0;
88 | style.zIndex = 100001;
89 | style.display = "none";
90 | if (options.className) canvas.classList.add(options.className);
91 | document.body.appendChild(canvas);
92 | addEvent(window, "resize", repaint);
93 | },
94 | topbar = {
95 | config: function (opts) {
96 | for (var key in opts)
97 | if (options.hasOwnProperty(key)) options[key] = opts[key];
98 | },
99 | show: function (delay) {
100 | if (showing) return;
101 | if (delay) {
102 | if (delayTimerId) return;
103 | delayTimerId = setTimeout(() => topbar.show(), delay);
104 | } else {
105 | showing = true;
106 | if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId);
107 | if (!canvas) createCanvas();
108 | canvas.style.opacity = 1;
109 | canvas.style.display = "block";
110 | topbar.progress(0);
111 | if (options.autoRun) {
112 | (function loop() {
113 | progressTimerId = window.requestAnimationFrame(loop);
114 | topbar.progress(
115 | "+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2)
116 | );
117 | })();
118 | }
119 | }
120 | },
121 | progress: function (to) {
122 | if (typeof to === "undefined") return currentProgress;
123 | if (typeof to === "string") {
124 | to =
125 | (to.indexOf("+") >= 0 || to.indexOf("-") >= 0
126 | ? currentProgress
127 | : 0) + parseFloat(to);
128 | }
129 | currentProgress = to > 1 ? 1 : to;
130 | repaint();
131 | return currentProgress;
132 | },
133 | hide: function () {
134 | clearTimeout(delayTimerId);
135 | delayTimerId = null;
136 | if (!showing) return;
137 | showing = false;
138 | if (progressTimerId != null) {
139 | window.cancelAnimationFrame(progressTimerId);
140 | progressTimerId = null;
141 | }
142 | (function loop() {
143 | if (topbar.progress("+.1") >= 1) {
144 | canvas.style.opacity -= 0.05;
145 | if (canvas.style.opacity <= 0.05) {
146 | canvas.style.display = "none";
147 | fadeTimerId = null;
148 | return;
149 | }
150 | }
151 | fadeTimerId = window.requestAnimationFrame(loop);
152 | })();
153 | },
154 | };
155 |
156 | if (typeof module === "object" && typeof module.exports === "object") {
157 | module.exports = topbar;
158 | } else if (typeof define === "function" && define.amd) {
159 | define(function () {
160 | return topbar;
161 | });
162 | } else {
163 | this.topbar = topbar;
164 | }
165 | }.call(this, window, document));
166 |
--------------------------------------------------------------------------------
/lib/membrane_webrtc/live/player.ex:
--------------------------------------------------------------------------------
1 | if Code.ensure_loaded?(Phoenix) and Code.ensure_loaded?(Phoenix.LiveView) do
2 | defmodule Membrane.WebRTC.Live.Player do
3 | @moduledoc ~S'''
4 | LiveView for playing audio and video received via WebRTC from `Membrane.WebRTC.Sink`.
5 |
6 | *Note:* This module will be available in your code only if you add `{:phoenix, "~> 1.7"}`
7 | and `{:phoenix_live_view, "~> 1.0"}` to the dependencies of of your root project.
8 |
9 | It:
10 | * renders a single HTMLVideoElement.
11 | * creates WebRTC PeerConnection on the browser side.
12 | * forwards signaling messages between the browser and `Membrane.WebRTC.Sink` via `Membrane.WebRTC.Signaling`.
13 | * attaches audio and video from the Elixir to the HTMLVideoElement.
14 |
15 | ## JavaScript Hook
16 |
17 | Player live view requires JavaScript hook to be registered under `Player` name.
18 | The hook can be created using `createPlayerHook` function.
19 | For example:
20 |
21 | ```javascript
22 | import { createPlayerHook } from "membrane_webrtc_plugin";
23 | let Hooks = {};
24 | const iceServers = [{ urls: "stun:stun.l.google.com:19302" }];
25 | Hooks.Player = createPlayerHook(iceServers);
26 | let liveSocket = new LiveSocket("/live", Socket, {
27 | // ...
28 | hooks: Hooks
29 | });
30 | ```
31 |
32 | ## Examples
33 |
34 | ```elixir
35 | defmodule StreamerWeb.StreamViewerLive do
36 | use StreamerWeb, :live_view
37 |
38 | alias Membrane.WebRTC.Live.Player
39 |
40 | @impl true
41 | def render(assigns) do
42 | ~H"""
43 |
44 | """
45 | end
46 |
47 | @impl true
48 | def mount(_params, _session, socket) do
49 | signaling = Membrane.WebRTC.Signaling.new()
50 | {:ok, _supervisor, _pipelne} = Membrane.Pipeline.start_link(MyPipeline, signaling: signaling)
51 |
52 | socket = socket |> Player.attach(id: "player", signaling: signaling)
53 | {:ok, socket}
54 | end
55 | end
56 | ```
57 | '''
58 | use Phoenix.LiveView
59 | require Logger
60 |
61 | alias Membrane.WebRTC.Signaling
62 |
63 | @type t() :: %__MODULE__{
64 | id: String.t(),
65 | signaling: Signaling.t()
66 | }
67 |
68 | defstruct id: nil, signaling: nil
69 |
70 | attr(:socket, Phoenix.LiveView.Socket, required: true, doc: "Parent live view socket")
71 |
72 | attr(:player_id, :string,
73 | required: true,
74 | doc: """
75 | ID of a `player` previously attached to the socket. It has to be the same as the value passed to `:id`
76 | field `#{inspect(__MODULE__)}.attach/2`.
77 | """
78 | )
79 |
80 | attr(:class, :string, default: nil, doc: "CSS/Tailwind classes for styling")
81 |
82 | @doc """
83 | Helper function for rendering Player live view.
84 | """
85 | def live_render(assigns) do
86 | ~H"""
87 | <%= live_render(@socket, __MODULE__, id: "#{@player_id}-lv", session: %{"class" => @class, "id" => @player_id}) %>
88 | """
89 | end
90 |
91 | @doc """
92 | Attaches required hooks and creates `#{inspect(__MODULE__)}` struct.
93 |
94 | Created struct is saved in socket's assigns (in `socket.assigns[#{inspect(__MODULE__)}][id]`) and then
95 | it is sent by an attached hook to a child live view process.
96 |
97 | Options:
98 | * `id` - player id. It is used to identify live view and generated HTML video player. It must be unique
99 | withing single page.
100 | * `signaling` - `Membrane.WebRTC.Signaling.t()`, that has been passed to `Membrane.WebRTC.Sink` as well.
101 | """
102 | @spec attach(Phoenix.LiveView.Socket.t(), Keyword.t()) :: Phoenix.LiveView.Socket.t()
103 | def attach(socket, opts) do
104 | opts = opts |> Keyword.validate!([:id, :signaling])
105 | player = struct!(__MODULE__, opts)
106 |
107 | all_players =
108 | socket.assigns
109 | |> Map.get(__MODULE__, %{})
110 | |> Map.put(player.id, player)
111 |
112 | socket
113 | |> assign(__MODULE__, all_players)
114 | |> detach_hook(:player_handshake, :handle_info)
115 | |> attach_hook(:player_handshake, :handle_info, &parent_handshake/2)
116 | end
117 |
118 | @spec get_attached(Phoenix.LiveView.Socket.t(), String.t()) :: t()
119 | def get_attached(socket, id), do: socket.assigns[__MODULE__][id]
120 |
121 | ## CALLBACKS
122 |
123 | @impl true
124 | def render(%{player: nil} = assigns) do
125 | ~H"""
126 | """
127 | end
128 |
129 | @impl true
130 | def render(assigns) do
131 | ~H"""
132 |
133 | """
134 | end
135 |
136 | @impl true
137 | def mount(_params, %{"class" => class, "id" => id}, socket) do
138 | socket = socket |> assign(class: class, player: nil)
139 |
140 | socket =
141 | if connected?(socket),
142 | do: client_handshake(socket, id),
143 | else: socket
144 |
145 | {:ok, socket}
146 | end
147 |
148 | defp parent_handshake({__MODULE__, {:connected, id, player_pid}}, socket) do
149 | player_struct =
150 | socket.assigns
151 | |> Map.fetch!(__MODULE__)
152 | |> Map.fetch!(id)
153 |
154 | send(player_pid, player_struct)
155 |
156 | {:halt, socket}
157 | end
158 |
159 | defp parent_handshake(_msg, socket) do
160 | {:cont, socket}
161 | end
162 |
163 | defp client_handshake(socket, id) do
164 | send(socket.parent_pid, {__MODULE__, {:connected, id, self()}})
165 |
166 | receive do
167 | %__MODULE__{} = player ->
168 | player.signaling
169 | |> Signaling.register_peer(message_format: :json_data)
170 |
171 | socket |> assign(player: player)
172 | after
173 | 5000 -> exit(:timeout)
174 | end
175 | end
176 |
177 | @impl true
178 | def handle_info({:membrane_webrtc_signaling, _pid, message, _metadata}, socket) do
179 | Logger.debug("""
180 | #{log_prefix(socket.assigns.player.id)} Sent WebRTC signaling message: #{inspect(message, pretty: true)}
181 | """)
182 |
183 | {:noreply,
184 | socket
185 | |> push_event("webrtc_signaling-#{socket.assigns.player.id}", message)}
186 | end
187 |
188 | @impl true
189 | def handle_event("webrtc_signaling", message, socket) do
190 | Logger.debug("""
191 | #{log_prefix(socket.assigns.player.id)} Received WebRTC signaling message: #{inspect(message, pretty: true)}
192 | """)
193 |
194 | if message["data"] do
195 | socket.assigns.player.signaling
196 | |> Signaling.signal(message)
197 | end
198 |
199 | {:noreply, socket}
200 | end
201 |
202 | defp log_prefix(id), do: [module: __MODULE__, id: id] |> inspect()
203 | end
204 | end
205 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Membrane WebRTC Plugin
2 |
3 | [](https://hex.pm/packages/membrane_webrtc_plugin)
4 | [](https://hexdocs.pm/membrane_webrtc_plugin)
5 | [](https://circleci.com/gh/membraneframework/membrane_webrtc_plugin)
6 |
7 | Membrane Plugin for sending and receiving streams via WebRTC. It's based on [ex_webrtc](https://github.com/elixir-webrtc/ex_webrtc).
8 |
9 | It's a part of the [Membrane Framework](https://membrane.stream).
10 |
11 | ## Installation
12 |
13 | The package can be installed by adding `membrane_webrtc_plugin` to your list of dependencies in `mix.exs`:
14 |
15 | ```elixir
16 | def deps do
17 | [
18 | {:membrane_webrtc_plugin, "~> 0.26.1"}
19 | ]
20 | end
21 | ```
22 |
23 | ## Demos
24 |
25 | The `examples` directory shows how to send and receive streams from a web browser.
26 | There are the following three demos:
27 | * `live_view` - a simple Phoenix LiveView project using `Membrane.WebRTC.Live.Player` and `Membrane.WebRTC.Live.Capture` to echo video stream
28 | captured from the user's browser.
29 | * `phoenix_signaling` - showcasing simple Phoenix application that uses `Membrane.WebRTC.PhoenixSignaling` to echo stream captured
30 | from the user's browser and sent via WebRTC. See `assets/phoenix_signaling/README.md` for details on how to run the demo.
31 | * `webrtc_signaling` - it consists of two scripts: `file_to_browser.exs` and `browser_to_file.exs`. The first one displays the stream from
32 | the fixture file in the user's browser. The latter captures the user's camera input from the browser and saves it in the file.
33 | To run one of these demos, type: `elixir ` and visit `http://localhost:4000`.
34 |
35 | ## Exchanging Signaling Messages
36 |
37 | To establish a WebRTC connection you have to exchange WebRTC signaling messages between peers.
38 | In `membrane_webrtc_plugin` it can be done by the user, with `Membrane.WebRTC.Signaling` or by passing WebSocket address to
39 | `Membrane.WebRTC.Source` or `Membrane.WebRTC.Sink`, but there are two additional ways of doing it, dedicated to be used within
40 | `Phoenix` projects:
41 | - The first one is to use `Membrane.WebRTC.PhoenixSignaling` along with `Membrane.WebRTC.PhoenixSignaling.Socket`
42 | - The second one is to use `Phoenix.LiveView` `Membrane.WebRTC.Live.Player` or `Membrane.WebRTC.Live.Capture`. These modules expect
43 | `t:Membrane.WebRTC.Signaling.t/0` as an argument and take advantage of WebSocket used by `Phoenix.LiveView` to exchange WebRTC
44 | signaling messages, so there is no need to add any code to handle signaling messages.
45 |
46 | ### How to use Membrane.WebRTC.PhoenixSignaling in your own Phoenix project?
47 |
48 | The see the full example, visit `example/phoenix_signaling`.
49 |
50 | 1. Create a new socket in your application endpoint, using the `Membrane.WebRTC.PhoenixSignaling.Socket`, for instance at `/signaling` path:
51 | ```
52 | socket "/signaling", Membrane.WebRTC.PhoenixSignaling.Socket,
53 | websocket: true,
54 | longpoll: false
55 | ```
56 | 2. Create a Phoenix signaling channel with the desired signaling ID and use it as `Membrane.WebRTC.Signaling.t()`
57 | for `Membrane.WebRTC.Source`, `Membrane.WebRTC.Sink` or [`Boombox`](https://github.com/membraneframework/boombox):
58 | ```
59 | signaling = Membrane.WebRTC.PhoenixSignaling.new("")
60 |
61 | # use it with Membrane.WebRTC.Source:
62 | child(:webrtc_source, %Membrane.WebRTC.Source{signaling: signaling})
63 | |> ...
64 |
65 | # or with Membrane.WebRTC.Sink:
66 | ...
67 | |> child(:webrtc_sink, %Membrane.WebRTC.Sink{signaling: signaling})
68 |
69 | # or with Boombox:
70 | Boombox.run(
71 | input: {:webrtc, signaling},
72 | output: ...
73 | )
74 | ```
75 |
76 | >Please note that `signaling_id` is expected to be globally unique for each WebRTC connection about to be
77 | >estabilished. You can, for instance:
78 | >1. Generate a unique id with `:uuid` package and assign it to the connection in the page controller:
79 | >```
80 | >unique_id = UUID.uuid4()
81 | >render(conn, :home, layout: false, signaling_id: unique_id)
82 | >```
83 | >
84 | >2. Generate HTML based on HEEx template, using the previously set assign:
85 | >```
86 | >
87 | >```
88 | >
89 | >3. Access it in your client code:
90 | >```
91 | >const videoPlayer = document.getElementById('videoPlayer');
92 | >const signalingId = videoPlayer.getAttribute('signaling_id');
93 | >```
94 |
95 |
96 | 3. Use the Phoenix Socket to exchange WebRTC signaling data.
97 | ```
98 | let socket = new Socket("/signaling", {params: {token: window.userToken}})
99 | socket.connect()
100 | let channel = socket.channel('')
101 | channel.join()
102 | .receive("ok", resp => { console.log("Signaling socket joined successfully", resp)
103 | // here you can exchange WebRTC data
104 | })
105 | .receive("error", resp => { console.log("Unable to join signaling socket", resp) })
106 | ```
107 |
108 | Visit `examples/phoenix_signaling/assets/js/signaling.js` to see how WebRTC signaling messages exchange might look like.
109 |
110 | ## Integrating Phoenix.LiveView with Membrane WebRTC Plugin
111 |
112 | `membrane_webrtc_plugin` comes with two `Phoenix.LiveView`s:
113 | - `Membrane.WebRTC.Live.Capture` - exchanges WebRTC signaling messages between `Membrane.WebRTC.Source` and the browser. It
114 | expects the same `Membrane.WebRTC.Signaling` that has been passed to the related `Membrane.WebRTC.Source`. As a result,
115 | `Membrane.Webrtc.Source` will return the media stream captured from the browser, where `Membrane.WebRTC.Live.Capture` has been
116 | rendered.
117 | - `Membrane.WebRTC.Live.Player` - exchanges WebRTC signaling messages between `Membrane.WebRTC.Sink` and the browser. It
118 | expects the same `Membrane.WebRTC.Signaling` that has been passed to the related `Membrane.WebRTC.Sink`. As a result,
119 | `Membrane.WebRTC.Live.Player` will play media streams passed to the related `Membrane.WebRTC.Sink`. Currently supports up
120 | to one video stream and up to one audio stream.
121 |
122 | ### Usage
123 |
124 | To use `Phoenix.LiveView`s from this repository, you have to use related JS hooks. To do so, add the following code snippet to `assets/js/app.js`
125 |
126 | ```js
127 | import { createCaptureHook, createPlayerHook } from "membrane_webrtc_plugin";
128 |
129 | let Hooks = {};
130 | const iceServers = [{ urls: "stun:stun.l.google.com:19302" }];
131 | Hooks.Capture = createCaptureHook(iceServers);
132 | Hooks.Player = createPlayerHook(iceServers);
133 | ```
134 |
135 | and add `Hooks` to the WebSocket constructor. It can be done in the following way:
136 |
137 | ```js
138 | new LiveSocket("/live", Socket, {
139 | params: SomeParams,
140 | hooks: Hooks,
141 | });
142 | ```
143 |
144 | To see the full usage example, you can go to `examples/live_view/` directory in this repository (take a look especially at `examples/live_view/assets/js/app.js` and `examples/live_view/lib/example_project_web/live_views/echo.ex`).
145 |
146 | ## Copyright and License
147 |
148 | Copyright 2020, [Software Mansion](https://swmansion.com/?utm_source=git&utm_medium=readme&utm_campaign=membrane_webrtc_plugin)
149 |
150 | [](https://swmansion.com/?utm_source=git&utm_medium=readme&utm_campaign=membrane_webrtc_plugin)
151 |
152 | Licensed under the [Apache License, Version 2.0](LICENSE)
153 |
--------------------------------------------------------------------------------
/lib/membrane_webrtc/signaling.ex:
--------------------------------------------------------------------------------
1 | defmodule Membrane.WebRTC.Signaling do
2 | @moduledoc """
3 | Signaling channel for sending WebRTC signaling messages between Membrane elements
4 | and other WebRTC peers.
5 |
6 | The flow of using the signaling channel is the following:
7 | - Create it with `new/0`.
8 | - Register the peer process (the one to send and receive signaling messages)
9 | with `register_peer/2`.
10 | - Pass the signaling to `Membrane.WebRTC.Source` or `Membrane.WebRTC.Sink` (this
11 | can also be done before the call to `register_peer/2`).
12 | - Send and receive signaling messages. Messages can be sent by calling `signal/2`.
13 | The signaling channel sends `t:message/0` to the peer.
14 | """
15 | use GenServer
16 |
17 | require Logger
18 |
19 | alias ExWebRTC.{ICECandidate, SessionDescription}
20 |
21 | @enforce_keys [:pid]
22 | defstruct @enforce_keys
23 |
24 | @type t :: %__MODULE__{pid: pid()}
25 |
26 | @typedoc """
27 | Messages sent by the signaling channel to the peer.
28 | """
29 | @type message :: {:membrane_webrtc_signaling, pid(), message_content, metadata :: map}
30 |
31 | @typedoc """
32 | Messages that the peer sends with `signal/2` and receives in `t:message/0`.
33 |
34 | If the `message_format` of the peer is `ex_webrtc` (default), they should be
35 | `t:ex_webrtc_message/0`.
36 | If the `message_format` is `json_data`, they should be `t:json_data_message/0`.
37 |
38 | The `message_format` of the peer can be set in `register_peer/2`.
39 | """
40 | @type message_content :: ex_webrtc_message | json_data_message
41 |
42 | @typedoc """
43 | Messages sent and received if `message_format` is `ex_webrtc`.
44 | """
45 | @type ex_webrtc_message :: ICECandidate.t() | SessionDescription.t()
46 |
47 | @typedoc """
48 | Messages sent and received if `message_format` is `json_data`.
49 |
50 | The keys and values are the following
51 | - `%{"type" => "sdp_offer", "data" => data}`, where data is the return value of
52 | `ExWebRTC.SessionDescription.to_json/1` or `RTCPeerConnection.create_offer` in the JavaScript API
53 | - `%{"type" => "sdp_answer", "data" => data}`, where data is the return value of
54 | `ExWebRTC.SessionDescription.to_json/1` or `RTCPeerConnection.create_answer` in the JavaScript API
55 | - `%{"type" => "ice_candidate", "data" => data}`, where data is the return value of
56 | `ExWebRTC.ICECandidate.to_json/1` or `event.candidate` from the `RTCPeerConnection.onicecandidate`
57 | callback in the JavaScript API.
58 | """
59 | @type json_data_message :: %{String.t() => term}
60 |
61 | @spec new() :: t
62 | def new() do
63 | {:ok, pid} = GenServer.start_link(__MODULE__, [])
64 | %__MODULE__{pid: pid}
65 | end
66 |
67 | @doc """
68 | Registers a process as a peer, so that it can send and receive signaling messages.
69 |
70 | Options:
71 | - `pid` - pid of the peer, `self()` by default
72 | - `message_format` - `:ex_webrtc` by default, see `t:message_content/0`
73 |
74 | See the moduledoc for details.
75 | """
76 | @spec register_peer(t, message_format: :ex_webrtc | :json_data, pid: pid) :: :ok
77 | def register_peer(%__MODULE__{pid: pid}, opts \\ []) do
78 | opts =
79 | opts
80 | |> Keyword.validate!(message_format: :ex_webrtc, pid: self())
81 | |> Map.new()
82 | |> Map.put(:is_element, false)
83 |
84 | GenServer.call(pid, {:register_peer, opts})
85 | end
86 |
87 | @doc false
88 | @spec register_element(t) :: :ok
89 | def register_element(%__MODULE__{pid: pid}) do
90 | GenServer.call(
91 | pid,
92 | {:register_peer, %{pid: self(), message_format: :ex_webrtc, is_element: true}}
93 | )
94 | end
95 |
96 | @doc """
97 | Sends a signaling message to the signaling channel.
98 |
99 | The calling process must be previously registered with `register_peer/2`.
100 | See the moduledoc for details.
101 | """
102 | @spec signal(t, message_content, metadata :: map) :: :ok
103 | def signal(%__MODULE__{pid: pid}, message, metadata \\ %{}) do
104 | send(pid, {:signal, self(), message, metadata})
105 | :ok
106 | end
107 |
108 | @spec close(t) :: :ok
109 | def close(%__MODULE__{pid: pid}) do
110 | GenServer.stop(pid)
111 | end
112 |
113 | @impl true
114 | def init(_opts) do
115 | state = %{
116 | peer_a: nil,
117 | peer_b: nil,
118 | message_queue: []
119 | }
120 |
121 | {:ok, state}
122 | end
123 |
124 | @impl true
125 | def handle_call({:register_peer, peer}, _from, state) do
126 | Process.monitor(peer.pid)
127 |
128 | case state do
129 | %{peer_a: nil} ->
130 | {:reply, :ok, %{state | peer_a: peer}}
131 |
132 | %{peer_b: nil, message_queue: queue} ->
133 | state = %{state | peer_b: peer}
134 |
135 | queue
136 | |> Enum.reverse()
137 | |> Enum.each(fn {message, metadata} ->
138 | send_peer(state.peer_a, state.peer_b, message, metadata)
139 | end)
140 |
141 | {:reply, :ok, %{state | message_queue: []}}
142 |
143 | state ->
144 | raise """
145 | Cannot register a peer, both peers already registered: \
146 | #{inspect(state.peer_a.pid)}, #{inspect(state.peer_b.pid)}
147 | """
148 | end
149 | end
150 |
151 | @impl true
152 | def handle_info({:signal, _from_pid, message, metadata}, %{peer_b: nil} = state) do
153 | {:noreply, %{state | message_queue: [{message, metadata} | state.message_queue]}}
154 | end
155 |
156 | @impl true
157 | def handle_info({:signal, from_pid, message, metadata}, state) do
158 | {from_peer, to_peer} = get_peers(from_pid, state)
159 | send_peer(from_peer, to_peer, message, metadata)
160 | {:noreply, state}
161 | end
162 |
163 | @impl true
164 | def handle_info({:DOWN, _monitor, :process, pid, reason}, state) do
165 | {peer, _other_peer} = get_peers(pid, state)
166 | reason = if peer.is_element, do: reason, else: :normal
167 | {:stop, reason, state}
168 | end
169 |
170 | defp get_peers(pid, state) do
171 | case state do
172 | %{peer_a: %{pid: ^pid} = peer_a, peer_b: peer_b} -> {peer_a, peer_b}
173 | %{peer_a: peer_a, peer_b: %{pid: ^pid} = peer_b} -> {peer_b, peer_a}
174 | end
175 | end
176 |
177 | defp send_peer(
178 | %{message_format: format},
179 | %{message_format: format, pid: pid},
180 | message,
181 | metadata
182 | ) do
183 | send(pid, {:membrane_webrtc_signaling, self(), message, metadata})
184 | end
185 |
186 | defp send_peer(
187 | %{message_format: :ex_webrtc},
188 | %{message_format: :json_data, pid: pid},
189 | message,
190 | metadata
191 | ) do
192 | json_data =
193 | case message do
194 | %ICECandidate{} ->
195 | %{"type" => "ice_candidate", "data" => ICECandidate.to_json(message)}
196 |
197 | %SessionDescription{type: type} ->
198 | %{"type" => "sdp_#{type}", "data" => SessionDescription.to_json(message)}
199 | end
200 |
201 | send(pid, {:membrane_webrtc_signaling, self(), json_data, metadata})
202 | end
203 |
204 | defp send_peer(
205 | %{message_format: :json_data},
206 | %{message_format: :ex_webrtc, pid: pid},
207 | message,
208 | metadata
209 | ) do
210 | message =
211 | case message do
212 | %{"type" => "ice_candidate", "data" => candidate} -> ICECandidate.from_json(candidate)
213 | %{"type" => "sdp_offer", "data" => offer} -> SessionDescription.from_json(offer)
214 | %{"type" => "sdp_answer", "data" => answer} -> SessionDescription.from_json(answer)
215 | end
216 |
217 | send(pid, {:membrane_webrtc_signaling, self(), message, metadata})
218 | end
219 | end
220 |
--------------------------------------------------------------------------------
/.credo.exs:
--------------------------------------------------------------------------------
1 | # This file contains the configuration for Credo and you are probably reading
2 | # this after creating it with `mix credo.gen.config`.
3 | #
4 | # If you find anything wrong or unclear in this file, please report an
5 | # issue on GitHub: https://github.com/rrrene/credo/issues
6 | #
7 | %{
8 | #
9 | # You can have as many configs as you like in the `configs:` field.
10 | configs: [
11 | %{
12 | #
13 | # Run any config using `mix credo -C `. If no config name is given
14 | # "default" is used.
15 | #
16 | name: "default",
17 | #
18 | # These are the files included in the analysis:
19 | files: %{
20 | #
21 | # You can give explicit globs or simply directories.
22 | # In the latter case `**/*.{ex,exs}` will be used.
23 | #
24 | included: [
25 | "lib/",
26 | "src/",
27 | "test/",
28 | "web/",
29 | "apps/*/lib/",
30 | "apps/*/src/",
31 | "apps/*/test/",
32 | "apps/*/web/"
33 | ],
34 | excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"]
35 | },
36 | #
37 | # Load and configure plugins here:
38 | #
39 | plugins: [],
40 | #
41 | # If you create your own checks, you must specify the source files for
42 | # them here, so they can be loaded by Credo before running the analysis.
43 | #
44 | requires: [],
45 | #
46 | # If you want to enforce a style guide and need a more traditional linting
47 | # experience, you can change `strict` to `true` below:
48 | #
49 | strict: false,
50 | #
51 | # To modify the timeout for parsing files, change this value:
52 | #
53 | parse_timeout: 5000,
54 | #
55 | # If you want to use uncolored output by default, you can change `color`
56 | # to `false` below:
57 | #
58 | color: true,
59 | #
60 | # You can customize the parameters of any check by adding a second element
61 | # to the tuple.
62 | #
63 | # To disable a check put `false` as second element:
64 | #
65 | # {Credo.Check.Design.DuplicatedCode, false}
66 | #
67 | checks: [
68 | #
69 | ## Consistency Checks
70 | #
71 | {Credo.Check.Consistency.ExceptionNames, []},
72 | {Credo.Check.Consistency.LineEndings, []},
73 | {Credo.Check.Consistency.ParameterPatternMatching, []},
74 | {Credo.Check.Consistency.SpaceAroundOperators, []},
75 | {Credo.Check.Consistency.SpaceInParentheses, []},
76 | {Credo.Check.Consistency.TabsOrSpaces, []},
77 |
78 | #
79 | ## Design Checks
80 | #
81 | # You can customize the priority of any check
82 | # Priority values are: `low, normal, high, higher`
83 | #
84 | {Credo.Check.Design.AliasUsage,
85 | [priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]},
86 | # You can also customize the exit_status of each check.
87 | # If you don't want TODO comments to cause `mix credo` to fail, just
88 | # set this value to 0 (zero).
89 | #
90 | {Credo.Check.Design.TagTODO, [exit_status: 0]},
91 | {Credo.Check.Design.TagFIXME, []},
92 |
93 | #
94 | ## Readability Checks
95 | #
96 | {Credo.Check.Readability.AliasOrder, [priority: :normal]},
97 | {Credo.Check.Readability.FunctionNames, []},
98 | {Credo.Check.Readability.LargeNumbers, []},
99 | {Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]},
100 | {Credo.Check.Readability.ModuleAttributeNames, []},
101 | {Credo.Check.Readability.ModuleDoc, []},
102 | {Credo.Check.Readability.ModuleNames, []},
103 | {Credo.Check.Readability.ParenthesesInCondition, []},
104 | {Credo.Check.Readability.ParenthesesOnZeroArityDefs, parens: true},
105 | {Credo.Check.Readability.PredicateFunctionNames, []},
106 | {Credo.Check.Readability.PreferImplicitTry, []},
107 | {Credo.Check.Readability.RedundantBlankLines, []},
108 | {Credo.Check.Readability.Semicolons, []},
109 | {Credo.Check.Readability.SpaceAfterCommas, []},
110 | {Credo.Check.Readability.StringSigils, []},
111 | {Credo.Check.Readability.TrailingBlankLine, []},
112 | {Credo.Check.Readability.TrailingWhiteSpace, []},
113 | {Credo.Check.Readability.UnnecessaryAliasExpansion, []},
114 | {Credo.Check.Readability.VariableNames, []},
115 | {Credo.Check.Readability.WithSingleClause, false},
116 |
117 | #
118 | ## Refactoring Opportunities
119 | #
120 | {Credo.Check.Refactor.CondStatements, []},
121 | {Credo.Check.Refactor.CyclomaticComplexity, []},
122 | {Credo.Check.Refactor.FunctionArity, []},
123 | {Credo.Check.Refactor.LongQuoteBlocks, []},
124 | {Credo.Check.Refactor.MapInto, false},
125 | {Credo.Check.Refactor.MatchInCondition, []},
126 | {Credo.Check.Refactor.NegatedConditionsInUnless, []},
127 | {Credo.Check.Refactor.NegatedConditionsWithElse, []},
128 | {Credo.Check.Refactor.Nesting, []},
129 | {Credo.Check.Refactor.UnlessWithElse, []},
130 | {Credo.Check.Refactor.WithClauses, []},
131 |
132 | #
133 | ## Warnings
134 | #
135 | {Credo.Check.Warning.BoolOperationOnSameValues, []},
136 | {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []},
137 | {Credo.Check.Warning.IExPry, []},
138 | {Credo.Check.Warning.IoInspect, []},
139 | {Credo.Check.Warning.LazyLogging, false},
140 | {Credo.Check.Warning.MixEnv, []},
141 | {Credo.Check.Warning.OperationOnSameValues, []},
142 | {Credo.Check.Warning.OperationWithConstantResult, []},
143 | {Credo.Check.Warning.RaiseInsideRescue, []},
144 | {Credo.Check.Warning.UnusedEnumOperation, []},
145 | {Credo.Check.Warning.UnusedFileOperation, []},
146 | {Credo.Check.Warning.UnusedKeywordOperation, []},
147 | {Credo.Check.Warning.UnusedListOperation, []},
148 | {Credo.Check.Warning.UnusedPathOperation, []},
149 | {Credo.Check.Warning.UnusedRegexOperation, []},
150 | {Credo.Check.Warning.UnusedStringOperation, []},
151 | {Credo.Check.Warning.UnusedTupleOperation, []},
152 | {Credo.Check.Warning.UnsafeExec, []},
153 |
154 | #
155 | # Checks scheduled for next check update (opt-in for now, just replace `false` with `[]`)
156 |
157 | #
158 | # Controversial and experimental checks (opt-in, just replace `false` with `[]`)
159 | #
160 | {Credo.Check.Readability.StrictModuleLayout,
161 | priority: :normal, order: ~w/shortdoc moduledoc behaviour use import require alias/a},
162 | {Credo.Check.Consistency.MultiAliasImportRequireUse, false},
163 | {Credo.Check.Consistency.UnusedVariableNames, force: :meaningful},
164 | {Credo.Check.Design.DuplicatedCode, false},
165 | {Credo.Check.Readability.AliasAs, false},
166 | {Credo.Check.Readability.MultiAlias, false},
167 | {Credo.Check.Readability.SinglePipe, false},
168 | {Credo.Check.Readability.WithCustomTaggedTuple, false},
169 | {Credo.Check.Refactor.ABCSize, false},
170 | {Credo.Check.Refactor.AppendSingleItem, false},
171 | {Credo.Check.Refactor.DoubleBooleanNegation, false},
172 | {Credo.Check.Refactor.ModuleDependencies, false},
173 | {Credo.Check.Refactor.NegatedIsNil, false},
174 | {Credo.Check.Refactor.PipeChainStart, false},
175 | {Credo.Check.Refactor.VariableRebinding, false},
176 | {Credo.Check.Warning.LeakyEnvironment, false},
177 | {Credo.Check.Warning.MapGetUnsafePass, false},
178 | {Credo.Check.Warning.UnsafeToAtom, false}
179 |
180 | #
181 | # Custom checks can be created using `mix credo.gen.check`.
182 | #
183 | ]
184 | }
185 | ]
186 | }
187 |
--------------------------------------------------------------------------------
/lib/membrane_webrtc/live/capture.ex:
--------------------------------------------------------------------------------
1 | if Code.ensure_loaded?(Phoenix) and Code.ensure_loaded?(Phoenix.LiveView) do
2 | defmodule Membrane.WebRTC.Live.Capture do
3 | @moduledoc ~S'''
4 | LiveView for capturing audio and video from a browser and sending it via WebRTC to `Membrane.WebRTC.Source`.
5 |
6 | *Note:* This module will be available in your code only if you add `{:phoenix, "~> 1.7"}`
7 | and `{:phoenix_live_view, "~> 1.0"}` to the dependencies of of your root project.
8 |
9 | It:
10 | * creates WebRTC PeerConnection on the browser side.
11 | * forwards signaling messages between the browser and `Membrane.WebRTC.Source` via `Membrane.WebRTC.Signaling`.
12 | * sends audio and video streams to the related `Membrane.WebRTC.Source`.
13 |
14 | ## JavaScript Hook
15 |
16 | Capture LiveView requires JavaScript hook to be registered under `Capture` name.
17 | The hook can be created using `createCaptureHook` function.
18 | For example:
19 |
20 | ```javascript
21 | import { createCaptureHook } from "membrane_webrtc_plugin";
22 | let Hooks = {};
23 | const iceServers = [{ urls: "stun:stun.l.google.com:19302" }];
24 | Hooks.Capture = createCaptureHook(iceServers);
25 | let liveSocket = new LiveSocket("/live", Socket, {
26 | // ...
27 | hooks: Hooks
28 | });
29 | ```
30 |
31 | ## Examples
32 |
33 | ```elixir
34 | defmodule StreamerWeb.StreamSenderLive do
35 | use StreamerWeb, :live_view
36 |
37 | alias Membrane.WebRTC.Live.Capture
38 |
39 | @impl true
40 | def render(assigns) do
41 | ~H"""
42 |
43 | """
44 | end
45 |
46 | @impl true
47 | def mount(_params, _session, socket) do
48 | signaling = Membrane.WebRTC.Signaling.new()
49 | {:ok, _supervisor, _pipelne} = Membrane.Pipeline.start_link(MyPipeline, signaling: signaling)
50 |
51 | socket = socket |> Capture.attach(id: "capture", signaling: signaling)
52 | {:ok, socket}
53 | end
54 | end
55 | ```
56 | '''
57 | use Phoenix.LiveView
58 | require Logger
59 |
60 | alias Membrane.WebRTC.Signaling
61 |
62 | @type t() :: %__MODULE__{
63 | id: String.t(),
64 | signaling: Signaling.t(),
65 | preview?: boolean(),
66 | audio?: boolean(),
67 | video?: boolean()
68 | }
69 |
70 | defstruct id: nil, signaling: nil, video?: true, audio?: true, preview?: true
71 |
72 | attr(:socket, Phoenix.LiveView.Socket, required: true, doc: "Parent live view socket")
73 |
74 | attr(:capture_id, :string,
75 | required: true,
76 | doc: """
77 | ID of a `caputre` previously attached to the socket. It has to be the same as the value passed to `:id`
78 | field `#{inspect(__MODULE__)}.attach/2`.
79 | """
80 | )
81 |
82 | attr(:class, :string, default: "", doc: "CSS/Tailwind classes for styling")
83 |
84 | @doc """
85 | Helper function for rendering Capture LiveView.
86 | """
87 | def live_render(assigns) do
88 | ~H"""
89 | <%= live_render(@socket, __MODULE__, id: "#{@capture_id}-lv", session: %{"class" => @class, "id" => @capture_id}) %>
90 | """
91 | end
92 |
93 | @doc """
94 | Attaches required hooks and creates `#{inspect(__MODULE__)}` struct.
95 |
96 | Created struct is saved in socket's assigns and then
97 | it is sent by an attached hook to a child LiveView process.
98 |
99 | Options:
100 | * `id` - capture id. It is used to identify live view and generated HTML video player. It must be unique
101 | within single page.
102 | * `signaling` - `Membrane.WebRTC.Signaling.t()`, that has been passed to `Membrane.WebRTC.Source` as well.
103 | * `video?` - if `true`, the video stream from the computer camera will be captured. Defaults to `true`.
104 | * `audio?` - if `true`, the audio stream from the computer microphone will be captured. Defaults to `true`.
105 | * `preview?` - if `true`, the function `#{inspect(__MODULE__)}.live_render/1` will return a video HTML tag
106 | with attached captured video stream. Defaults to `true`.
107 | """
108 | @spec attach(Phoenix.LiveView.Socket.t(), Keyword.t()) :: Phoenix.LiveView.Socket.t()
109 | def attach(socket, opts) do
110 | opts =
111 | opts
112 | |> Keyword.validate!([
113 | :id,
114 | :signaling,
115 | video?: true,
116 | audio?: true,
117 | preview?: true
118 | ])
119 |
120 | capture = struct!(__MODULE__, opts)
121 |
122 | all_captures =
123 | socket.assigns
124 | |> Map.get(__MODULE__, %{})
125 | |> Map.put(capture.id, capture)
126 |
127 | socket
128 | |> assign(__MODULE__, all_captures)
129 | |> detach_hook(:capture_handshake, :handle_info)
130 | |> attach_hook(:capture_handshake, :handle_info, &parent_handshake/2)
131 | end
132 |
133 | @spec get_attached(Phoenix.LiveView.Socket.t(), String.t()) :: t()
134 | def get_attached(socket, id), do: socket.assigns[__MODULE__][id]
135 |
136 | ## CALLBACKS
137 |
138 | @impl true
139 | def render(%{capture: nil} = assigns) do
140 | ~H"""
141 | """
142 | end
143 |
144 | @impl true
145 | def render(%{capture: %__MODULE__{preview?: true}} = assigns) do
146 | ~H"""
147 |
154 | """
155 | end
156 |
157 | @impl true
158 | def render(%{capture: %__MODULE__{preview?: false}} = assigns) do
159 | ~H"""
160 |
161 | """
162 | end
163 |
164 | @impl true
165 | def mount(_params, %{"class" => class, "id" => id}, socket) do
166 | socket = socket |> assign(class: class, capture: nil)
167 |
168 | socket =
169 | if connected?(socket),
170 | do: client_handshake(socket, id),
171 | else: socket
172 |
173 | {:ok, socket}
174 | end
175 |
176 | defp parent_handshake({__MODULE__, {:connected, id, capture_pid}}, socket) do
177 | capture_struct =
178 | socket.assigns
179 | |> Map.fetch!(__MODULE__)
180 | |> Map.fetch!(id)
181 |
182 | send(capture_pid, capture_struct)
183 |
184 | {:halt, socket}
185 | end
186 |
187 | defp parent_handshake(_msg, socket) do
188 | {:cont, socket}
189 | end
190 |
191 | defp client_handshake(socket, id) do
192 | send(socket.parent_pid, {__MODULE__, {:connected, id, self()}})
193 |
194 | receive do
195 | %__MODULE__{} = capture ->
196 | capture.signaling
197 | |> Signaling.register_peer(message_format: :json_data)
198 |
199 | media_constraints = %{
200 | "audio" => capture.audio?,
201 | "video" => capture.video?
202 | }
203 |
204 | socket
205 | |> assign(capture: capture)
206 | |> push_event("media_constraints-#{capture.id}", media_constraints)
207 | after
208 | 5000 -> exit(:timeout)
209 | end
210 | end
211 |
212 | @impl true
213 | def handle_info({:membrane_webrtc_signaling, _pid, message, _metadata}, socket) do
214 | Logger.debug("""
215 | #{log_prefix(socket.assigns.capture.id)} Sent WebRTC signaling message: #{inspect(message, pretty: true)}
216 | """)
217 |
218 | {:noreply,
219 | socket
220 | |> push_event("webrtc_signaling-#{socket.assigns.capture.id}", message)}
221 | end
222 |
223 | @impl true
224 | def handle_event("webrtc_signaling", message, socket) do
225 | Logger.debug("""
226 | #{log_prefix(socket.assigns.capture.id)} Received WebRTC signaling message: #{inspect(message, pretty: true)}
227 | """)
228 |
229 | if message["data"] do
230 | socket.assigns.capture.signaling
231 | |> Signaling.signal(message)
232 | end
233 |
234 | {:noreply, socket}
235 | end
236 |
237 | defp log_prefix(id), do: [module: __MODULE__, id: id] |> inspect()
238 | end
239 | end
240 |
--------------------------------------------------------------------------------
/lib/membrane_webrtc/sink.ex:
--------------------------------------------------------------------------------
1 | defmodule Membrane.WebRTC.Sink do
2 | @moduledoc """
3 | Membrane Bin that allows sending audio and video tracks via WebRTC.
4 |
5 | It sends an SDP offer and expects an answer during initialization and
6 | each time when new tracks are added. For more information about signaling,
7 | see the `signaling` option.
8 |
9 | Before connecting pads, each audio and video track has to be negotiated.
10 | Tracks passed via `tracks` option are negotiated during initialization.
11 | You can negotiate more tracks by sending `t:add_tracks/0` notification
12 | and waiting for `t:new_tracks/0` notification reply.
13 |
14 | When the tracks are negotiated, pads can be linked. The pad either
15 | has to have a `kind` option set or its id has to match the id of
16 | the track received in `t:new_tracks/0` notification.
17 | """
18 | use Membrane.Bin
19 |
20 | alias Membrane.H264
21 | alias Membrane.RemoteStream
22 | alias Membrane.VP8
23 | alias Membrane.WebRTC.{ExWebRTCSink, Signaling, SimpleWebSocketServer}
24 |
25 | @typedoc """
26 | Notification that should be sent to the bin to negotiate new tracks.
27 |
28 | See the moduledoc for details.
29 | """
30 | @type add_tracks :: {:add_tracks, [:audio | :video]}
31 |
32 | @typedoc """
33 | Notification sent when new tracks are negotiated.
34 |
35 | See the moduledoc for details.
36 | """
37 | @type new_tracks :: {:new_tracks, [%{id: term, kind: :audio | :video}]}
38 |
39 | @typedoc """
40 | WHIP client options
41 |
42 | - `uri` - Address of the WHIP server (HTTP/HTTPS)
43 | - `token` - WHIP token, defaults to an empty string
44 | """
45 | @type whip_options :: [{:uri, String.t()} | {:token, String.t()}]
46 |
47 | def_options signaling: [
48 | spec:
49 | Signaling.t()
50 | | {:whip, whip_options}
51 | | {:websocket, SimpleWebSocketServer.options()},
52 | description: """
53 | Signaling channel for passing WebRTC signaling messages (SDP and ICE).
54 | Either:
55 | - `#{inspect(Signaling)}` - See its docs for details.
56 | - `{:whip, options}` - Acts as a WHIP client, see `t:whip_options/0` for details.
57 | - `{:websocket, options}` - Spawns #{inspect(SimpleWebSocketServer)},
58 | see there for details.
59 | """
60 | ],
61 | tracks: [
62 | spec: [:audio | :video],
63 | default: [:audio, :video],
64 | description: """
65 | Tracks to be negotiated. By default one audio and one video track
66 | is negotiated, meaning that at most one audio and one video can be
67 | sent.
68 | """
69 | ],
70 | video_codec: [
71 | spec: :vp8 | :h264 | [:vp8 | :h264],
72 | default: [:vp8, :h264],
73 | description: """
74 | Video codecs, that #{inspect(__MODULE__)} will try to negotiatie in SDP
75 | message exchange. Even if `[:vp8, :h264]` is passed to this option, there
76 | is a chance, that one of these codecs won't be approved by the second
77 | WebRTC peer.
78 |
79 | After SDP messages exchange, #{inspect(__MODULE__)} will send a parent
80 | notification `{:negotiated_video_codecs, codecs}` where `codecs` is
81 | a list of supported codecs.
82 | """
83 | ],
84 | ice_servers: [
85 | spec: [ExWebRTC.PeerConnection.Configuration.ice_server()],
86 | default: [%{urls: "stun:stun.l.google.com:19302"}]
87 | ],
88 | ice_port_range: [
89 | spec: Enumerable.t(non_neg_integer()),
90 | default: [0]
91 | ],
92 | ice_ip_filter: [
93 | spec: (:inet.ip_address() -> boolean()),
94 | default: &__MODULE__.default_ice_ip_filter/1
95 | ],
96 | payload_rtp: [
97 | spec: boolean(),
98 | default: true
99 | ]
100 |
101 | def_input_pad :input,
102 | accepted_format:
103 | any_of(
104 | %Membrane.H264{alignment: :nalu},
105 | %Membrane.RemoteStream{content_format: Membrane.VP8},
106 | Membrane.VP8,
107 | Membrane.Opus,
108 | Membrane.RTP
109 | ),
110 | availability: :on_request,
111 | options: [
112 | kind: [
113 | spec: :audio | :video | nil,
114 | default: nil,
115 | description: """
116 | When set, the pad is associated with the first negotiated track
117 | of the given kind. See the moduledoc for details.
118 | """
119 | ]
120 | ]
121 |
122 | @impl true
123 | def handle_init(_ctx, opts) do
124 | :ok = Membrane.WebRTC.Utils.validate_signaling!(opts.signaling)
125 |
126 | spec =
127 | child(:webrtc, %ExWebRTCSink{
128 | signaling: opts.signaling,
129 | tracks: opts.tracks,
130 | video_codec: opts.video_codec,
131 | ice_servers: opts.ice_servers,
132 | ice_port_range: opts.ice_port_range,
133 | ice_ip_filter: opts.ice_ip_filter
134 | })
135 |
136 | {[spec: spec], %{payload_rtp: opts.payload_rtp, video_codec: opts.video_codec}}
137 | end
138 |
139 | @impl true
140 | def handle_setup(_ctx, state) do
141 | {[setup: :incomplete], state}
142 | end
143 |
144 | @impl true
145 | def handle_pad_added(Pad.ref(:input, pid) = pad_ref, %{pad_options: %{kind: kind}}, state) do
146 | spec =
147 | cond do
148 | not state.payload_rtp ->
149 | bin_input(pad_ref)
150 | |> via_in(pad_ref, options: [kind: kind])
151 | |> get_child(:webrtc)
152 |
153 | kind == :audio ->
154 | bin_input(pad_ref)
155 | |> child({:rtp_opus_payloader, pid}, Membrane.RTP.Opus.Payloader)
156 | |> via_in(pad_ref, options: [kind: :audio, codec: :opus])
157 | |> get_child(:webrtc)
158 |
159 | kind == :video ->
160 | bin_input(pad_ref)
161 | |> child({:connector, pad_ref}, %Membrane.Connector{notify_on_stream_format?: true})
162 | end
163 |
164 | {[spec: spec], state}
165 | end
166 |
167 | @impl true
168 | def handle_child_notification(
169 | {:stream_format, _connector_pad, _stream_format},
170 | {:connector, pad_ref},
171 | ctx,
172 | state
173 | )
174 | when is_map_key(ctx.children, {:rtp_payloader, pad_ref}) do
175 | {[], state}
176 | end
177 |
178 | @impl true
179 | def handle_child_notification(
180 | {:stream_format, _connector_pad, stream_format},
181 | {:connector, pad_ref},
182 | _ctx,
183 | state
184 | ) do
185 | codec =
186 | case stream_format do
187 | %H264{} -> :h264
188 | %VP8{} -> :vp8
189 | %RemoteStream{content_format: VP8} -> :vp8
190 | end
191 |
192 | payloader =
193 | case codec do
194 | :h264 -> %Membrane.RTP.H264.Payloader{max_payload_size: 1000}
195 | :vp8 -> Membrane.RTP.VP8.Payloader
196 | end
197 |
198 | spec =
199 | get_child({:connector, pad_ref})
200 | |> child({:rtp_payloader, pad_ref}, payloader)
201 | |> via_in(pad_ref, options: [kind: :video, codec: codec])
202 | |> get_child(:webrtc)
203 |
204 | {[spec: spec], state}
205 | end
206 |
207 | @impl true
208 | def handle_child_notification(:connected, :webrtc, _ctx, state) do
209 | {[setup: :complete], state}
210 | end
211 |
212 | @impl true
213 | def handle_child_notification({type, _content} = notification, :webrtc, _ctx, state)
214 | when type in [:new_tracks, :negotiated_video_codecs] do
215 | {[notify_parent: notification], state}
216 | end
217 |
218 | @impl true
219 | def handle_parent_notification({:add_tracks, tracks}, _ctx, state) do
220 | {[notify_child: {:webrtc, {:add_tracks, tracks}}], state}
221 | end
222 |
223 | @impl true
224 | def handle_element_end_of_stream(:webrtc, Pad.ref(:input, id), _ctx, state) do
225 | {[notify_parent: {:end_of_stream, id}], state}
226 | end
227 |
228 | @impl true
229 | def handle_element_end_of_stream(_name, _pad, _ctx, state) do
230 | {[], state}
231 | end
232 |
233 | def default_ice_ip_filter(_ip), do: true
234 | end
235 |
--------------------------------------------------------------------------------
/lib/membrane_webrtc/whip_server.ex:
--------------------------------------------------------------------------------
1 | defmodule Membrane.WebRTC.WhipServer do
2 | @moduledoc """
3 | Server accepting WHIP connections.
4 |
5 | Accepts the following options:
6 |
7 | - `handle_new_client` - function that accepts the client token and returns either
8 | the signaling channel to negotiate the connection or error to reject it. The signaling
9 | channel can be passed to `Membrane.WebRTC.Source`.
10 | - `serve_static` - path to static assets that should be served along with WHIP,
11 | under a `/static` endpoint. Useful to serve HTML assets. If set to `false` (default),
12 | no static assets are served
13 | - Any of `t:Bandit.options/0` - Bandit configuration
14 | """
15 |
16 | alias Membrane.WebRTC.Signaling
17 |
18 | @type option ::
19 | {:handle_new_client,
20 | (token :: String.t() -> {:ok, Signaling.t()} | {:error, reason :: term()})}
21 | | {:serve_static, String.t() | false}
22 | | {atom, term()}
23 | @spec child_spec([option()]) :: Supervisor.child_spec()
24 | def child_spec(opts) do
25 | Bandit.child_spec(bandit_opts(opts))
26 | end
27 |
28 | @spec start_link([option()]) :: Supervisor.on_start()
29 | def start_link(opts) do
30 | Bandit.start_link(bandit_opts(opts))
31 | end
32 |
33 | defp bandit_opts(opts) do
34 | {whip_opts, bandit_opts} = Keyword.split(opts, [:handle_new_client, :serve_static])
35 | plug = {__MODULE__.Router, whip_opts}
36 | [plug: plug] ++ bandit_opts
37 | end
38 |
39 | defmodule Router do
40 | @moduledoc """
41 | WHIP router pluggable to a plug pipeline.
42 |
43 | Accepts the same options as `Membrane.WebRTC.WhipServer`.
44 |
45 | ## Example
46 |
47 | ```
48 | defmodule Router do
49 | use Plug.Router
50 |
51 | plug(Plug.Logger)
52 | plug(Plug.Static, at: "/static", from: "assets")
53 | plug(:match)
54 | plug(:dispatch)
55 |
56 | forward(
57 | "/whip",
58 | to: Membrane.WebRTC.WhipServer.Router,
59 | handle_new_client: &__MODULE__.handle_new_client/1
60 | )
61 |
62 | def handle_new_client(token) do
63 | validate_token!(token)
64 | signaling = Membrane.WebRTC.Signaling.new()
65 | # pass the signaling to a pipeline
66 | {:ok, signaling}
67 | end
68 | end
69 |
70 | Bandit.start_link(plug: Router, ip: any)
71 | ```
72 | """
73 | use Plug.Router
74 |
75 | plug(Plug.Logger, log: :debug)
76 | plug(Corsica, origins: "*", allow_methods: :all, allow_headers: :all)
77 | plug(:match)
78 | plug(:dispatch)
79 |
80 | # TODO: the HTTP response codes are not completely compliant with the RFCs
81 |
82 | defmodule ClientHandler do
83 | @moduledoc false
84 | use GenServer
85 |
86 | @spec start_link(GenServer.options()) :: {:ok, pid()}
87 | def start_link(opts), do: GenServer.start_link(__MODULE__, [], opts)
88 |
89 | @spec exec(GenServer.server(), (state -> {resp, state})) :: resp
90 | when state: term(), resp: term()
91 | def exec(client_handler, fun), do: GenServer.call(client_handler, {:exec, fun})
92 | @spec stop(GenServer.server()) :: :ok
93 | def stop(client_handler), do: GenServer.stop(client_handler)
94 |
95 | @impl true
96 | def init(_opts), do: {:ok, nil}
97 |
98 | @impl true
99 | def handle_call({:exec, fun}, _from, state) do
100 | {reply, state} = fun.(state)
101 | {:reply, reply, state}
102 | end
103 |
104 | @impl true
105 | def handle_info(_message, state), do: {:noreply, state}
106 | end
107 |
108 | post "/" do
109 | with {:ok, token} <- get_token(conn),
110 | {:ok, offer_sdp, conn} <- get_body(conn, "application/sdp"),
111 | resource_id = generate_resource_id(),
112 | {:ok, client_handler} = ClientHandler.start_link(name: handler_name(resource_id)),
113 | {:ok, answer_sdp} <-
114 | get_answer(client_handler, offer_sdp, token, conn.private.whip.handle_new_client) do
115 | Process.unlink(client_handler)
116 |
117 | conn
118 | |> put_resp_header("location", Path.join(conn.request_path, "resource/#{resource_id}"))
119 | |> put_resp_header("access-control-expose-headers", "location")
120 | |> put_resp_content_type("application/sdp")
121 | |> resp(201, answer_sdp)
122 | else
123 | {:error, _other} -> resp(conn, 400, "Bad request")
124 | end
125 | |> send_resp()
126 | end
127 |
128 | patch "resource/:resource_id" do
129 | with {:ok, sdp, conn} <- get_body(conn, "application/trickle-ice-sdpfrag"),
130 | sdp = ExSDP.parse!(sdp),
131 | media = List.first(sdp.media),
132 | {"candidate", candidate} <- ExSDP.get_attribute(media, "candidate") || :no_candidate do
133 | {:ice_ufrag, ufrag} = ExSDP.get_attribute(sdp, :ice_ufrag)
134 | {:mid, mid} = ExSDP.get_attribute(media, :mid)
135 |
136 | candidate = %ExWebRTC.ICECandidate{
137 | candidate: candidate,
138 | sdp_mid: mid,
139 | username_fragment: ufrag,
140 | sdp_m_line_index: 0
141 | }
142 |
143 | ClientHandler.exec(handler_name(resource_id), fn signaling ->
144 | Signaling.signal(signaling, candidate)
145 | {:ok, signaling}
146 | end)
147 |
148 | resp(conn, 204, "")
149 | else
150 | :no_candidate -> resp(conn, 204, "")
151 | {:error, _res} -> resp(conn, 400, "Bad request")
152 | end
153 | |> send_resp()
154 | end
155 |
156 | delete "resource/:resource_id" do
157 | ClientHandler.stop(handler_name(resource_id))
158 | send_resp(conn, 204, "")
159 | end
160 |
161 | get "static/*_" do
162 | case conn.private.whip.plug_static do
163 | nil -> send_resp(conn, 404, "Not found")
164 | plug_static -> Plug.Static.call(conn, plug_static)
165 | end
166 | end
167 |
168 | match _ do
169 | send_resp(conn, 404, "Not found")
170 | end
171 |
172 | @impl true
173 | def init(opts) do
174 | {handle_new_client, opts} = Keyword.pop(opts, :handle_new_client)
175 | unless handle_new_client, do: raise("Missing option 'handle_new_client'")
176 | {serve_static, opts} = Keyword.pop(opts, :serve_static, false)
177 | if opts != [], do: raise("Unknown options: #{Enum.join(opts, ", ")}")
178 |
179 | plug_static =
180 | if serve_static, do: Plug.Static.init(at: "static", from: serve_static)
181 |
182 | super(%{handle_new_client: handle_new_client, plug_static: plug_static})
183 | end
184 |
185 | @impl true
186 | def call(conn, opts) do
187 | conn
188 | |> put_private(:whip, opts)
189 | |> super(opts)
190 | end
191 |
192 | defp get_token(conn) do
193 | with ["Bearer " <> token] <- get_req_header(conn, "authorization") do
194 | {:ok, token}
195 | else
196 | _other -> {:error, :unauthorized}
197 | end
198 | end
199 |
200 | defp get_body(conn, content_type) do
201 | with [^content_type] <- get_req_header(conn, "content-type"),
202 | {:ok, body, conn} <- read_body(conn) do
203 | {:ok, body, conn}
204 | else
205 | headers when is_list(headers) -> {:error, :unsupported_media}
206 | _other -> {:error, :bad_request}
207 | end
208 | end
209 |
210 | defp get_answer(client_handler, offer_sdp, token, handle_new_client) do
211 | ClientHandler.exec(client_handler, fn _state ->
212 | with {:ok, signaling} <- handle_new_client.(token) do
213 | Signaling.register_peer(signaling)
214 |
215 | Signaling.signal(
216 | signaling,
217 | %ExWebRTC.SessionDescription{type: :offer, sdp: offer_sdp},
218 | %{candidates_in_sdp: true}
219 | )
220 |
221 | receive do
222 | {:membrane_webrtc_signaling, _pid, answer, _metadata} ->
223 | %ExWebRTC.SessionDescription{type: :answer, sdp: answer_sdp} = answer
224 | {{:ok, answer_sdp}, signaling}
225 | after
226 | 5000 -> raise "Timeout waiting for SDP answer"
227 | end
228 | else
229 | {:error, reason} -> {{:error, reason}, nil}
230 | end
231 | end)
232 | end
233 |
234 | defp generate_resource_id() do
235 | for _i <- 1..10, into: "", do: <>
236 | end
237 |
238 | defp handler_name(resource_id) do
239 | {:via, Registry, {Membrane.WebRTC.WhipRegistry, resource_id}}
240 | end
241 | end
242 | end
243 |
--------------------------------------------------------------------------------
/lib/membrane_webrtc/source.ex:
--------------------------------------------------------------------------------
1 | defmodule Membrane.WebRTC.Source do
2 | @moduledoc """
3 | Membrane Bin that allows receiving audio and video tracks via WebRTC.
4 |
5 | It expects an SDP offer to be sent by the other peer at the beginning
6 | of playback and each time new tracks are added. For more information
7 | about signaling, see the `signaling` option.
8 |
9 | Pads connected immediately when the bin is created
10 | (in the same spec `t:Membrane.ChildrenSpec.t/0`) need to have the `kind`
11 | option set to `:audio` or `:video`. Each of those pads will be associated
12 | with the first WebRTC track of the given kind that arrives.
13 |
14 | When a WebRTC tracks arrive and there's no pad to link them to,
15 | the `t:new_tracks/0` notification is sent. Then, the corresponding pads
16 | should be linked - the id of each pad should match one of the track ids.
17 | """
18 | use Membrane.Bin
19 | require Membrane.Logger
20 |
21 | alias Membrane.WebRTC.{
22 | ExWebRTCSource,
23 | ExWebRTCUtils,
24 | Signaling,
25 | SimpleWebSocketServer
26 | }
27 |
28 | @typedoc """
29 | Notification sent when new tracks arrive.
30 |
31 | See moduledoc for details.
32 | """
33 | @type new_tracks :: {:new_tracks, [%{id: term, kind: :audio | :video}]}
34 |
35 | @typedoc """
36 | Options for WHIP server input.
37 |
38 | The server accepts a single connection and the stream is received by this source. The options are:
39 |
40 | - `token` - either expected WHIP token or a function returning true if the token is valid, otherwise false
41 | - `serve_static` - make WHIP server also serve static content, such as an HTML page under `/static` endpoint
42 | - Any of `t:Bandit.options/0` - in particular `ip` and `port`
43 |
44 | To handle multiple connections and have more control over the server, see `Membrane.WebRTC.WhipServer`.
45 | """
46 | @type whip_options :: [
47 | {:token, String.t() | (String.t() -> boolean())}
48 | | {:serve_static, String.t()}
49 | | {atom, term()}
50 | ]
51 |
52 | def_options signaling: [
53 | spec:
54 | Signaling.t()
55 | | {:whip, whip_options()}
56 | | {:websocket, SimpleWebSocketServer.options()},
57 | description: """
58 | Signaling channel for passing WebRTC signaling messages (SDP and ICE).
59 | Either:
60 | - `#{inspect(Signaling)}` - See its docs for details.
61 | - `{:whip, options}` - Starts a WHIP server, see `t:whip_options/0` for details.
62 | - `{:websocket, options}` - Spawns #{inspect(SimpleWebSocketServer)},
63 | see there for details.
64 | """
65 | ],
66 | allowed_video_codecs: [
67 | spec: :vp8 | :h264 | [:vp8 | :h264],
68 | default: :vp8,
69 | description: """
70 | Specifies, which video codecs can be accepted by the source during the SDP
71 | negotiaion.
72 |
73 | Either `:vp8`, `:h264` or a list containing both options.
74 |
75 | Event if it is set to `[:h264, :vp8]`, the source will negotiate at most
76 | one video codec. Negotiated codec can be deduced from
77 | `{:negotiated_video_codecs, codecs}` notification sent to the parent.
78 |
79 | If prefer to receive one video codec over another, but you are still able
80 | to handle both of them, use `:preferred_video_codec` option.
81 |
82 | By default only `:vp8`.
83 | """
84 | ],
85 | preferred_video_codec: [
86 | spec: :vp8 | :h264,
87 | default: :vp8,
88 | description: """
89 | Specyfies, which video codec will be preferred by the source, if both of
90 | them will be available.
91 |
92 | Usage of this option makes sense only if there are at least 2 codecs
93 | specified in the `:allowed_video_codecs` option.
94 |
95 | Defaults to `:vp8`.
96 | """
97 | ],
98 | keyframe_interval: [
99 | spec: Membrane.Time.t() | nil,
100 | default: nil,
101 | description: """
102 | If set, a keyframe will be requested as often as specified on each video
103 | track.
104 | """
105 | ],
106 | ice_servers: [
107 | spec: [ExWebRTC.PeerConnection.Configuration.ice_server()],
108 | default: [%{urls: "stun:stun.l.google.com:19302"}]
109 | ],
110 | ice_port_range: [
111 | spec: Enumerable.t(non_neg_integer()),
112 | default: [0]
113 | ],
114 | ice_ip_filter: [
115 | spec: (:inet.ip_address() -> boolean()),
116 | default: &__MODULE__.default_ice_ip_filter/1
117 | ],
118 | depayload_rtp: [
119 | spec: boolean(),
120 | default: true
121 | ],
122 | sdp_candidates_timeout: [
123 | spec: Membrane.Time.t(),
124 | default: Membrane.Time.seconds(1),
125 | default_inspector: &Membrane.Time.pretty_duration/1
126 | ]
127 |
128 | def_output_pad :output,
129 | accepted_format:
130 | any_of(
131 | Membrane.H264,
132 | %Membrane.RemoteStream{content_format: Membrane.VP8},
133 | %Membrane.RemoteStream{content_format: Membrane.Opus},
134 | Membrane.RTP
135 | ),
136 | availability: :on_request,
137 | options: [kind: [default: nil]]
138 |
139 | @impl true
140 | def handle_init(_ctx, opts) do
141 | opts = opts |> Map.from_struct() |> Map.update!(:allowed_video_codecs, &Bunch.listify/1)
142 | spec = child(:webrtc, struct(ExWebRTCSource, opts))
143 |
144 | :ok = Membrane.WebRTC.Utils.validate_signaling!(opts.signaling)
145 |
146 | state =
147 | %{tracks: %{}, negotiated_video_codecs: nil, awaiting_pads: MapSet.new()}
148 | |> Map.merge(opts)
149 |
150 | {[spec: spec], state}
151 | end
152 |
153 | @impl true
154 | def handle_pad_added(Pad.ref(:output, pad_id) = pad_ref, ctx, state) do
155 | %{kind: kind} = ctx.pad_options
156 | track = state.tracks[pad_id]
157 |
158 | if ctx.playback == :stopped and kind == nil do
159 | raise "Option `kind` not specified for pad #{inspect(pad_ref)}"
160 | end
161 |
162 | if ctx.playback == :playing and track == nil do
163 | raise "Unknown track id #{inspect(pad_id)}, cannot link pad #{inspect(pad_ref)}"
164 | end
165 |
166 | link_webrtc(pad_ref, kind || track.kind, state)
167 | end
168 |
169 | defp link_webrtc(pad_ref, kind, state) do
170 | spec =
171 | get_child(:webrtc)
172 | |> via_out(pad_ref, options: [kind: kind])
173 |
174 | {spec, state} =
175 | cond do
176 | not state.depayload_rtp ->
177 | {spec |> bin_output(pad_ref), state}
178 |
179 | kind == :audio ->
180 | {spec |> get_depayloader(:audio, state) |> bin_output(pad_ref), state}
181 |
182 | kind == :video and state.negotiated_video_codecs == nil ->
183 | spec =
184 | [
185 | spec
186 | |> child({:input_connector, pad_ref}, Membrane.Connector),
187 | child({:output_connector, pad_ref}, Membrane.Connector)
188 | |> bin_output(pad_ref)
189 | ]
190 |
191 | state = state |> Map.update!(:awaiting_pads, &MapSet.put(&1, pad_ref))
192 | {spec, state}
193 |
194 | kind == :video ->
195 | {spec |> get_depayloader(:video, state) |> bin_output(pad_ref), state}
196 | end
197 |
198 | {[spec: spec], state}
199 | end
200 |
201 | @impl true
202 | def handle_child_notification({:new_tracks, tracks}, :webrtc, _ctx, state) do
203 | tracks_map = Map.new(tracks, &{&1.id, &1})
204 | state = %{state | tracks: Map.merge(state.tracks, tracks_map)}
205 | {[notify_parent: {:new_tracks, tracks}], state}
206 | end
207 |
208 | @impl true
209 | def handle_child_notification({:negotiated_video_codecs, codecs}, :webrtc, _ctx, state) do
210 | state = %{state | negotiated_video_codecs: codecs}
211 |
212 | spec =
213 | state.awaiting_pads
214 | |> Enum.map(fn pad_ref ->
215 | get_child({:input_connector, pad_ref})
216 | |> get_depayloader(:video, state)
217 | |> get_child({:output_connector, pad_ref})
218 | end)
219 |
220 | state = %{state | awaiting_pads: MapSet.new()}
221 |
222 | {[notify_parent: {:negotiated_video_codecs, codecs}, spec: spec], state}
223 | end
224 |
225 | @impl true
226 | def handle_child_notification(notification, child, _ctx, state) do
227 | Membrane.Logger.debug(
228 | "Received notification from child #{inspect(child)}: #{inspect(notification)}"
229 | )
230 |
231 | {[], state}
232 | end
233 |
234 | @spec get_depayloader(
235 | Membrane.ChildrenSpec.builder(),
236 | :audio | :video,
237 | map()
238 | ) :: Membrane.ChildrenSpec.builder() | no_return()
239 |
240 | defp get_depayloader(builder, :audio, _state) do
241 | child(builder, {:depayloader_bin, make_ref()}, %Membrane.RTP.DepayloaderBin{
242 | depayloader: Membrane.RTP.Opus.Depayloader,
243 | clock_rate: ExWebRTCUtils.codec_clock_rate(:opus)
244 | })
245 | end
246 |
247 | defp get_depayloader(builder, :video, state) do
248 | cond do
249 | state.allowed_video_codecs == [:vp8] ->
250 | get_vp8_depayloader(builder)
251 |
252 | state.allowed_video_codecs == [:h264] ->
253 | get_h264_depayloader(builder)
254 |
255 | state.negotiated_video_codecs == [:vp8] ->
256 | get_vp8_depayloader(builder)
257 |
258 | state.negotiated_video_codecs == [:h264] ->
259 | get_h264_depayloader(builder)
260 |
261 | state.negotiated_video_codecs == nil ->
262 | raise "Cannot select depayloader before end of SDP messages exchange"
263 | end
264 | end
265 |
266 | defp get_vp8_depayloader(builder) do
267 | child(builder, {:depayloader_bin, make_ref()}, %Membrane.RTP.DepayloaderBin{
268 | depayloader: Membrane.RTP.VP8.Depayloader,
269 | clock_rate: ExWebRTCUtils.codec_clock_rate(:vp8)
270 | })
271 | end
272 |
273 | defp get_h264_depayloader(builder) do
274 | child(builder, {:depayloader_bin, make_ref()}, %Membrane.RTP.DepayloaderBin{
275 | depayloader: Membrane.RTP.H264.Depayloader,
276 | clock_rate: ExWebRTCUtils.codec_clock_rate(:h264)
277 | })
278 | end
279 |
280 | def default_ice_ip_filter(_ip), do: true
281 | end
282 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | APPENDIX: How to apply the Apache License to your work.
179 |
180 | To apply the Apache License to your work, attach the following
181 | boilerplate notice, with the fields enclosed by brackets "[]"
182 | replaced with your own identifying information. (Don't include
183 | the brackets!) The text should be enclosed in the appropriate
184 | comment syntax for the file format. We also recommend that a
185 | file or class name and description of purpose be included on the
186 | same "printed page" as the copyright notice for easier
187 | identification within third-party archives.
188 |
189 | Copyright 2020 Software Mansion
190 |
191 | Licensed under the Apache License, Version 2.0 (the "License");
192 | you may not use this file except in compliance with the License.
193 | You may obtain a copy of the License at
194 |
195 | http://www.apache.org/licenses/LICENSE-2.0
196 |
197 | Unless required by applicable law or agreed to in writing, software
198 | distributed under the License is distributed on an "AS IS" BASIS,
199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 | See the License for the specific language governing permissions and
201 | limitations under the License.
202 |
--------------------------------------------------------------------------------