Crafting the Ultimate iOS Video Player: BONUS ✨— Watch Party Integration

Ajay Kumar
10 min readMay 31, 2024

--

Watch Party 🎉

In this bonus article of our Crafting the Ultimate iOS Video Player series, we’ll explore the exciting world of watch party integration using Firebase Realtime Database. By the end of this article, you’ll have the knowledge to create a feature that allows users to watch videos together in real-time, enhancing the social experience of your iOS app. Let’s dive into the realm of watch parties!

Before we dive into watch party integration, make sure to check out all 4 parts of Crafting the Ultimate iOS Video Player series if you haven’t already.

Part 1 — Mastering Custom Control Setup

Part 2 — Demystifying Subtitle Handling

Part 3 — Exploring Video Quality Selection

Part 4 — Elevating Your Player with Live Content Support

Firebase Integration

To integrate a watch party feature using Firebase Realtime Database, the first step is to set up a Firebase app.

Follow these steps to create and configure your Firebase app:

  1. Visit the firebase console
  2. Click on Add Project to create a new project
  3. Add an iOS App to your Project by adding your app’s bundle ID (e.g., com.example.MyVideoPlayerApp)
  4. Download the GoogleService-Info.plist and add it to root of your project (.xcworkspace)
Folder Structure

Add these 2 dependencies to your <custom-video-player-pod>.podspec and do a pod install

s.dependency 'Firebase'
s.dependency 'Firebase/Database'

Open your AppDelegate.swift file and initliaze firebase app

// ...
import Firebase

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

// ...

func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// ...
FirebaseApp.configure()
// ...
return true
}

Watch Party Config

To manage the settings and state of a watch party, we need a dedicated configuration structure. This will be separate from the existing video player configuration but will integrate seamlessly with it to enable watch party functionality. Let’s define and understand the WatchPartyConfig structure, which will hold essential information for managing a watch party session.

struct WatchPartyConfig {
var partyID: String?
var userID: String?
var partyLink: String?
var isHost: Bool?

init(partyID: String? = nil, userID: String? = nil, partyLink: String? = nil, isHost: Bool? = nil) {
self.partyID = partyID
self.userID = userID
self.partyLink = partyLink
self.isHost = isHost
}
}

Watch Party Controls

In addition to the existing playback controls, we are adding watch party specific controls like host party, copy link, show participants and leave party.

Initially, only the host party button will be visible. Once a party is successfully hosted, the user will see the remaining controls. The back button now will be replaced by leave party button.

Playback Controls before and after hosting a party

Additionally, there will be a notification view at the bottom to display watch party-specific notifications, such as users joining or leaving the party.

Here’s how the updated player controls view would look like:

@objc protocol PlayerControlsViewDelegate {
// Existing control delegates
/*
...
*/
// Watch party specific delegates
func hostWatchParty()
func leaveWatchParty()
func showParticipants()
func copyLink()
}

