Exploring Stream's Video SDK: Upgrading WWDC Watch Party with Custom Features

In the previous article, we explored how to build a basic WWDC Watch Party app using Stream's Video SDK.

Exploring Stream’s Video SDK: Creating a WWDC Watch Party App
Build a WWDC 2024 Watch Party App using Stream’s Video SDK. Implement video playback, calling features, and synchronize playback for seamless group viewing. Dive into Stream’s powerful tools to create interactive experiences for Apple developers and elevate your WWDC experience.

We covered essential features such as setting up the project, playing WWDC session videos, enabling real-time video calling between participants, and synchronizing playback across devices. While the basic app provided a good foundation, we can build upon that user experience to create a a better engaging and interactive watch party.

Imagine being able to customize the look and feel of your watch party app to match the brand style of WWDC 2024. It is fun to see the participants' faces lit when they use reactions during the WWDC sessions. And the convenience of receiving push notifications about important events, such as when a new session is starting or when a friend joins the watch party.

In this article, we will explore more about Stream's Video SDK and how to upgrade your WWDC watch party app!

This post is sponsored by Stream, but all views expressed are my own.

You can find the final project here:

GitHub - rudrankriyam/WWDC-Watch-Party: Sample project for Stream Video SDK
Sample project for Stream Video SDK. Contribute to rudrankriyam/WWDC-Watch-Party development by creating an account on GitHub.

We will cover three main areas:

  1. Customizing the User Interface: The Stream's Video SDK has a lot to offer when it comes to UI customization. We can tailor the app's appearance using simple theming options, or go further by swapping out entire views with custom components.
  2. Adding Reactions: We will engage participants by allowing them to express their emotions and interact with each other using reactions. We will explore how to send and display reactions within the watch party, adding a fun element to the experience.
  3. Implementing Push Notifications: Keeping participants informed and engaged even when they are not actively using the app. We will go through the process of setting up and sending push notifications for various events, such as when a new session starts or when a participant joins the watch party.

By the end of this article, you will have the knowledge about working with Stream Video SDK and create a better featured, visually appealing watch party app that participants will love. Let's get started!

Theming with the Stream Video SDK

The SDK by Stream provides up with options to theme our app to match the branding. We will create a custom theme that matches the WWDC 2024 style and apply it to the StreamVideoUI.

First, let us create a new file called WWDC24Theme.swift and add the following code:

import SwiftUI
import StreamVideo
import StreamVideoSwiftUI

struct WWDC24Theme {
  static func createAppearance() -> Appearance {
    var colors = Colors()

    let gradientColors: [UIColor] = [.red, .purple, .blue, .cyan, .purple]
    let gradientColor = UIColor.gradient(colors: gradientColors, from: .leading, to: .trailing)

    colors.tintColor = Color(gradientColor)
    colors.text = .white
    colors.textInverted = .black
    colors.background = UIColor.black
    colors.background1 = UIColor(white: 0.1, alpha: 1.0)
    colors.textLowEmphasis = UIColor(white: 0.7, alpha: 1.0)
    colors.callBackground = UIColor.black
    colors.participantBackground = UIColor(white: 0.15, alpha: 1.0)
    colors.lobbyBackground = Color.black
    colors.lobbySecondaryBackground = Color(white: 0.15)
    colors.primaryButtonBackground = Color(gradientColor)
    colors.callControlsBackground = Color(white: 0.2)
    colors.livestreamBackground = Color.black.opacity(0.8)
    colors.participantInfoBackgroundColor = Color.black.opacity(0.6)

    var fonts = Fonts()
    fonts.caption1 = Font.custom("SFProDisplay-Regular", size: 12)
    fonts.footnoteBold = Font.custom("SFProDisplay-Bold", size: 13)
    fonts.footnote = Font.custom("SFProDisplay-Regular", size: 13)
    fonts.subheadline = Font.custom("SFProDisplay-Regular", size: 15)
    fonts.subheadlineBold = Font.custom("SFProDisplay-Bold", size: 15)
    fonts.body = Font.custom("SFProDisplay-Regular", size: 17)
    fonts.bodyBold = Font.custom("SFProDisplay-Bold", size: 17)
    fonts.bodyItalic = Font.custom("SFProDisplay-Italic", size: 17)
    fonts.headline = Font.custom("SFProDisplay-Semibold", size: 17)
    fonts.headlineBold = Font.custom("SFProDisplay-Bold", size: 17)
    fonts.title = Font.custom("SFProDisplay-Bold", size: 28)
    fonts.title2 = Font.custom("SFProDisplay-Regular", size: 22)
    fonts.title3 = Font.custom("SFProDisplay-Regular", size: 20)
    fonts.emoji = Font.custom("SFProDisplay-Regular", size: 50)

    return Appearance(colors: colors, fonts: fonts)
  }
}

