Video Player Integration
Native Apps Integration
iOS
Swift UI Integration

iOS Swift UI Integration

Pre-Requirements

Download and install XCODE (opens in a new tab). Only available in MacOS systems.

The source code of the next step-by-step guide can be downloaded here (opens in a new tab)

Steps

  1. Create new project using SwiftUI.
  2. Create HTMLTemplate file.
  3. Create WebViewCoordinator class.
  4. Create WebViewWrapper structure.
  5. Create VideoPlayerConfiguration structure.
  6. Create FullscreenContentView.
  7. Create Custom Betslip.
  8. Handle closing the player

Steps detailed

1. Create new project using SwiftUI.

Open XCode and create a new project.

File > New > Project or Shift + Command + N

swiftui-image

Make sure that the interface selected is SwiftUI.

2. Create HTMLTemplate file.

Create a file named HTMLTemplate.swift

Feel free to create this file wherever you find it convenient.

Create getHTMLString function

This function will interpolate dynamic values based on the configuration object. After this, it will return a string with the HTML that you want to render inside the WebView. You can take a look at our template definition here (opens in a new tab), and in order to get better understanding of this template feel free to read the breakdown section here.

3. WebViewCoordinator class.

Create WebViewCoordinator class file. Feel free to create this file wherever you find it convenient.

This class is responsible of managing the WebView instance and disabling the AVPlayer. We've done this because we want to use the player's custom control bar and functionality.

These are the functionalities that this class provides:

  • Ensuring the proper functioning of the video player when transitioning between non-fullscreen and fullscreen modes.

  • Handling the communication between the HTML content and the app. This communication is done with the userContentController function.

    func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
      // Handle the message received from JavaScript
      if let data = message.body as? [String : Any],
          let type = data["type"] {
        messageHandler?("\(type)", nil)
        if let payload = data["payload"] {
          messageHandler?("\(type)", payload)
        }
      }
    }

Your file should look like this:

import WebKit
 
class WebViewCoordinator: NSObject, ObservableObject, WKScriptMessageHandler, WKNavigationDelegate {
  @Published var webView: WKWebView
  var messageHandler: ((String, Any?) -> Void)?
 
  override init() {
    let configuration = WKWebViewConfiguration()
    configuration.allowsInlineMediaPlayback = true
    configuration.allowsPictureInPictureMediaPlayback = false
 
    let userContentController = WKUserContentController()
    configuration.userContentController = userContentController
 
    webView = WKWebView(frame: .zero, configuration: configuration)
    super.init()
    userContentController.add(self, name: "gsVideoPlayerBridge")
    webView.navigationDelegate = self
  }
 
  func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
    // Handle the message received from JavaScript
    if let data = message.body as? [String : Any],
       let type = data["type"] {
      messageHandler?("(type)", nil)
      if let payload = data["payload"] {
        messageHandler?("(type)", payload)
      }
    }
  }
}

4. Create WebViewWrapper structure.

Create WebViewWrapper class file. Place this file wherever you find it convenient.

This class is responsible for wrapping the WebView and configuring its default values.

  • The WebView has to be wrapped so that it can be used in the ContentView.
  • The WebView's scrollView is configured so that it does not bounce nor scrolls.
    webView.scrollView.bounces = false
    webView.scrollView.isScrollEnabled = false

Your code should look like this:

import SwiftUI
import WebKit
 
struct WebViewWrapper: UIViewRepresentable {
  let webView: WKWebView
 
  func makeUIView(context: Context) -> WKWebView {
    webView.scrollView.bounces = false
    webView.scrollView.isScrollEnabled = false
    webView.backgroundColor = .black
 
    return webView
  }
 
  func updateUIView(_ uiView: WKWebView, context: Context) {
    // No-op
  }
}

5. Create VideoPlayerConfiguration structure.

Create VideoPlayerConfiguration structure file. Place this file wherever you find it convenient.

This structure contains all parameters needed in the HTMLTemplate interpolation.

struct VideoPlayerConfiguration {
  var customerId: String = ""
  var fixtureId: String = ""
  var playerWidth: String = "100vw"
  var playerHeight: String = "100vh"
  var controlsEnabled: String = "true"
  var audioEnabled: String = "true"
  var allowFullScreen: String = "true"
  var bufferLength: String = "2"
  var autoplayEnabled: String = "true"
}