class PlayerControlsView: UIView {
weak var delegate: PlayerControlsViewDelegate?

// MARK: - Controls on Top

// Existing controls...

let watchPartyButton = UIButton().configure {
$0.setImage(VideoPlayerImage.watchPartyButton.uiImage, for: .normal)
$0.addTarget(self, action: #selector(hostWatchPartyTapped), for: .touchUpInside)
}

let participantsButton = UIButton().configure {
$0.setImage(VideoPlayerImage.participantsButton.uiImage, for: .normal)
$0.isHidden = true
$0.addTarget(self, action: #selector(showParticipantsTapped), for: .touchUpInside)
}

let copyLinkButton = UIButton().configure {
$0.setImage(VideoPlayerImage.copyLinkButton.uiImage, for: .normal)
$0.isHidden = true
$0.addTarget(self, action: #selector(copyLinkTapped), for: .touchUpInside)
}

let leavePartyButton = UIButton().configure {
$0.setImage(VideoPlayerImage.leavePartyButton.uiImage, for: .normal)
$0.isHidden = true
$0.addTarget(self, action: #selector(leavePartyTapped), for: .touchUpInside)
}

// Existing controls...

// MARK: - Controls on Bottom

private let notificationView = UIView()

// Rest of the controls...
}

// MARK: - Player Control Views

// ...

// MARK: - Player Control Events

// ...

extension PlayerControlsView {
func hideWatchPartyFeatureButtons() {
participantsButton.isHidden = true
copyLinkButton.isHidden = true
leavePartyButton.isHidden = true
}

func unhideWatchPartyFeatureButtons() {
participantsButton.isHidden = false
copyLinkButton.isHidden = false
leavePartyButton.isHidden = false
}

func displayPlayerNotification(style: ToastStyle, message: String) {
notificationView.displayToast(style: style, message: message)
}
}

VideoPlayer ViewModel Initialization

To manage the state and functionality of a watch party, we leverage a delegate pattern and Firebase Realtime Database. This approach ensures real-time synchronization and smooth interaction between the video player and the watch party participants.

The WatchPartyDelegate protocol defines several methods that handle different watch party events. By implementing these methods, the delegate can respond to changes in the watch party state:

The view model will have an instance of the DatabaseReference with help of which we will make state updations in firebase db seamlessly.

// ...
import FirebaseDatabase

protocol WatchPartyDelegate: AnyObject {
func onWatchPartyEntered()
func onWatchPartyExited()
func onWatchPartyEnded()
func onPlayerStateUpdated()
func onCurrentTimeUpdated(_ currentTimeDurationText: String, _ currentTimeInSeconds: Float)
func onParticipantAdded(_ participantName: String)
func onParticipantRemoved(_ participantName: String)
}

public class VideoPlayerViewModel {
// ...

var watchPartyConfig: WatchPartyConfig?
var ref: DatabaseReference
weak var watchPartyDelegate: WatchPartyDelegate?

// ...

public init(videoPlayerConfig: VideoPlayerConfig, partyID: String? = nil, chatName: String? = nil) {
self.videoPlayerConfig = videoPlayerConfig
self.watchPartyConfig = WatchPartyConfig(partyID: partyID)
self.ref = Database.database().reference()
if let chatName = chatName {
self.joinParty(for: chatName)
}
}

// ...
}

Watch Party Features

To manage the watch party feature effectively, we will extend the VideoPlayerViewModel to include methods for hosting, joining, and leaving a watch party, as well as fetching participants.

Each of these methods will interact with Firebase Realtime Database to ensure real-time synchronization of party data.

  1. Hosting a Watch Party

When a user decides to host a watch party, we need to generate a unique party ID and user ID, set up the initial party configuration, and store it in the Firebase database. Here’s how it’s done:

extension VideoPlayerViewModel {
func hostParty(for host: String, currentTime: CMTime?) {
watchPartyConfig?.partyID = RandomUUIDGenerator.generateRandomUUID(length: 6)
watchPartyConfig?.userID = RandomUUIDGenerator.generateRandomUUID(length: 12)
watchPartyConfig?.isHost = true

if let partyID = watchPartyConfig?.partyID {
watchPartyConfig?.partyLink = Environment.shared.getWatchPartyDeeplink(partyID)
}

guard let partyID = watchPartyConfig?.partyID, let userID = watchPartyConfig?.userID, let partyLink = watchPartyConfig?.partyLink, let videoURL = url?.description, let subtitleLabelText = subtitleLabelText, let currentTime = currentTime else { return }

setupWatchPartyObservers()
watchPartyDelegate?.onWatchPartyEntered()

self.ref.child("parties").child(partyID).setValue([
"partyLink": partyLink,
"participants": [
userID : [
"username": "\(host)",
"type": "Host"
]
],
"videoSetting": [
"isPlaying": playerState == .play,
"currentTimeDurationText": currentTime.durationText,
"currentTimeInSeconds": currentTime.seconds,
"url": videoURL,
"title": titleLabelText,
"subtitle": subtitleLabelText
]
])
}
}

2. Joining a Watch Party

To join an existing watch party, the user needs to be added to the participants list in Firebase:

extension VideoPlayerViewModel {
func joinParty(for participant: String) {
watchPartyConfig?.userID = RandomUUIDGenerator.generateRandomUUID(length: 12)
watchPartyConfig?.isHost = false

guard let partyID = watchPartyConfig?.partyID, let userID = watchPartyConfig?.userID else { return }

setupWatchPartyObservers()

let newParticipant: [String: String] = [
"username": participant,
"type": "Participant"
]

let participantsRef = self.ref.child("parties").child(partyID).child("participants")
let childUpdates = [userID: newParticipant]
participantsRef.updateChildValues(childUpdates)
}
}

3. Leaving a Watch Party

When a user leaves a watch party, we need to handle it differently based on whether the user is the host or a participant:

extension VideoPlayerViewModel {
func leaveParty() {
guard let partyID = watchPartyConfig?.partyID, let userID = watchPartyConfig?.userID else { return }

self.ref.child("parties").child(partyID).observeSingleEvent(of: .value) { [weak self] (snapshot) in
guard let self = self,
let roomData = snapshot.value as? [String: Any],
let participants = roomData["participants"] as? [String: [String: Any]] else {
return
}

self.watchPartyDelegate?.onWatchPartyExited()

if participants.first(where: { $0.value["type"] as? String == "Host" && $0.key == userID }) != nil {
self.ref.child("parties").child(partyID).removeValue()
} else {
var updatedParticipants: [String: [String: Any]] = participants
updatedParticipants.removeValue(forKey: userID)
self.ref.child("parties").child(partyID).child("participants").setValue(updatedParticipants)
}
}
}
}

4. Fetching Participants

To display the list of participants in the watch party, we need a method that fetches participant data from Firebase:

extension VideoPlayerViewModel {
func fetchParticipants(completion: @escaping ([String]?) -> Void) {
guard let partyID = watchPartyConfig?.partyID else {
completion(nil)
return
}

self.ref.child("parties").child(partyID).child("participants").observeSingleEvent(of: .value) { (snapshot) in
guard let participantsData = snapshot.value as? [String: [String: Any]] else {
completion(nil)
return
}

let participantUsernames = participantsData.values.compactMap { participant -> String? in
guard let username = participant["username"] as? String else {
return nil
}
return username
}

completion(participantUsernames)
}
}
}

Watch Party State Updates through Observers

To ensure that all participants in the watch party have a synchronized and seamless experience, we need to set up observers that track and update the state of the video player in real-time.

This involves monitoring the playback state, current playback time, and participant changes using Firebase Realtime Database.

  1. Updating Player State

When the play/pause state of the video player changes, we need to update this state in Firebase so that all participants receive the update:

extension VideoPlayerViewModel {
func updatePlayerState() {
guard let partyID = watchPartyConfig?.partyID else { return }
let videoSettingRef = self.ref.child("parties").child(partyID).child("videoSetting")
let childUpdates = ["isPlaying": playerState == .play]
videoSettingRef.updateChildValues(childUpdates)
}
}

2. Updating Current Playback Time

As the video plays, the current playback time needs to be continuously updated in Firebase:

extension VideoPlayerViewModel {
func updateCurrentTime(_ currentTimeDurationText: String, _ currentTimeInSeconds: Float) {
guard let partyID = watchPartyConfig?.partyID else { return }
let videoSettingRef = self.ref.child("parties").child(partyID).child("videoSetting")
let currentTimeDurationTextUpdates = ["currentTimeDurationText": currentTimeDurationText]
let currentTimeInSecondsUpdates = ["currentTimeInSeconds": currentTimeInSeconds]
videoSettingRef.updateChildValues(currentTimeDurationTextUpdates)
videoSettingRef.updateChildValues(currentTimeInSecondsUpdates)
}
}

3. Setting Up Watch Party Observers

To handle real-time updates and notifications, we set up various observers

extension VideoPlayerViewModel {
func setupWatchPartyObservers() {
setupPartyObserver()
setupPlayerStateObserver()
setupCurrentTimeObserver()
setupParticipantsObserver()
}

private func setupPartyObserver() {
guard let partyID = watchPartyConfig?.partyID else { return }
let partiesRef = self.ref.child("parties")

partiesRef.observe(.value, with: { [weak self] snapshot in
if !snapshot.hasChild(partyID), let isHost = self?.watchPartyConfig?.isHost, !isHost {
self?.watchPartyDelegate?.onWatchPartyEnded()
}
})
}

private func setupPlayerStateObserver() {
guard let partyID = watchPartyConfig?.partyID else { return }
ref.child("parties").child(partyID).child("videoSetting").observe(.value) { [weak self] (snapshot) in
if let value = snapshot.value as? [String: Any],
let isPlaying = value["isPlaying"] as? Bool {
if isPlaying && self?.playerState == .pause {
self?.watchPartyDelegate?.onPlayerStateUpdated()
} else if !isPlaying && self?.playerState == .play {
self?.watchPartyDelegate?.onPlayerStateUpdated()
}
}
}
}

private func setupCurrentTimeObserver() {
guard let partyID = watchPartyConfig?.partyID else { return }
ref.child("parties").child(partyID).child("videoSetting").observe(.value) { [weak self] (snapshot) in
if let value = snapshot.value as? [String: Any],
let currentTimeDurationText = value["currentTimeDurationText"] as? String,
let currentTimeInSeconds = value["currentTimeInSeconds"] as? Float {
self?.watchPartyDelegate?.onCurrentTimeUpdated(currentTimeDurationText, currentTimeInSeconds)
}
}
}

private func setupParticipantsObserver() {
guard let partyID = watchPartyConfig?.partyID else { return }
let participantsRef = ref.child("parties").child(partyID).child("participants")

participantsRef.observe(.childAdded, with: { [weak self] (snapshot) in
if snapshot.key != self?.watchPartyConfig?.userID, let participantData = snapshot.value as? [String: Any], let participantName = participantData["username"] as? String {
self?.watchPartyDelegate?.onParticipantAdded(participantName)
}
})

participantsRef.observe(.childRemoved, with: { [weak self] (snapshot) in
if snapshot.key != self?.watchPartyConfig?.userID, let participantData = snapshot.value as? [String: Any], let participantName = participantData["username"] as? String {
self?.watchPartyDelegate?.onParticipantRemoved(participantName)
}
})
}
}

VideoPlayer ViewController

In this section, we integrate the watch party functionality into the VideoPlayerViewController by conforming to the PlayerControlsViewDelegate and WatchPartyDelegate protocols.

PlayerControlsViewDelegate is responsible for defining the user actions on click of the player controls view — host party button, copy link, show participants and leave party.

extension VideoPlayerViewController: PlayerControlsViewDelegate {

// ...

func showParticipants() {
viewModel.fetchParticipants { [weak self] participants in
guard let participants = participants else { return }
self?.setupParticipantsView(with: participants)
guard let participantsView = self?.participantsView else { return }
self?.coordinator.navigationController.presentedViewController?.present(participantsView, animated: true)
}
}

func copyLink() {
playerControlsView.displayPlayerNotification(style: .success, message: "Party link has been successfully copied 🎉")
UIPasteboard.general.string = viewModel.watchPartyConfig?.partyLink
}

func switchSubtitles() {
guard let subtitleSelectionView = subtitleSelectionView else { return }
coordinator.navigationController.presentedViewController?.present(subtitleSelectionView, animated: true)
}

func hostWatchParty() {
coordinator.navigationController.presentedViewController?.present(hostWatchPartyAlert, animated: true, completion: nil)
}

func leaveWatchParty() {
coordinator.navigationController.presentedViewController?.present(leaveWatchPartyAlert, animated: true, completion: nil)
}

// ...
}

WatchPartyDelegate is responsible for handling various watch party events and update the user interface and player state accordingly.

extension VideoPlayerViewController: WatchPartyDelegate {
func onWatchPartyEntered() {
playerControlsView.unhideWatchPartyFeatureButtons()
playerControlsView.watchPartyButton.isEnabled = false
playerControlsView.backButton.setImage(VideoPlayerImage.leaveButton.uiImage, for: .normal)
playerControlsView.backButton.removeTarget(playerControlsView.self,
action: #selector(playerControlsView.backButtonTap),
for: .touchUpInside)
playerControlsView.backButton.addTarget(playerControlsView.self, action: #selector(playerControlsView.leaveWatchPartyButtonTap(_:)), for: .touchUpInside)
}

func onWatchPartyExited() {
viewModel.watchPartyConfig = nil
pausePlayer()
goBack()
}

func onWatchPartyEnded() {
viewModel.watchPartyConfig = nil
pausePlayer()
coordinator.navigationController.presentedViewController?.present(watchPartyEndedAlert, animated: true, completion: nil)
}

func onPlayerStateUpdated() {
togglePlayPause()
}

func onCurrentTimeUpdated(_ currentTimeDurationText: String, _ currentTimeInSeconds: Float) {
player?.seek(to: CMTimeMakeWithSeconds(Float64(currentTimeInSeconds), preferredTimescale: .max), toleranceBefore: CMTime.zero, toleranceAfter: CMTime.zero) { [weak self] _ in
guard let self = self else { return }
guard let currentTime = self.player?.currentItem?.currentTime() else { return }
self.playerControlsView.seekBarValue = Float(currentTime.seconds)
self.playerControlsView.currentTimeLabelText = currentTime.durationText + "/"
}
}

func onParticipantAdded(_ participantName: String) {
onParticipantsUpdated()
self.showControls()
self.playerControlsView.displayPlayerNotification(style: .success, message: "\(participantName) has joined the party!")
}

func onParticipantRemoved(_ participantName: String) {
onParticipantsUpdated()
self.showControls()
self.playerControlsView.displayPlayerNotification(style: .error, message: "\(participantName) has left the party!")
}

func onParticipantsUpdated() {
if let isHost = viewModel.watchPartyConfig?.isHost, isHost {
guard let currentTime = player?.currentItem?.currentTime() else { return }
viewModel.updateCurrentTime(currentTime.durationText, Float(currentTime.seconds))
}
}
}

Deeplinks for joining a watch party

I’ve integrated deeplink functionality into the application to allow users to seamlessly join watch parties. This enables users to join watch parties directly from external sources, such as messages or notifications, by simply clicking on the provided deeplink.

Once clicked, the app interprets the deeplink, extracts the necessary information, such as the party ID, and initiates the process to join the corresponding watch party within the app interface.

import UIKit
import SnapKit
import Custom_Video_Player

enum Deeplink: String {
case play /// custom-video-player://video/play?id=<video_id>
case watchParty = "watch_party"/// custom-video-player://video/watch_party/join?=<party_id>
}

extension ViewController {
func handleDeeplink(_ deeplink: Deeplink, url: URL) {
switch deeplink {
case .play:
if let components = URLComponents(url: url, resolvingAgainstBaseURL: true),
components.host == "video",
let queryItems = components.queryItems {
for queryItem in queryItems {
if queryItem.name == "id", let _ = queryItem.value {
navigatoToVideoPlayer()
}
}
}
case .watchParty:
if let components = URLComponents(url: url, resolvingAgainstBaseURL: true),
components.host == "video",
components.path == "/watch_party/join",
let queryItems = components.queryItems {
for queryItem in queryItems {
if queryItem.name == "party_id", let partyID = queryItem.value {
viewModel?.partyID = partyID
setupJoinWatchPartyAlertView()
if let joinWatchPartyAlert = joinWatchPartyAlert {
navigationController?.present(joinWatchPartyAlert, animated: true)
}
}
}
}
}
}
}

Note: In addition to the described functionality, we have also designed corresponding user interface views and util functions. These include the watch party alert view asking user to enter chat name for the party, Participants View Controller, which serves as a presentable view for managing participants, a toast notification system used for displaying watch party notifications, random UUID generator, etc.

For a detailed insight into their implementation, you can refer to the codebase.

Firebase Realtime Database Structure

Firebase Realtime Database Structure on successful host of a watch party

In this installment of our Crafting the Ultimate iOS Video Player series, we’ve introduced the pivotal feature of integrating watch party functionality, enabling users to engage collaboratively while enjoying their favorite videos.

Stay tuned for the forthcoming articles where we’ll explore additional captivating topics.

And don’t forget to check out the complete source code on my GitHub repository — https://github.com/ajkmr7/Custom-Video-Player for hands-on exploration. Happy coding 🚀

--

--