extension UIColor {
  static func gradient(colors: [UIColor], from startPoint: UnitPoint, to endPoint: UnitPoint) -> UIColor {
    UIColor { traitCollection in
      let bounds = CGRect(x: 0, y: 0, width: 1, height: 1)
      let renderer = UIGraphicsImageRenderer(bounds: bounds)
      let image = renderer.image { context in
        let cgContext = context.cgContext
        let cgColors = colors.map { $0.cgColor }
        let colorSpace = CGColorSpaceCreateDeviceRGB()

        guard let gradient = CGGradient(colorsSpace: colorSpace, colors: cgColors as CFArray, locations: nil) else {
          return
        }

        let start = CGPoint(x: startPoint.x * bounds.width, y: startPoint.y * bounds.height)
        let end = CGPoint(x: endPoint.x * bounds.width, y: endPoint.y * bounds.height)

        cgContext.drawLinearGradient(gradient, start: start, end: end, options: [])
      }
      return UIColor(patternImage: image)
    }
  }
}

Now, let's update the SessionDetailView to use the StreamVideoUI instead of the StreamVideo:

struct SessionDetailView: View {
  let session: Session

  @State private var streamVideoUI: StreamVideoUI

  @State private var callCreated: Bool = false
  @State private var player: AVPlayer?
  @State private var syncTimer: Timer?
  private let token: String = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiS3lsZV9LYXRhcm4iLCJpc3MiOiJodHRwczovL3Byb250by5nZXRzdHJlYW0uaW8iLCJzdWIiOiJ1c2VyL0t5bGVfS2F0YXJuIiwiaWF0IjoxNzIxMjA0MTQ0LCJleHAiOjE3MjE4MDg5NDl9.uaM9VaHH3F5G2vg_d8949Bxko0z8u5iIZktYGeU6NNI"

  @ObservedObject var viewModel: CallViewModel

  init(session: Session) {
    self.session = session
    let appearance = WWDC24Theme.createAppearance()

    let streamVideo = StreamVideo(
      apiKey: "mmhfdzb5evj2",
      user: .init(id: UUID().uuidString, name: "Rudrank Riyam"),
      token: .init(stringLiteral: token)
    )

    streamVideoUI = StreamVideoUI(streamVideo: streamVideo, appearance: appearance)

    self.viewModel = .init()
  }

  var body: some View {
  ///
  }
}

In addition to customizing colors and fonts, you can also modify images and sounds used in the video calling experience throughout the Stream Video SDK components.

Customizing UI elements with the Stream Video SDK

The Stream Video SDK provides a range of options for customizing the user interface of your video application. Let's explore some of the key areas you can customize:

  1. Video renderer
    The VideoRendererView is a SwiftUI view that handles the rendering of video for a participant. You can customize its appearance and behavior by modifying the VideoRendererView initializer parameters:
VideoRendererView(id: participant.id, size: size) { videoRenderer in
    videoRenderer.handleViewRendering(for: participant, onTrackSizeUpdate: { _, _ in })
}
.frame(width: size.width, height: size.height)
.clipShape(RoundedRectangle(cornerRadius: 8))