6. Create FullScreenContentView.

Create the FullScreenContentView file.

This file is in charge of using the WebViewCoordinator, injecting the HTML into the webview and handling fullscreen and device rotation controls.

a. Instantiate the WebViewCoordinator.

You should instantiate it as a private @StateObject

struct FullscreenContentView: View {
 @StateObject private var webViewCoordinator = WebViewCoordinator()
 ...
}

b. Add WebViewWrapper in the main view.

Add WebViewWrapper to the body.

var body: some View {
 GeometryReader { metrics in
   VStack(spacing: 0) {
     GeometryReader { geometry in
       ZStack {
         WebViewWrapper(webView: webViewCoordinator.webView)
           .frame(width: metrics.size.width, height: 300)
       }
     }
   }

c. Create Subscription and EventHandlers.

  • Device Rotation:

    You should create a function that tells the app to change orientation. This will be used when changing between fullscreen modes and when loading the views.

    func changeOrientation(to orientation: UIInterfaceOrientationMask) {
      // tell the app to change the orientation
      let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene
      windowScene?.requestGeometryUpdate(.iOS(interfaceOrientations: orientation))
      windowScene?.keyWindow?.rootViewController?.setNeedsUpdateOfSupportedInterfaceOrientations()
    }
  • Fullscreen state:

    • You should implement a function that Toggles the HTML video element's fullscreen as true, this should happen when the app is portrait and is presented.
    func onToggleFullscreen() {
      // This is needed to avoid unkown orientation value
      let isPortrait = UIDevice.current.orientation.rawValue == 0
      ? UIScreen.main.bounds.size.width < UIScreen.main.bounds.size.height
      : UIDevice.current.orientation.rawValue == 1
     
      if (!isPortrait && !isPresented) || (isPortrait && isPresented) {
        isPresented.toggle()
        let script = """
          document.querySelector(".video-container")?.classList.toggle("full-screen")
        """
        webViewCoordinator.webView.evaluateJavaScript(script)
      }
    }
  • Subscription and EventHandlers integration:

    • Create Message Handler

      Your "messageHandler" function should receive a type and use it to trigger the fullscreen state or the orientation change event.

      func messageHandler(type: String) {
        if (type == "toggleFullscreen") {
          changeOrientation(to: UIDevice.current.orientation.isLandscape ? .portrait : .landscape)
          isPresented.toggle()
        } else if (type == "init") {
          isVideoPlayerReady = true
          onToggleFullscreen()
        }
      }
    • Add the "messageHandler" to the view's body, also add events for onAppear, onReceive, fullScreenCover

      var body: some View {
        GeometryReader { metrics in
          VStack(spacing: 0) {
            GeometryReader { geometry in
              ZStack {
                WebViewWrapper(webView: webViewCoordinator.webView)
                  .frame(width: metrics.size.width, height: 300)
              }
            }
          }
          .onAppear {
            // Load initial web content
            webViewCoordinator.messageHandler = messageHandler
            updateVideoURL()
          }
          .onDisappear() {
            // Re-enable idle timer when the view disappears
            UIApplication.shared.isIdleTimerDisabled = false
          }
          .onReceive(rotationChangePublisher) { _ in
            if isVideoPlayerReady {
              onToggleFullscreen()
            }
          }
        }
      }

d. Interpolate HTMLTemplate with configuration object.

Call the getHTMLTemplate from the HTMLTemplate.swift function with an instance of the VideoPlayerConfiguration.

let configuration = VideoPlayerConfiguration()
let htmlString = getHTMLString(configuration: configuration)

e. Load processed HTML in the Webview.

You need a baseUrl, which can be any URL that is used to load the WebView. In our case, we use the one seen on the code "https://www.example.com"

let baseURL = "https://www.example.com"
webViewCoordinator.webView.loadHTMLString(
htmlString,
baseURL: URL(string: String(format: baseURL)))

f. Prevent device from locking when user isn't interacting :

In the video view it is recommended to avoid the automatic screen lock, so that the user enjoys a fluid experience without having to constantly touch the screen. In order to enable this option you must add the following function:

init() {
  UIApplication.shared.isIdleTimerDisabled = true
}

Also to keeps this feature alive, even when user mute or unmute the video player, we need to refresh the state of isIdleTimerDisabled behavior, to get this you need to declare this two vars:

@State private var isIdleTimerDisabled = false
 let timer = Timer.publish(every: 10, on: .main, in: .common).autoconnect()
}

Add the handler to set the isIdleTimerDisabled on the VStack component:

onReceive(timer) { _ in
    isIdleTimerDisabled.toggle()
    UIApplication.shared.isIdleTimerDisabled = isIdleTimerDisabled
}

g. Fullscreen Landscape Support with Notch Awareness [Optional] :

This step is optional. but if you want to respect the safe area which can reduce the video size, you have to follow this code version: View FullscreenContentView.swift on GitHub (opens in a new tab)

We want to offer the best fullscreen experience of the embedded video player when rotating the device to landscape mode. To ensure that the content should never hidden behind the device's notch (also known as the sensor housing or camera cutout) and that all interactive UI remains fully visible.

Key points:

  • Dynamic Notch Detection: The app now detects which side the notch is on (left or right) when the device is rotated into landscape.

  • Safe Area Handling: The video player automatically respects the safe area insets to prevent UI elements from being obstructed.

  • Consistent Fullscreen Behavior: Even when the orientation is changed programmatically or manually by the user, the layout adjusts in real time.

Device Compatibility:

  • iPhones with notch (X and newer): notch padding applied dynamically

  • iPhones without notch: no layout changes

  • iPads: unaffected, layout remains consistent

Why This Matters:

Without proper notch handling, key content (like buttons, stats, or the video stream) may be partially hidden or clipped on devices with a notch. This functionality ensures:

  • Improved usability

  • Better UX consistency

  • Correct video aspect ratio

Adding notch detection:

Let's create to types one for orientation source and other one to identify the position of the notch:

  enum NotchPosition: String {
    case left = "left"
    case right = "right"
    case top = "top"
    case none = "No detected notch"
  }
 
  enum OrientationSource: String {
    case programatically = "programatically"
    case accelerometer = "accelerometer"
    case none = "none"
  }

We need the following two funtions to get the safe areas and the notch detection logic:

  private func getSafeAreaInsets() -> UIEdgeInsets {
      guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
            let window = windowScene.windows.first else {
          return .zero
      }
      return window.safeAreaInsets
  }
 
  private func detectNotchSide(safeArea: UIEdgeInsets) -> NotchPosition {
  let orientation = UIDevice.current.orientation
  
 // Landscape left or programmatic - check left edge
    if ((orientation == .landscapeLeft  && safeArea.left > 0) || orientationSource == .programatically) {
      return .left
    }
   
  // Landscape right - check right edge
    if orientation == .landscapeRight && safeArea.right > 0 {
      return .right
    }
  
  // Face up/down - use previous notch position
    if orientation == .faceUp || orientation == .faceDown || orientation == .portraitUpsideDown {
        return notchUpdateChange
    }
    
    // Portrait - check top edge
    if orientation == .portrait && safeArea.top > 0 {
        return .top
    }
    // No notch detected
    return notchUpdateChange
}

Now we are going to add a state for the orientation source

  @State private var orientationSource = OrientationSource.none  
  @State private var notchUpdateChange  = NotchPosition.none

The orientationSource state should be assigned based in two situations:

  • when the user turns the phone from portrait to landscape. You need to assign the orientation as accelerometer in the onToggleFullscreen function
  func onToggleFullscreen() {
    // This is needed to avoid unkown orientation value
    let isPortrait = UIDevice.current.orientation.rawValue == 0 || UIDevice.current.orientation.isFlat
    ? UIScreen.main.bounds.size.width < UIScreen.main.bounds.size.height
    : UIDevice.current.orientation == .portrait || UIDevice.current.orientation.isFlat
 
    orientationSource = .accelerometer
    ...
  • when the user taps the fullscreen button. You need to assign the orientation as programatically in the changeOrientation function
  func changeOrientation(to orientation: UIInterfaceOrientationMask) {
    // tell the app to change the orientation
    let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene
    windowScene?.requestGeometryUpdate(.iOS(interfaceOrientations: orientation))
    windowScene?.keyWindow?.rootViewController?.setNeedsUpdateOfSupportedInterfaceOrientations()
 
    DispatchQueue.main.async {
      notchUpdateChange = detectNotchSide(safeArea: getSafeAreaInsets())
    }
 
    orientationSource = .programatically
  }

Now, let's add a in the onReceive to execute detectNotchSide side when a rotation occurs

   .onReceive(rotationChangePublisher) { _ in
        onToggleFullscreen()
        
        DispatchQueue.main.async {
            notchUpdateChange = detectNotchSide(safeArea: getSafeAreaInsets())
        }
      }
  ...

Finally you need to add the proper padding and ignore all safeAreaInsets in the ZStack

  ...
 
  .edgesIgnoringSafeArea(.all)
  .padding(.leading, notchUpdate == .left ? 1 : 0)
  .padding(.trailing, notchUpdate == .right ? 1 : 0)
  .padding(.top, notchUpdateChange == .top ? 1 : 0)  
  ...

Your code should look like this:

import SwiftUI
import WebKit
 
struct FullscreenContentView: View {
 
   @StateObject private var webViewCoordinator = WebViewCoordinator()
   @State private var url: String = ""
   @State private var isPresented = false
   @State private var showBetslip = false
   @State private var showToast = false
   @State private var fakeFixtureId: String = "10462057"
   @State private var immersiveFixtureId: String = ""
   @State private var environment: String = "PROD"
   @State private var culture: String = ""
   @State private var source: DeliveryStream = DeliveryStream(id:"",name:"DEFAULT")
   @State private var sources: Array<DeliveryStream> = []
   @State private var apiKey: String = ""
   @State private var customerId: String = ""
   @State private var showPlayerTracking: Bool = false
   @State private var triggerOnboarding: String = "Disabled"
   @State private var customer: VideoCustomer = VideoCustomersKt.videoCustomers[0]
   @StateObject private var betslipData = BetslipData()
   @StateObject private var betslipCoordinates = BetslipCoordinates()
   @State private var userEmail: String = ""
   @State private var deviceID: String = DeviceIDManager().deviceID
   @State private var isFullScreen = false
   @Environment(\.horizontalSizeClass) var horizontalSizeClass
   @State private var orientationSource = OrientationSource.none
   @State private var notchUpdateChange  = NotchPosition.none
   
   private let rotationChangePublisher = NotificationCenter.default
     .publisher(for: UIDevice.orientationDidChangeNotification)
   
   @State private var isIdleTimerDisabled = false
   let timer = Timer.publish(every: 10, on: .main, in: .common).autoconnect()
   
   @State private var selectedTab: Int = 0
   let tabs: [Tab] = [
     .init(title: "Video"),
     .init(title: "Stats")
   ]
   
   
   var body: some View {
     GeometryReader { metrics in
       ScrollView  {
           VStack(spacing: 0) {
           HeaderPlaceholder(geometry: metrics)
           GeometryReader { geometry in
             ZStack {
               VStack(spacing: 0) {
                 if(!isPresented){
                   Tabs(tabs: tabs, geoWidth: geometry.size.width, selectedTab: $selectedTab).background(Color.background)
                 }
                 TabView (selection: $selectedTab,
                         content: {
                   WebViewWrapper(webView: webViewCoordinator.webView)
                     .tag(0)
                     .gesture(isPresented ? DragGesture() : nil)
                   Text("Stats")
                     .tag(1)
                 })
                 .background(.black)
                 .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
               }
               
               if showBetslip {
                 CustomBetslip(betslipData: betslipData, showToast: $showToast, showBetslip: $showBetslip, betslipCoordinates: betslipCoordinates)
               }
               if showToast {
                 CustomToast(showToast: $showToast)
               }
             }
             .frame(width: metrics.size.width, height: geometry.size.height)
             .offset(x: 0, y: isPresented ? -geometry.frame(in: .global).minY : 0)
             
           }
           .frame(height: isPresented ? metrics.size.height : 300)
             LoadVideoForm(
               fakeFixtureId: $fakeFixtureId,
               immersiveFixtureId: $immersiveFixtureId,
               culture: $culture,
               environment: $environment,
               source: $source,
               sources: $sources,
               apiKey: $apiKey,
               customerId: $customerId,
               showPlayerTracking: $showPlayerTracking,
               triggerOnboarding: $triggerOnboarding,
               customer: $customer,
               userEmail: $userEmail,
               action: updateVideoURL
             )
             BodyPlaceholder()
         }
         .frame(height: metrics.size.height, alignment: .top)
         .background(Color.background)
         .onAppear {
           // Load initial web content
           webViewCoordinator.messageHandler = messageHandler
           updateVideoURL(reset: true)
         }
         .onDisappear {
           webViewCoordinator.onDisappear()
         }
         .onReceive(rotationChangePublisher) { _ in
           onToggleFullscreen()
           
           DispatchQueue.main.async {
             notchUpdateChange = detectNotchSide(safeArea: getSafeAreaInsets())
             }
         }.onReceive(timer) { _ in
           // Toggle the idle timer state
           isIdleTimerDisabled.toggle()
           UIApplication.shared.isIdleTimerDisabled = isIdleTimerDisabled
         }
       }
       .ignoresSafeArea(.all)
       .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)) { notification in
         guard let userInfo = notification.userInfo,
               let keyboardSize = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else {
           return
         }
         withAnimation {
           scrollViewBottomInset = keyboardSize.height
         }
       }
       .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)) { notification in
         withAnimation {
           scrollViewBottomInset = 0
         }
       }
       .padding(.bottom, keyboardHeight())
     
     }
     .onAppear {
         UIDevice.current.beginGeneratingDeviceOrientationNotifications()
         notchUpdateChange = detectNotchSide(safeArea: getSafeAreaInsets())
     }
     .edgesIgnoringSafeArea(.bottom)
     .ignoresSafeArea(.all)
     .padding(.leading, notchUpdateChange == .left ? 1 : 0)
     .padding(.trailing, notchUpdateChange == .right ? 1 : 0)
     .padding(.top, notchUpdateChange == .top ? 1 : 0)
     .preferredColorScheme(.dark)
     
   }
   @State private var scrollViewBottomInset: CGFloat = 0
   
   private func keyboardHeight() -> CGFloat {
       return scrollViewBottomInset
   }
   
   init() {
     UIApplication.shared.isIdleTimerDisabled = true
   }
   
   func onDismiss() {
     changeOrientation(to: .portrait)
   }
   
   private func getSafeAreaInsets() -> UIEdgeInsets {
       guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
             let window = windowScene.windows.first else {
           return .zero
       }
       return window.safeAreaInsets
   }
   
   private func detectNotchSide(safeArea: UIEdgeInsets) -> NotchPosition {
     let orientation = UIDevice.current.orientation
     
   // Landscape left or programmatic - check left edge
       if ((orientation == .landscapeLeft  && safeArea.left > 0) || orientationSource == .programatically) {
         return .left
       }
     
     // Landscape right - check right edge
       if orientation == .landscapeRight && safeArea.right > 0 {
         return .right
       }
     
     // Face up/down - use previous notch position
       if orientation == .faceUp || orientation == .faceDown || orientation == .portraitUpsideDown {
           return notchUpdateChange
       }
       
       // Portrait - check top edge
       if orientation == .portrait && safeArea.top > 0 {
           return .top
       }
       // No notch detected
       return notchUpdateChange
   }
   
   func messageHandler(type: String, payload: Any) {
     if (type == "toggleFullscreen") {
       withAnimation {
         isPresented.toggle()
       }
       changeOrientation(to: isPresented ? .landscape : .portrait)
     } else if (type == "init") {
       onToggleFullscreen()
     } else if (type == "multibet-event") {
       print(payload)
       var showCustomBetslip = false
       if let data = payload as? [String: Any] {
         if let newSportsbookFixtureId = data["sportsbookFixtureId"] as? String {
           betslipData.sportsbookFixtureId = "\(newSportsbookFixtureId)"
         }
         if let newSportsbookFixtureId = data["sportsbookSelectionId"] as? String {
           betslipData.sportsbookSelectionId = "\(newSportsbookFixtureId)"
         }
         if let newSportsbookFixtureId = data["sportsbookMarketId"] as? String {
           betslipData.sportsbookMarketId = "\(newSportsbookFixtureId)"
         }
         if let newSportsbookFixtureId = data["sportsbookMarketContext"] as? String {
           betslipData.sportsbookMarketContext = "\(newSportsbookFixtureId)"
         }
         if let newSportsbookFixtureId = data["marketId"] as? String {
           betslipData.marketId = "\(newSportsbookFixtureId)"
         }
         if let newDecimalPrice = data["decimalPrice"] as? Double {
           betslipData.decimalPrice = newDecimalPrice
         }
         if let newDecimalPrice = data["stake"] as? Double {
           betslipData.stake = "\(newDecimalPrice)"
           }
         if let command = data["command"] as? String {
             if (command == "closeBetslip") {
               showCustomBetslip = false
             }
             if (command == "openBetslip") {
               showCustomBetslip = true
             }
         }
         showBetslip = showCustomBetslip
       }
     } else if (type == "betslip-container-dimensions") {
       if let data = payload as? [String: Any] {
           if let newTop = data["top"] as? Int, let newWidth = data["width"] as? Int, let newHeight = data["height"] as? Int, let newLeft = data["left"] as? Int {
               betslipCoordinates.top = newTop
               betslipCoordinates.left = newLeft
               betslipCoordinates.width = newWidth
               betslipCoordinates.height = newHeight
           }
       }
     }
   }
   
   func onToggleFullscreen() {
     if(selectedTab == 0){
       // This is needed to avoid unkown orientation value
       let isPortrait = UIDevice.current.orientation.rawValue == 0 || UIDevice.current.orientation.isFlat
       ? UIScreen.main.bounds.size.width < UIScreen.main.bounds.size.height
       : UIDevice.current.orientation == .portrait || UIDevice.current.orientation.isFlat
       
       orientationSource = .accelerometer
       
       if (!isPortrait && !isPresented) || (isPortrait && isPresented) {
         hideKeyboard()
         isPresented.toggle()
         let script = """
         document.querySelector(".video-container")?.classList.toggle("full-screen")
       """
         webViewCoordinator.webView.evaluateJavaScript(script)
       }
     }
   }
   
   func updateConfiguration(configuration: VideoConfiguration) -> VideoConfiguration{
     let apiKeyValue = !apiKey.isEmpty ? apiKey : customer.getApiKey(env: environment)
     let customerIdValue = !customerId.isEmpty ? customerId : customer.id
     
     configuration.fixtureId = fakeFixtureId
     configuration.cgFixtureId = immersiveFixtureId
     configuration.customerId = customerIdValue
     configuration.apikey = apiKeyValue
     configuration.user = "dummyvideocustomer"
     configuration.password = "Bookmaker0987"
     configuration.environment = getVideoEnvironment()
     configuration.triggerOnboarding = triggerOnboarding.lowercased()
     configuration.deviceId = deviceID
     configuration.hashedUserEmail = sha256(userEmail)
     configuration.hashedUserId = sha256(deviceID + "_" + userEmail)
     configuration.culture = culture
     return configuration
   }
   
   func getBaseUrl() -> URL? {
     let sourceId = (source.id.isEmpty) ?  "" : "\(source.id)"
     return URL(string: String(format: "https://api.geniussports.com?fixtureImmersive=%@&liveStreamId=%@&showPlayerTrackingGuides=%@", immersiveFixtureId, sourceId, showPlayerTracking ? "true" : "false"))
   }
   
   func updateVideoURL(reset: Bool){
     if(reset){
       sources = []
       source = DeliveryStream(id: "", name: "DEFAULT")
     }
     let configuration = updateConfiguration(configuration: VideoConfiguration())
     VideoSDK().getDeliveryIds(videoConfiguration: configuration, completionHandler: { (result: Array<DeliveryStream>?, error: KotlinThrowable?) in
       if let kotlinError = error {
         print("Error: \(kotlinError.message ?? "")")
       } else if let result = result {
         sources = result
       }
     })
     let baseURL = getBaseUrl()
     webViewCoordinator.webView.loadHTMLString("Loading...", baseURL: baseURL)
     VideoSDK().getVideoStream(videoConfiguration: configuration, completionHandler: { (result: String?, error: KotlinThrowable?) in
       if let kotlinError = error {
         print("Error: \(kotlinError.message ?? "")")
         webViewCoordinator.webView.loadHTMLString("Error: \(kotlinError.message ?? "")", baseURL: baseURL)
       } else if let result = result {
         let htmlString = result
         webViewCoordinator.webView.loadHTMLString(htmlString, baseURL: baseURL)
       }
     })
   }
   
   func changeOrientation(to orientation: UIInterfaceOrientationMask) {
     // tell the app to change the orientation
     let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene
     windowScene?.requestGeometryUpdate(.iOS(interfaceOrientations: orientation))
     windowScene?.keyWindow?.rootViewController?.setNeedsUpdateOfSupportedInterfaceOrientations()
     
     orientationSource = .programatically
     DispatchQueue.main.async {
       notchUpdateChange = detectNotchSide(safeArea: getSafeAreaInsets())
       }
   }
   
   func getVideoEnvironment() -> VideoEnvironment {
     switch(environment) {
     case "PROD":
       return VideoEnvironment.prod
     case "CI":
       return VideoEnvironment.ci
     default:
       return VideoEnvironment.uat
     }
   }
 }
 
 struct ContentView_Previews: PreviewProvider {
   static var previews: some View {
     NavigationView {
       ContentView()
     }
   }
 }
 
 enum NotchPosition: String {
     case left = "left"
     case right = "right"
     case top = "top"
     case bottom = "bottom"
     case none = "No detected notch"
 }
 
 enum OrientationSource: String {
   case programatically = "programatically"
   case accelerometer = "accelerometer"
   case none = "none"
 }
 

