Skip to main content
Subscribing allows you to receive and render tracks published by remote participants. The SDK supports both automatic and manual subscription modes.

Auto-Subscribe (Default)

By default, the SDK automatically subscribes to all published tracks:
let room = Room()
try await room.connect(url: url, token: token)

// Automatically subscribes to all remote tracks
Handle subscribed tracks:
class MyRoomDelegate: RoomDelegate {
    func room(_ room: Room, participant: RemoteParticipant, didSubscribeTrack publication: RemoteTrackPublication) {
        guard let track = publication.track else { return }
        
        if let videoTrack = track as? VideoTrack {
            // Render video track
            videoTrack.add(videoRenderer: videoView)
        } else if let audioTrack = track as? AudioTrack {
            // Audio plays automatically
            print("Subscribed to audio track: \(publication.name)")
        }
    }
}
Audio tracks are played automatically when subscribed. Video tracks require you to attach a video renderer.

Manual Subscribe

Disable auto-subscribe to manually control subscriptions:
let roomOptions = RoomOptions(
    autoSubscribe: false  // Disable automatic subscription
)

let room = Room(roomOptions: roomOptions)
try await room.connect(url: url, token: token)
Subscribe to specific tracks:
func room(_ room: Room, participant: RemoteParticipant, didPublishTrack publication: RemoteTrackPublication) {
    // Decide whether to subscribe
    if publication.source == .camera {
        // Subscribe to camera tracks
        Task {
            try await publication.set(subscribed: true)
        }
    } else if publication.source == .screenShareVideo {
        // Subscribe to screen shares
        Task {
            try await publication.set(subscribed: true)
        }
    }
    // Ignore other track types
}

Unsubscribing

Unsubscribe from a track:
try await publication.set(subscribed: false)

Subscription State

Check subscription state:
if publication.isSubscribed {
    print("Currently subscribed to track")
}

switch publication.subscriptionState {
case .subscribed:
    print("Subscribed")
case .notAllowed:
    print("Subscription not allowed by publisher")
case .unsubscribed:
    print("Not subscribed")
}

Subscription Events

Monitor subscription lifecycle:
class MyDelegate: RoomDelegate, ParticipantDelegate {
    // Track published (not yet subscribed)
    func room(_ room: Room, participant: RemoteParticipant, didPublishTrack publication: RemoteTrackPublication) {
        print("\(participant.identity!) published \(publication.source) track")
    }
    
    // Successfully subscribed
    func room(_ room: Room, participant: RemoteParticipant, didSubscribeTrack publication: RemoteTrackPublication) {
        print("Subscribed to \(publication.name)")
        
        if let videoTrack = publication.track as? VideoTrack {
            videoTrack.add(videoRenderer: videoView)
        }
    }
    
    // Unsubscribed from track
    func room(_ room: Room, participant: RemoteParticipant, didUnsubscribeTrack publication: RemoteTrackPublication) {
        print("Unsubscribed from \(publication.name)")
    }
    
    // Track unpublished
    func room(_ room: Room, participant: Participant, didUnpublishTrack publication: TrackPublication) {
        print("\(participant.identity ?? "unknown") unpublished track")
    }
    
    // Subscription failed
    func room(_ room: Room, participant: RemoteParticipant, didFailToSubscribeTrackWithSid trackSid: String, error: Error) {
        print("Failed to subscribe to track \(trackSid): \(error)")
    }
}

Track Settings

Control quality and bandwidth for subscribed video tracks.

Enable/Disable Track

Temporarily disable receiving data:
// Disable (stops receiving data)
try await publication.set(enabled: false)

// Enable (resumes receiving data)
try await publication.set(enabled: true)
Disabling a track is useful when a participant is off-screen. It stops receiving data to save bandwidth while keeping the subscription active.

Video Quality

For simulcast tracks, request a specific quality layer:
// Request high quality
try await publication.set(videoQuality: .high)

// Request medium quality
try await publication.set(videoQuality: .medium)

// Request low quality
try await publication.set(videoQuality: .low)

Preferred Dimensions

Request specific video dimensions:
let dimensions = Dimensions(width: 1280, height: 720)
try await publication.set(preferredDimensions: dimensions)
The server will send the closest available quality layer.

Preferred FPS

Request a specific frame rate:
try await publication.set(preferredFPS: 15)
Track settings can only be modified when adaptiveStream is disabled and the track is subscribed.

Adaptive Stream

Adaptive Stream automatically adjusts video quality based on view size and visibility.

Enabling Adaptive Stream

Enabled by default:
let roomOptions = RoomOptions(
    adaptiveStream: true  // Enabled by default
)

How It Works

  1. Tracks attached video renderers to determine visibility
  2. Measures the video view size
  3. Requests appropriate quality layer from the server
  4. Automatically disables tracks when views are not visible
let videoView = VideoView()
videoView.isAdaptiveStreamEnabled = true  // Enabled by default

if let videoTrack = publication.track as? VideoTrack {
    videoTrack.add(videoRenderer: videoView)
}

Custom Adaptive Stream Behavior

class CustomVideoRenderer: VideoRenderer {
    var isAdaptiveStreamEnabled: Bool = true
    
    var adaptiveStreamSize: CGSize {
        // Return current view size
        return myView.bounds.size
    }
    
    // ... implement other VideoRenderer methods
}

Disabling Adaptive Stream

// Disable globally
let roomOptions = RoomOptions(
    adaptiveStream: false
)

// Disable per-view
videoView.isAdaptiveStreamEnabled = false

Stream State