In this example, we specify the size of the video renderer and apply a rounded rectangle clip shape to give it a visually appealing appearance.

  1. Participant view
    The ParticipantsView is responsible for displaying the list of participants in the video call. You can customize how each participant is represented by modifying the ForEach loop that iterates over the participants array:
ForEach(participants) { participant in
    VideoRendererView(id: participant.id, size: size) { videoRenderer in
        videoRenderer.handleViewRendering(for: participant, onTrackSizeUpdate: { _, _ in })
    }
    .frame(width: size.width, height: size.height)
    .clipShape(RoundedRectangle(cornerRadius: 8))
    .onAppear { onChangeTrackVisibility(participant, true) }
    .onDisappear { onChangeTrackVisibility(participant, false) }
}

Here, you can add additional UI elements or views to represent each participant, such as their name or avatar.

  1. Call controls
    You can create custom call control buttons and views to handle actions like muting audio, turning on/off video, leaving the call, etc. These controls can be added to the SessionDetailView or any other relevant view in your application.

For example, you can create a button to leave the call:

Button(action: {
    Task {
        await call.leave()
    }
}) {
    Text("Leave Call")
        .foregroundColor(.white)
        .padding()
        .background(Color.red)
        .cornerRadius(8)
}
  1. Layout and positioning
    By leveraging SwiftUI's layout system, you can customize the positioning and arrangement of various UI elements in your video application. You can use VStack, HStack, ZStack, and other layout containers to create the desired visual structure.

For instance, you can position the participant view at the bottom of the screen and the video renderer above it:

VStack {
    if let player {
        VideoPlayer(player: player)
            .frame(height: 300)
    }
    
    if let localParticipant = call.state.localParticipant {
        ParticipantsView(
            call: call,
            participants: [localParticipant] + call.state.remoteParticipants,
            onChangeTrackVisibility: changeTrackVisibility(_:isVisible:)
        )
    }
}

These are just a few examples of how you can customize the user interface using the Stream Video SDK. The SDK provides a flexible foundation that allows you to create a unique and tailored user experience for your video application.

Adding Reactions!

Watching WWDC sessions together is exciting, and what better way to share that excitement than with real-time reactions? Stream's Video SDK makes it easy to implement this fun and interactive feature. Let us explore how to add reactions to our watch party app.

First, we will define a set of reactions that participants can use:

enum Reaction: String, CaseIterable {
  case thumbsUp = "👍"
  case heart = "❤️"
  case party = "🎉"
  case mindBlown = "🤯"
  case questionMark = "❓"

  init?(emojiCode: String) {
    switch emojiCode {
      case ":thumbsup:": self = .thumbsUp
      case ":heart:": self = .heart
      case ":tada:": self = .party
      case ":exploding_head:": self = .mindBlown
      case ":question:": self = .questionMark
      default: return nil
    }
  }

  var emojiCode: String {
    switch self {
      case .thumbsUp: return ":thumbsup:"
      case .heart: return ":heart:"
      case .party: return ":tada:"
      case .mindBlown: return ":exploding_head:"
      case .questionMark: return ":question:"
    }
  }
}

Next, we will create a view for displaying reaction buttons:

struct ReactionButtonsView: View {
    let onReactionSent: (Reaction) -> Void
    
    var body: some View {
        HStack {
            ForEach(Reaction.allCases, id: \.self) { reaction in
                Button(action: {
                    onReactionSent(reaction)
                }) {
                    Text(reaction.rawValue)
                        .font(.system(size: 24))
                        .padding(8)
                        .background(Color.gray.opacity(0.2))
                        .clipShape(Circle())
                }
            }
        }
    }
}

Now, let us add a method to send reactions using Stream's custom events:

 private func sendReaction(_ reaction: Reaction) {
    Task {
      do {
        let response = try await viewModel.call?.sendReaction(type: "call.reaction_new", emojiCode: reaction.emojiCode)
        debugPrint("Reaction sent successfully: \(String(describing: response?.reaction))")
      } catch {
        debugPrint("Error sending reaction: \(error)")
      }
    }
  }

