Live Video Streaming from Laravel PHP using LiveKit
Tags: laravel, php, streaming, auctions
Published: 2022-08-05 09:12:06

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. [Laravel](https://laravel.com)
2. [Livewire](https://laravel-livewire.com)
3. [Alpine.js](https://alpinejs.dev)
4. [Tailwind](https://tailwindcss.com)

# 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. On the server side - [PHP Server SDK to LiveKit](https://github.com/agence104/livekit-server-sdk-php)
2. On the client side - [LiveKit browser client SDK (javascript)](https://github.com/livekit/client-sdk-js) 

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. Creators that publish video and/or audio
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.