Monitor whether the track is actively receiving data:
switch publication.streamState {
case .active:
    print("Track is streaming")
case .paused:
    print("Track is paused (disabled or not sending)")
case .unknown:
    print("Stream state unknown")
}
Receive stream state updates:
func room(_ room: Room, participant: RemoteParticipant, trackPublication: RemoteTrackPublication, didUpdateStreamState streamState: StreamState) {
    if streamState == .paused {
        showLoadingIndicator()
    } else {
        hideLoadingIndicator()
    }
}

Subscription Permissions

Publishers can control who subscribes to their tracks:
// Check if subscription is allowed
if publication.isSubscriptionAllowed {
    try await publication.set(subscribed: true)
} else {
    print("Not allowed to subscribe to this track")
}
Monitor permission changes:
func room(_ room: Room, participant: RemoteParticipant, trackPublication: RemoteTrackPublication, didUpdateIsSubscriptionAllowed isAllowed: Bool) {
    if isAllowed {
        print("Now allowed to subscribe")
        Task {
            try await trackPublication.set(subscribed: true)
        }
    } else {
        print("Subscription permission revoked")
    }
}

Rendering Video Tracks

Using VideoView (UIKit/AppKit)

import LiveKit

let videoView = VideoView()
videoView.layoutMode = .fill  // or .fit

if let videoTrack = publication.track as? VideoTrack {
    videoTrack.add(videoRenderer: videoView)
}

// Remove when done
videoTrack.remove(videoRenderer: videoView)

Using SwiftUIVideoView

import SwiftUI
import LiveKit

struct ParticipantView: View {
    let publication: RemoteTrackPublication
    
    var body: some View {
        if let track = publication.track as? VideoTrack {
            SwiftUIVideoView(track: track)
                .aspectRatio(track.aspectRatio, contentMode: .fit)
        } else {
            Text("No video track")
        }
    }
}

Custom Video Renderer

Implement your own video renderer:
class MyVideoRenderer: NSObject, VideoRenderer {
    var isAdaptiveStreamEnabled: Bool = true
    var adaptiveStreamSize: CGSize = .zero
    
    func set(size: CGSize) {
        adaptiveStreamSize = size
    }
    
    func renderFrame(_ frame: VideoFrame?) {
        guard let frame = frame else { return }
        // Process and render the frame
        processVideoFrame(frame)
    }
}

let renderer = MyVideoRenderer()
videoTrack.add(videoRenderer: renderer)

Handling Audio Tracks

Audio tracks play automatically:
func room(_ room: Room, participant: RemoteParticipant, didSubscribeTrack publication: RemoteTrackPublication) {
    if let audioTrack = publication.track as? AudioTrack {
        // Audio plays automatically through system audio output
        print("Audio track subscribed: \(publication.name)")
        
        // Optional: Add custom audio renderer for processing
        audioTrack.add(audioRenderer: myCustomAudioRenderer)
    }
}

Track Mute State

Remote tracks can be muted by the publisher:
if publication.isMuted {
    print("Track is muted by publisher")
}
Monitor mute state changes:
func room(_ room: Room, participant: Participant, trackPublication: TrackPublication, didUpdateIsMuted isMuted: Bool) {
    if isMuted {
        showMutedIndicator()
    } else {
        hideMutedIndicator()
    }
}

Best Practices

  1. Use auto-subscribe for simple cases: Default behavior works well for most applications
  2. Manual subscribe for optimization: Disable auto-subscribe to control bandwidth usage
  3. Enable adaptive stream: Automatically optimizes quality based on view size
  4. Remove renderers: Always remove video renderers when views are removed from screen
  5. Handle all events: Implement subscribe, unsubscribe, and unpublish events
  6. Check subscription permissions: Verify isSubscriptionAllowed before subscribing
  7. Disable off-screen tracks: Use set(enabled: false) for tracks that aren’t visible
  8. Monitor stream state: Show loading indicators when stream is paused
  9. Handle failures: Implement didFailToSubscribeTrackWithSid delegate method

Example: Complete Subscriber

class ParticipantViewController: UIViewController, RoomDelegate, ParticipantDelegate {
    let videoView = VideoView()
    var currentVideoTrack: VideoTrack?
    
    override func viewDidLoad() {
        super.viewDidLoad()
        view.addSubview(videoView)
    }
    
    func room(_ room: Room, participantDidJoin participant: RemoteParticipant) {
        participant.delegates.add(delegate: self)
    }
    
    func room(_ room: Room, participant: RemoteParticipant, didPublishTrack publication: RemoteTrackPublication) {
        print("Published: \(publication.name)")
    }
    
    func room(_ room: Room, participant: RemoteParticipant, didSubscribeTrack publication: RemoteTrackPublication) {
        guard let track = publication.track else { return }
        
        if let videoTrack = track as? VideoTrack {
            currentVideoTrack = videoTrack
            videoTrack.add(videoRenderer: videoView)
        } else if let audioTrack = track as? AudioTrack {
            print("Audio track subscribed")
        }
    }
    
    func room(_ room: Room, participant: RemoteParticipant, didUnsubscribeTrack publication: RemoteTrackPublication) {
        if let videoTrack = publication.track as? VideoTrack {
            videoTrack.remove(videoRenderer: videoView)
            currentVideoTrack = nil
        }
    }
    
    func room(_ room: Room, participant: RemoteParticipant, trackPublication: RemoteTrackPublication, didUpdateStreamState streamState: StreamState) {
        videoView.alpha = streamState == .active ? 1.0 : 0.5
    }
}

Build docs developers (and LLMs) love