PHP

Live Video Streaming from Laravel PHP using LiveKit

Photo by Vlada Karpovich on Pexels

The Auction Events Platform is a Laravel PHP web application streaming live video from an auction event, using the LiveKit Open source WebRTC infrastructure.

The Auction Events Platform for Creators

The platform allows creators to run a live auction which is a hybrid event, with bidders in the auction hall and online. Online bidders watch a live video and audio stream of the auction hall and bid via a web application. An auction event can be purely virtual with no auction hall.

An auction may run for a few hours with items coming up for bidding in sequence. A creator runs the auction, streaming it from a camera and microphone attached to the creator's computer.

The Platform Architecture

The platform is a web application built with the TALL stack:

  1. 1. Laravel
  2. 2. Livewire
  3. 3. Alpine.js
  4. 4. Tailwind

LiveKit

LiveKit is an open-source platform for live audio and video. It works by creating rooms in which participants can join and then publish video and audio tracks. Other participants can subscribe to tracks published in the room and stream the track to an HTML video and audio tags.

Running LiveKit

First, you need to obtain the API key and secret and generate an initial configuration file, livekit.yaml, by running:

docker run --rm -v$PWD:/output livekit/generate --local

To operate a LiveKit in the simplest way possible, you run a docker container,

docker run --rm -p 7880:7880 \
    -p 7881:7881 \
    -p 7882:7882/udp \
    -v $PWD/livekit.yaml:/livekit.yaml \
    livekit/livekit-server \
    --config /livekit.yaml \
    --node-ip <your local IP>

To interact with LiveKit in any way, such as to create a room or connect to a room, participants need to get an access token. An access token carries certain permissions, such as the ability to create rooms, subscribe to tracks, and publish tracks.

Using LiveKit for Live Streaming of Auctions

In the Auction Events Platform for Creators, the platform generates the rooms a few hours before the auction. So the creators can get the setup right. The platform deletes the room a short while after the auction is completed.

Creators publish video and audio, and bidders subscribe to the tracks and watch them.

To integrate LiveKit into our platform, we use the

  1. 1. On the server side - PHP Server SDK to LiveKit
  2. 2. On the client side - LiveKit browser client SDK (javascript)

We use the following LiveKit configuration:


port: 7880
rtc:
    udp_port: 7882
    tcp_port: 7881
    use_external_ip: false
room:
    auto_create: false
keys:
    xxxxxxx: yyyyyy
logging:
    json: true
    level: debug
    pion_level: debug
webhook:
    api_key: xxxxxxxx
    urls:
        - <our webhooks url>

Since the platform creates the rooms, we set auto_create to false, preventing a participant from creating a room upon joining it.

Issue Access Tokens

We have two types of users:

  1. 1. Creators that publish video and/or audio
  2. 2. Bidders that subscribe to the video and/or audio

Consequently, we generate using the PHP SDK access token as:

 
use Agence104\LiveKit\AccessToken;
use Agence104\LiveKit\AccessTokenOptions;
use Agence104\LiveKit\VideoGrant;

 // Define the token options.
 $token_options = (new AccessTokenOptions())
   ->setIdentity($participant_name)
   ->setTtl($key_expiry_seconds);

// Define the video grants.
$video_grant = (new VideoGrant())
  ->setRoomJoin(true) // TRUE by default.
        ->setRoomName($room_name)
    ->setCanPublish($is_creator) // only creators can publish
        ->setCanSubscribe(true) // TRUE by default.
        ->setRoomCreate(false); 


// Initialize and fetch the JWT access token. 
$token = (new AccessToken())
    ->init($token_options)
    ->setGrant($video_grant)
    ->toJwt();

Create and Delete Rooms

On the server side, using the PHP SDK,

Create Room


use Agence104\LiveKit\RoomServiceClient;
use Agence104\LiveKit\RoomCreateOptions;

$opts = (new RoomCreateOptions())
    ->setName($room_name)
    ->setMaxParticipants(env('MAX_LIVEKIT_ROOM_PARTICIPANTS'))
    ->setEmptyTimeout(env('LIVEKIT_EMPTY_ROOM_TIMEOUT_SECONDS'));
$room = $svc->createRoom($opts);

Delete Room


$host = env('LIVEKIT_URL');
$svc = new RoomServiceClient($host);
$rsp = $svc->deleteRoom($room_name);

Webhooks

We use webhooks to listen to events in the room, such as participants joining and leaving, to control the room capacity.

Routing

The webhook part of the configuration points to a controller action through a webhook.php file in routes,


use Illuminate\Support\Facades\Route;
use Illuminate\Http\Request;
use App\Http\Controllers\LiveKitWebhookController;

Route::post('handler', LiveKitWebhookController::class)->name('livekit_webhook');

Processing the Call

We have a single action controller using the PHP SDK WebhookReceiver,


<?php

namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Agence104\LiveKit\WebhookReceiver;