7. Create Custom Betslip.

  • Create the CustomBetslip file:

    This file is in charge of using the betslip data coming from the video player through the message handler created before and it can be modified as you need.

    An example code could look like this:

    import SwiftUI
     
    struct CustomBetslip: View {
     @ObservedObject var betslipData: BetslipData
     @Binding var showToast: Bool
     @Binding var showBetslip: Bool
     var body: some View {
       VStack {
         Text("Customer betslip")
           .font(.system(.title2))
         Text("sportsbookFixtureId: \(betslipData.sportsbookFixtureId)")
           .font(.system(.subheadline))
         Text("sportsbookSelectionId: \(betslipData.sportsbookSelectionId)")
           .font(.system(.subheadline))
         Text("marketId: \(betslipData.marketId)")
           .font(.system(.subheadline))
         Text("sportsbookMarketId: \(betslipData.sportsbookMarketId)")
           .font(.system(.subheadline))
         Text("sportsbookMarketContext: \(betslipData.sportsbookMarketContext)")
           .font(.system(.subheadline))
         Text("decimalPrice: \(betslipData.decimalPrice!)")
           .font(.system(.subheadline))
         Text("stake: \(betslipData.stake)")
           .font(.system(.subheadline))
         Button(action: {
           showToast = true
           showBetslip.toggle()
         }) {
           Text("Place bet")
             .padding(.vertical, 10)
             .foregroundStyle(Color.white)
             .frame(maxWidth: .infinity)
             .background(
               RoundedRectangle(cornerRadius: 60).fill(Color.blue)
             )
         }
         Button(action: { showBetslip.toggle() }) {
           Text("Cancel")
             .padding(.vertical, 10)
             .foregroundStyle(Color.white)
             .frame(maxWidth: .infinity)
             .background(
               RoundedRectangle(cornerRadius: 60).fill(Color.blue)
             )
         }
       }
       .foregroundColor(.white)
       .frame(width: 250)
       .padding(10)
       .background(
         RoundedRectangle(cornerRadius: 4).fill(Color.black.opacity(0.8))
       )
     }
    }
  • Create the CustomToast file:

    import SwiftUI
     
    struct CustomToast: View {
      @Binding var showToast: Bool
      var body: some View {
        VStack {
          Spacer()
          HStack {
            Image(systemName: "checkmark.seal.fill")
            Text("BET PLACED!!")
          }
          .foregroundColor(.white)
          .padding(5)
          .background(
            RoundedRectangle(cornerRadius: 60).fill(Color.black.opacity(0.8))
          )
        }
        .padding(.bottom, 20)
        .onAppear() {
          Timer.scheduledTimer(withTimeInterval: 3.0, repeats: false) { _ in
            showToast = false
          }
        }
      }
    }
  • Add the CustomBetslip and CustomToast to the main view:

    struct FullscreenContentView: View {
     
      @StateObject private var webViewCoordinator = WebViewCoordinator()
      @State private var isPresented = false
      @StateObject private var betslipData = BetslipData()
      @State private var showBetslip = false
      @State private var showToast = false
      @State private var orientationSource = OrientationSource.none
     
      var body: some View {
        GeometryReader { metrics in
          VStack(spacing: 0) {
           let notchUpdate = detectNotchSide(safeArea: getSafeAreaInsets())
            GeometryReader { geometry in
              ZStack {
                WebViewWrapper(webView: webViewCoordinator.webView)
                if showBetslip {
                  CustomBetslip(betslipData: betslipData, showToast: $showToast, showBetslip: $showBetslip)
                }
                if showToast {
                  CustomToast(showToast: $showToast)
                }
              }
            }
          }
          ...
        }
      }
    }
  • Handle events:

Using the messageHandler function you can listen to market related events.

func messageHandler(type: String, payload: Any) {
  if (type == "toggleFullscreen") {
    isPresented.toggle()
    changeOrientation(to: isPresented ? .landscape : .portrait)
  } else if (type == "init") {
    onToggleFullscreen()
  } else if (type == "multibet-event") {
    print(payload)
    if let data = payload as? [String: Any] {
      if let newSportsbookFixtureId = data["sportsbookFixtureId"] as? String {
        betslipData.sportsbookFixtureId = "\(newSportsbookFixtureId)"
      }
      if let newSportsbookFixtureId = data["sportsbookSelectionId"] as? String {
        betslipData.sportsbookSelectionId = "\(newSportsbookFixtureId)"
      }
      if let newSportsbookFixtureId = data["sportsbookMarketId"] as? String {
        betslipData.sportsbookMarketId = "\(newSportsbookFixtureId)"
      }
      if let newSportsbookFixtureId = data["sportsbookMarketContext"] as? String {
        betslipData.sportsbookMarketContext = "\(newSportsbookFixtureId)"
      }
      if let newSportsbookFixtureId = data["marketId"] as? String {
        betslipData.marketId = "\(newSportsbookFixtureId)"
      }
      if let newDecimalPrice = data["decimalPrice"] as? Double {
        betslipData.decimalPrice = newDecimalPrice
      }
      if let newDecimalPrice = data["stake"] as? Double {
        betslipData.stake = "\(newDecimalPrice)"
      }
      showBetslip = true
    }
  }
}

