Skip to content

Stream recordings #6

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Feb 22, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
[![Hex.pm](https://img.shields.io/hexpm/v/live_ex_webrtc.svg)](https://hex.pm/packages/live_ex_webrtc)
[![API Docs](https://img.shields.io/badge/api-docs-yellow.svg?style=flat)](https://hexdocs.pm/live_ex_webrtc)

Phoenix Live Components for Elixir WebRTC.
Phoenix Live Components for [Elixir WebRTC](https://github.com/elixir-webrtc/ex_webrtc).

## Installation

Expand Down
6 changes: 6 additions & 0 deletions assets/publisher.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export function createPublisherHook(iceServers = []) {
view.fps = document.getElementById("lex-fps");
view.bitrate = document.getElementById("lex-bitrate");

view.recordStream = document.getElementById("lex-record-stream");

view.previewPlayer = document.getElementById("lex-preview-player");

view.audioBitrate = document.getElementById("lex-audio-bitrate");
Expand Down Expand Up @@ -93,6 +95,8 @@ export function createPublisherHook(iceServers = []) {
view.audioApplyButton.disabled = true;
view.videoApplyButton.disabled = true;
view.bitrate.disabled = true;
// Button present only when Recorder is used
if (view.recordStream) view.recordStream.disabled = true;
},

enableControls(view) {
Expand All @@ -107,6 +111,8 @@ export function createPublisherHook(iceServers = []) {
view.audioApplyButton.disabled = false;
view.videoApplyButton.disabled = false;
view.bitrate.disabled = false;
// See above
if (view.recordStream) view.recordStream.disabled = false;
},

async findDevices(view) {
Expand Down
123 changes: 94 additions & 29 deletions lib/live_ex_webrtc/publisher.ex
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@ defmodule LiveExWebRTC.Publisher do
* renders:
* audio and video device selects
* audio and video stream configs
* stream recording toggle (with recordings enabled)
* stream preview
* transmission stats
* on clicking "Start Streaming", creates WebRTC PeerConnection both on the client and server side
* connects those two peer connections negotiatiing a single audio and video track
* sends audio and video from selected devices to the live view process
* publishes received audio and video packets to the configured PubSub
* can optionally use the [ExWebRTC Recorder](https://github.com/elixir-webrtc/ex_webrtc_recorder) to record the stream

When `LiveExWebRTC.Player` is used, audio and video packets are delivered automatically,
assuming both components are configured with the same PubSub.
Expand Down Expand Up @@ -68,28 +70,44 @@ defmodule LiveExWebRTC.Publisher do
use Phoenix.LiveView

alias LiveExWebRTC.Publisher
alias ExWebRTC.{ICECandidate, PeerConnection, SessionDescription}
alias ExWebRTC.{ICECandidate, PeerConnection, Recorder, SessionDescription}
alias Phoenix.PubSub

@type on_connected() :: (publisher_id :: String.t() -> any())
@type on_connected :: (publisher_id :: String.t() -> any())

@type on_packet() ::
@typedoc """
Function signature of the `on_disconnected` callback.

* If `recorder` was passed to `attach/2` and the "Record stream?" checkbox ticked,
the second argument contains the result of calling `ExWebRTC.Recorder.end_tracks/2`.
See `t:ExWebRTC.Recorder.end_tracks_ok_result/0` for more info.
* Otherwise, the second argument is `nil` and can be ignored.
"""
@type on_disconnected :: (publisher_id :: String.t(),
ExWebRTC.Recorder.end_tracks_ok_result()
| nil ->
any())

@type on_packet ::
(publisher_id :: String.t(),
packet_type :: :audio | :video,
packet :: ExRTP.Packet.t(),
socket :: Phoenix.LiveView.Socket.t() ->
packet :: ExRTP.Packet.t())

@type t() :: struct()
@type t :: struct()

defstruct id: nil,
pc: nil,
streaming?: false,
record?: false,
audio_track_id: nil,
video_track_id: nil,
on_packet: nil,
on_connected: nil,
on_disconnected: nil,
pubsub: nil,
recorder: nil,
ice_servers: nil,
ice_ip_filter: nil,
ice_port_range: nil,
Expand Down Expand Up @@ -125,8 +143,11 @@ defmodule LiveExWebRTC.Publisher do
Options:
* `id` - publisher id. This is typically your user id (if there is users database).
It is used to identify live view and generated HTML elements.
* `pubsub` - a pubsub that publisher live view will use for broadcasting audio and video packets received from a browser. See module doc for more.
* `pubsub` - a pubsub that publisher live view will use for broadcasting audio and video packets received from a browser. See module doc for more info.
* `recorder` - optional `ExWebRTC.Recorder` instance that publisher live view will use for recording the stream.
See module doc and `t:on_disconnected/0` for more info.
* `on_connected` - callback called when the underlying peer connection changes its state to the `:connected`. See `t:on_connected/0`.
* `on_disconnected` - callback called when the underlying peer connection process terminates. See `t:on_disconnected/0`.
* `on_packet` - callback called for each audio and video RTP packet. Can be used to modify the packet before publishing it on a pubsub. See `t:on_packet/0`.
* `ice_servers` - a list of `t:ExWebRTC.PeerConnection.Configuration.ice_server/0`,
* `ice_ip_filter` - `t:ExICE.ICEAgent.ip_filter/0`,
Expand All @@ -142,8 +163,10 @@ defmodule LiveExWebRTC.Publisher do
:id,
:name,
:pubsub,
:recorder,
:on_packet,
:on_connected,
:on_disconnected,
:ice_servers,
:ice_ip_filter,
:ice_port_range,
Expand All @@ -155,8 +178,10 @@ defmodule LiveExWebRTC.Publisher do
publisher = %Publisher{
id: Keyword.fetch!(opts, :id),
pubsub: Keyword.fetch!(opts, :pubsub),
recorder: Keyword.get(opts, :recorder),
on_packet: Keyword.get(opts, :on_packet),
on_connected: Keyword.get(opts, :on_connected),
on_disconnected: Keyword.get(opts, :on_disconnected),
ice_servers: Keyword.get(opts, :ice_servers, [%{urls: "stun:stun.l.google.com:19302"}]),
ice_ip_filter: Keyword.get(opts, :ice_ip_filter),
ice_port_range: Keyword.get(opts, :ice_port_range),
Expand All @@ -165,8 +190,11 @@ defmodule LiveExWebRTC.Publisher do
pc_genserver_opts: Keyword.get(opts, :pc_genserver_opts, [])
}

# Check the "Record stream?" checkbox by default if recorder was configured
record? = publisher.recorder != nil

socket
|> assign(publisher: publisher)
|> assign(publisher: %Publisher{publisher | record?: record?})
|> attach_hook(:handshake, :handle_info, &handshake/2)
end

Expand Down Expand Up @@ -255,7 +283,7 @@ defmodule LiveExWebRTC.Publisher do
<input
type="text"
id="lex-fps"
value="24"
value="30"
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a hack: Recorder.Converter assumes we're using 30 FPS and there's currently no logic that would allow changing/overriding this assumption

I think adding it is a must, I'll get to work on that

class="rounded-lg disabled:text-gray-400 disabled:border-gray-400 focus:border-brand focus:outline-none focus:ring-0"
/>
</div>
Expand All @@ -271,6 +299,10 @@ defmodule LiveExWebRTC.Publisher do
</div>
<button id="lex-video-apply-button" class="rounded-lg px-10 py-2.5 bg-brand disabled:bg-brand/50 hover:bg-brand/90 text-white font-bold" disabled>Apply</button>
</details>
<div :if={@publisher.recorder} class="flex gap-2.5 items-center">
<label for="lex-record-stream">Record stream:</label>
<input type="checkbox" phx-click="record-stream-change" id="lex-record-stream" class="rounded-full" checked={@publisher.record?} />
</div>
<div id="lex-videoplayer-wrapper" class="flex flex-1 flex-col min-h-0 pt-2.5">
<video id="lex-preview-player" class="m-auto rounded-lg bg-black h-full" autoplay controls muted>
</video>
Expand Down Expand Up @@ -358,37 +390,43 @@ defmodule LiveExWebRTC.Publisher do
def handle_info({:ex_webrtc, _pc, {:rtp, track_id, nil, packet}}, socket) do
%{publisher: publisher} = socket.assigns

case publisher do
%Publisher{video_track_id: ^track_id} ->
packet =
if publisher.on_packet,
do: publisher.on_packet.(publisher.id, :video, packet, socket),
else: packet

PubSub.broadcast(
publisher.pubsub,
"streams:video:#{publisher.id}",
{:live_ex_webrtc, :video, packet}
)
kind =
case publisher do
%Publisher{video_track_id: ^track_id} -> :video
%Publisher{audio_track_id: ^track_id} -> :audio
end

{:noreply, socket}
packet =
if publisher.on_packet,
do: publisher.on_packet.(publisher.id, kind, packet, socket),
else: packet

%Publisher{audio_track_id: ^track_id} ->
PubSub.broadcast(
publisher.pubsub,
"streams:audio:#{publisher.id}",
{:live_ex_webrtc, :audio, packet}
)
if publisher.record?, do: Recorder.record(publisher.recorder, track_id, nil, packet)

if publisher.on_packet, do: publisher.on_packet.(publisher.id, :audio, packet, socket)
{:noreply, socket}
end
PubSub.broadcast(
publisher.pubsub,
"streams:#{kind}:#{publisher.id}",
{:live_ex_webrtc, kind, packet}
)

{:noreply, socket}
end

@impl true
def handle_info({:ex_webrtc, _pid, {:connection_state_change, :connected}}, socket) do
%{publisher: pub} = socket.assigns

if pub.record? do
[
%{kind: :audio, receiver: %{track: audio_track}},
%{kind: :video, receiver: %{track: video_track}}
] = PeerConnection.get_transceivers(pub.pc)

Recorder.add_tracks(pub.recorder, [audio_track, video_track])
end

if pub.on_connected, do: pub.on_connected.(pub.id)

{:noreply, socket}
end

Expand All @@ -397,6 +435,23 @@ defmodule LiveExWebRTC.Publisher do
{:noreply, socket}
end

@impl true
def handle_info(
{:DOWN, _ref, :process, pc, _reason},
%{assigns: %{publisher: %{pc: pc} = pub}} = socket
) do
recorder_result =
if pub.record? do
Recorder.end_tracks(pub.recorder, [pub.audio_track_id, pub.video_track_id])
end

if pub.on_disconnected, do: pub.on_disconnected.(pub.id, recorder_result)

{:noreply,
socket
|> assign(publisher: %Publisher{pub | streaming?: false})}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if setting streaming?: false here could break some of our other logic

end

@impl true
def handle_event("start-streaming", _, socket) do
{:noreply,
Expand All @@ -413,11 +468,21 @@ defmodule LiveExWebRTC.Publisher do
|> push_event("stop-streaming", %{})}
end

@impl true
def handle_event("record-stream-change", params, socket) do
record? = params["value"] == "on"

{:noreply,
socket
|> assign(publisher: %Publisher{socket.assigns.publisher | record?: record?})}
end

@impl true
def handle_event("offer", unsigned_params, socket) do
%{publisher: publisher} = socket.assigns
offer = SessionDescription.from_json(unsigned_params)
{:ok, pc} = spawn_peer_connection(socket)
Process.monitor(pc)

:ok = PeerConnection.set_remote_description(pc, offer)

Expand Down
8 changes: 6 additions & 2 deletions mix.exs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
defmodule LiveExWebrtc.MixProject do
defmodule LiveExWebRTC.MixProject do
use Mix.Project

@version "0.6.0"
Expand Down Expand Up @@ -42,7 +42,11 @@ defmodule LiveExWebrtc.MixProject do
[
{:phoenix_live_view, "~> 1.0"},
{:jason, "~> 1.0"},
{:ex_webrtc, "~> 0.8.0"},
# {:ex_webrtc, "~> 0.8.0"},
{:ex_webrtc, github: "elixir-webrtc/ex_webrtc", override: true},
{:ex_webrtc_recorder, github: "elixir-webrtc/ex_webrtc_recorder"},

# Dev deps
{:ex_doc, "~> 0.31", only: :dev, runtime: false}
]
end
Expand Down
Loading