To display reactions, we will create a view that shows incoming reactions and animates them:

struct ReactionView: View {
    let reaction: Reaction
    
    @State private var offset: CGFloat = 0
    @State private var opacity: Double = 1
    
    var body: some View {
        Text(reaction.rawValue)
            .font(.system(size: 40))
            .offset(y: offset)
            .opacity(opacity)
            .onAppear {
                withAnimation(.easeOut(duration: 2)) {
                    offset = -100
                    opacity = 0
                }
            }
    }
}

Finally, let us update our SessionDetailView to include these new reaction features:

struct SessionDetailView: View {
    // ... existing properties ...
    @State private var reactions: [Reaction] = []
    
    var body: some View {
        VStack {
            // ... existing video player and participants view ...
            
            ReactionButtonsView(onReactionSent: { reaction in
                sendReaction(reaction)
            })
            
            ZStack {
                ForEach(reactions.indices, id: \.self) { index in
                    ReactionView(reaction: reactions[index])
                        .transition(.opacity)
                }
            }
        }
        .onAppear {
            // ... existing onAppear code ...
            subscribeToReactionEvents()
        }
    }
    
  private func subscribeToReactionEvents() {
    Task {
      if let call = viewModel.call {
        for await event in call.subscribe(for: CallReactionEvent.self) {
          if let emojiCode = event.reaction.emojiCode, let reaction = Reaction(emojiCode: emojiCode) {
            self.reactions.append(reaction)
            DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
              self.reactions.removeFirst()
            }
          }
        }
      }
    }
  }
}

With these additions, participants can now send and see reactions during the watch party, making the experience more interactive and fun!

Implementing Push Notifications for Real-time Updates

To keep participants engaged even when they are not actively using the app, let's implement push notifications. We will use Stream's push notification support to alert users about new sessions starting or when friends join the watch party.

We will start by going to the Stream's dashboard and from there, select the Push Notifications menu option under Video & Audio:

Then, we go to our developer account and create a new key for the service of type Apple Push Notifications service.

After downloading the key, noting down the Bundle, Team and Key ID, put these values in the Stream's dashboard and click create:

To implement push notifications in a SwiftUI app without a traditional AppDelegate, we will use the @UIApplicationDelegateAdaptor property wrapper. This allows you to create a class that conforms to UIApplicationDelegate and use it within the SwiftUI app.

Here is how we can modify the WWDCWatchPartyApp.swift file to include push notification handling. First, we will create a new class that conforms to UIApplicationDelegate:

import UIKit
import UserNotifications

class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
  func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
    UNUserNotificationCenter.current().delegate = self
    return true
  }

  func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
    let tokenParts = deviceToken.map { data in String(format: "%02.2hhx", data) }
    let token = tokenParts.joined()
    debugPrint("Device Token: \(token)")

    UserDefaults.standard.set(token, forKey: "pushNotificationToken")
  }

  func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
    debugPrint("Failed to register for notifications: \(error)")
  }

  func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
    if let aps = userInfo["aps"] as? [String: Any] {
      if let alert = aps["alert"] as? [String: Any], let body = alert["body"] as? String {
        if body.contains("New session starting") {
          NotificationCenter.default.post(name: .newSessionStarting, object: nil)
        } else if body.contains("joined the watch party") {
          NotificationCenter.default.post(name: .participantJoined, object: nil)
        }
      }
    }

    completionHandler(.newData)
  }
}

extension Notification.Name {
  static let newSessionStarting = Notification.Name("newSessionStarting")
  static let participantJoined = Notification.Name("participantJoined")
}

We will update the WWDCWatchPartyApp struct to use this AppDelegate:

@main
struct WWDCWatchPartyApp: App {
  @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate

  init() {
    registerForPushNotifications()
  }

  var body: some Scene {
    WindowGroup {
      SessionsView(sessions: .sampleSessions)
        .preferredColorScheme(.dark)
    }
  }