class LiveKitWebhookController extends Controller {
    /**
     * Handle the incoming request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \Illuminate\Http\Response
     */
    public function __invoke(Request $request)
    {
        $content = $request->getContent(); 
        $header = $request->header('authorization');
        $receiver = new WebhookReceiver(env('LIVEKIT_API_KEY'), env('LIVEKIT_API_SECRET'));
        $data = $receiver->receive($content, $header);
        $event = $data->getEvent();

        switch($event)
        {
            case 'participant_joined':

            case 'participant_left':
        }
    }
}

Front-End

The front-end is a close integration of Livewire components that run on the server and Alpine.js that runs on the browser. Alpine.js integrates the LiveKit Client JS SDK to connect to rooms and publish and subscribe.

We import LiveKit into app.js,


import {
  connect,
  createLocalTracks,
  createLocalVideoTrack,
  createLocalAudioTrack,
  Room,
  RoomEvent,
  RemoteParticipant,
  RemoteTrackPublication,
  RemoteTrack,
  Participant,
  RoomOptions,
  VideoPresets,
  RoomConnectOptions,
  ParticipantEvent,
  MediaDeviceFailure,
  MediaDevicesError,
  MediaDevicesChanged,
  RoomMetadataChanged,
  Track,
  VideoQuality,
} from 'livekit-client';

The container of the video and audio elements sets the LiveKit configuration in the browser in x-data, then initializes the device list and emits a rendered event.


<div class="py-4 bg-white" 
    x-data="av(true)" 
    x-init="acquireDeviceList(); $nextTick(() => { Livewire.emit('rendered') });" 
>

The Livewire component, on receiving the event, runs code to check permissions and status of the auction and only then gets the access token and LiveKit URL for the connection and sends it to the browser with dispatchBrowserEvent,

$this->set_user_access_token($this->auction, true);
$this->dispatchBrowserEvent('connect-live', [
    'url' => $this->livekit_url, 
    'token' => $this->livekit_access_token
]); 

The Alpine.js, on receiving the connect-live event, connects the room.

     
@connect-live.window="(event) => connectToRoom(event.detail.url, 
event.detail.token, false)" 

The connectToRoom works via the Client JS SDK.

Because we need the Livewire component to check certain conditions, such as if there is a live stream, we do not connect to the room automatically. We use nextTick to assure that the component is rendered before we send the event to Livewire. In this way, when Livewire dispatches an event to Alpine.js, the listener is active in the HTML.

Connecting to A Room in JavaScript


async connectToRoom(url, token, forceTURN) {
  this.token = token;
  this.url = url;

  const roomOptions = 
      this.isPublisher ?
      {
        adaptiveStream: true,
        dynacast: true,
        publishDefaults: {
          simulcast: true,
          videoSimulcastLayers: [
            VideoPresets.h90, 
            VideoPresets.h180, 
            VideoPresets.h216, 
            VideoPresets.h360, VideoPresets.h540
          ],
          videoCodec: this.selectedVideoCodec,
        },
        videoCaptureDefaults: {
          resolution: this.selectedVideoResolution.id,
        },
      }
      :
      {};

  const connectOptions = {
    autoSubscribe: true,
    publishOnly: undefined,
  };
  if (forceTURN) {
    connectOptions.rtcConfig = {
      iceTransportPolicy: 'relay',
    };
  }

  try {
    await room.connect(this.url, this.token, connectOptions);
    this.currentRoom = room;
    window.currentRoom = room;  
  } catch (error) {
    message = error;
    if (error.message) {
      message = error.message;
    }
    console.log('could not connect:', message);
  } finally {
    if (message) {
      Livewire.emit('connectionFailure');
    }

    return room;
}

Publishing a Video


await this.currentRoom?.localParticipant.setCameraEnabled(true);

const videoElm = this.$refs.video;

this.videoTrack = await createLocalVideoTrack();
this.videoTrack?.attach(videoElm);

Subscribing to a Track

Once the track is published, we render it,


renderParticipant(participant) {

  const cameraPub = participant.getTrack(Track.Source.Camera);
  const micPub = participant.getTrack(Track.Source.Microphone);


  if (cameraPub) {

    const cameraEnabled = cameraPub && cameraPub.isSubscribed && 
       !cameraPub.isMuted;

    if (cameraEnabled) {
      const videoElm = this.$refs.video;
      this.attachTrack(cameraPub?.videoTrack, 'video');
    } 

  }

  if (micPub) {
    const micEnabled = micPub && micPub.isSubscribed && !micPub.isMuted;

    if (micEnabled) {
      const audioElm = this.$refs.audio;
      this.attachTrack(micPub?.audioTrack, 'audio');
    }      
  }

},

It was Tricky

The integration involved a complicated interplay of server and client code. And the client code was an interplay of PHP and JavaScript.

We love LiveKit and hope to carry this into production. So far, it was a single Docker container in a local environment. We have lots of work to do on the production setup.

Yoram Kornatzky

Yoram Kornatzky