Expected payload data structure:

class BetslipData: ObservableObject {
  @Published var decimalPrice: Double? = nil
  @Published var command: String = ""
  @Published var marketId: String = ""
  @Published var sportsbookMarketContext: String = ""
  @Published var sportsbookMarketId: String = ""
  @Published var sportsbookFixtureId: String = ""
  @Published var sportsbookSelectionId: String = ""
  @Published var stake: String = ""
}

8. Handle player close.

You should also take into account handling the player close event, because if it isn't called it WILL leak http requests. Please do it wherever it works best for your application.
You can find an example below:

// WebViewCoordinator.swift
func onDisappear() {
  let script = """
    if (window.GeniusLivePlayer?.player) {
        window.GeniusLivePlayer.player.close()
    }
  """
  webView.evaluateJavaScript(script)
}

We tie it to the onDisappear of the ContentView, but it just needs to be called whenever your application requires it. In most cases it will be wherever the parent of the component disappears.

// ContentView.swift
// LoadVideoForm
.onAppear {
  // Load initial web content
  webViewCoordinator.messageHandler = messageHandler
  updateVideoURL(reset: true)
}
.onDisappear {
  webViewCoordinator.onDisappear()
}

The UIKit and Swift UI Integration were tested on iPhone 14 pro with iOS 16.4.