  private func registerForPushNotifications() {
    Task {
      let granted = try? await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge])
      guard let granted, granted else { return }
      UIApplication.shared.registerForRemoteNotifications()
    }
  }
}

To handle the notifications in the views, we can observe the NotificationCenter notifications we defined. For example, in the SessionsView:

struct SessionsView: View {
  let sessions: [Session]
  @State private var showNewSessionAlert = false
  
  var body: some View {
    NavigationStack {
      ScrollView {
        LazyVStack {
          ForEach(sessions) { session in
            NavigationLink(destination: SessionDetailView(session: session)) {
              VStack {
                AsyncImage(url: session.thumbnailURL) { image in
                  image
                    .resizable()
                    .aspectRatio(contentMode: .fit)
                    .cornerRadius(12)
                } placeholder: {
                  ProgressView()
                }
                
                Text(session.title)
                  .font(.headline)
                  .multilineTextAlignment(.center)
              }
            }
            .buttonStyle(.plain)
          }
        }
        .padding()
      }
      .navigationTitle("WWDC Sessions")
    }
    .onReceive(NotificationCenter.default.publisher(for: .newSessionStarting)) { _ in
      showNewSessionAlert = true
    }
    .alert(isPresented: $showNewSessionAlert) {
      Alert(title: Text("New Session Starting"), message: Text("A new WWDC session is about to begin!"), dismissButton: .default(Text("OK")))
    }
  }
}

#Preview("SessionsView") {
  SessionsView(sessions: .sampleSessions)
}

Now, let's register our device with Stream's backend:

private func registerDeviceForPushNotifications(with streamVideo: StreamVideo) {
  guard let token = UserDefaults.standard.string(forKey: "pushNotificationToken") else {
    print("No push notification token available")
    return
  }

  Task {
    do {
      try await streamVideo.setDevice(id: token)
      print("Device registered for push notifications")
    } catch {
      print("Error registering device: \(error)")
    }
  }
}

To send a push notification when a new session starts or a friend joins, we can use Stream's custom events along with their push notification system. Here is an example of how to send a notification when a new session starts:

func notifyNewSessionStarting(session: Session) {
    let customEventData: [String: RawJSON] = [
        "type": .string("newSession"),
        "sessionId": .string(String(session.id)),
        "sessionTitle": .string(session.title)
    ]
    
    Task {
        do {
            try await call.sendCustomEvent(customEventData)
            print("New session notification sent")
        } catch {
            print("Error sending new session notification: \(error)")
        }
    }
}

On the receiving end, you will need to handle the push notification in your app delegate:

func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
    if let aps = userInfo["aps"] as? [String: Any] {
        if let alert = aps["alert"] as? [String: Any], let body = alert["body"] as? String {
            // Handle the notification based on its content
            if body.contains("New session starting") {
                // Navigate to the new session
            } else if body.contains("joined the watch party") {
                // Update the participants list
            }
        }
    }
    completionHandler(.newData)
}

With these push notifications in place, users will know immediately when their friends join, encouraging them to jump back into the app. This turns viewers into active participants, upgrading the watch party experience for the year ahead of 100+ WWDC sessions!

Conclusion

In this article, we explored how to customise the WWDC Watch Party app using Stream's Video SDK. By implementing custom UI elements, interactive reactions, and push notifications, we created a more fun app to capture the excitement of WWDC sessions!

Find the final project here:

GitHub - rudrankriyam/WWDC-Watch-Party: Sample project for Stream Video SDK
Sample project for Stream Video SDK. Contribute to rudrankriyam/WWDC-Watch-Party development by creating an account on GitHub.

If you are interested in exploring Stream's Video SDK further, I encourage you to check out the official documentation and resources. There is also a blog post by Stream on creating an iOS live-streaming app with SwiftUI.

In the next article of this series, we will take our WWDC Watch Party app further: integrate their Chat SDK. This allows participants to discuss sessions in real-time, and connect with fellow developers! We will explore how Stream's Chat SDK can be combined with the Video SDK to create a fully interactive, all-in-one WWDC sessions companion app!