Award Winning Company

Award Winning Company

DIAGNAL Logo

Shared Codebase Approach for iOS and tvOS in OTT Applications

How OTT engineering teams can build and maintain a shared codebase across iOS and tvOS using SwiftUI and Swift Package Manager, covering shared UI components, core module architecture, platform-specific boundaries, and playback responsibilities across both platforms.

Building OTT Apps Across iOS and tvOS: The Core Challenge

The OTT space has grown rapidly, and streaming is no longer a single-screen experience. Users expect continuity; they can start watching on iPhone, resume on Apple TV, and expect everything to work the same way on both.

From an engineering perspective, the reality looks quite different

  • Two separate codebases
  • Two UI systems
  • Two release cycles
  • Analytics implemented twice
  • Higher risk of bugs across both platforms

The problem is not just duplication. Over time, both platforms start moving in different directions. Networking, analytics, data handling, and third-party SDK versions drift apart. A change on one platform needs a separate effort on the other. Feature parity becomes less about implementation and more about coordination.

The downstream impact shows up quickly:

  • A bug fixed on iOS needs a separate fix cycle on tvOS.
  • Features get released at different times across platforms.
  • Developers need to understand two versions of the same system.
  • Code reviews happen in isolation with very little cross-platform visibility.

This increases maintenance effort and slows delivery. As both codebases grow, the gap becomes harder to control. What starts as duplication gradually turns into long-term divergence.

Why a Single-Code Strategy for the Apple Ecosystem

The goal is not to write one codebase that runs everywhere. It is to be deliberate: share what makes sense, let platforms own what they need to, and keep the boundaries clean.

In OTT applications, a big portion of the codebase has nothing to do with the UI. Networking, data handling, analytics, and business logic work the same way on both platforms. Duplicating these layers is where maintenance overhead starts building up over time.

What Goes in the Core
  • Networking and API communication.
  • Data models and business logic.
  • Analytics and event tracking.
  • Local storage and caching.

These components have no platform awareness and should stay that way.

What Stays in the Platform
  • Interaction handling, touch on iOS and focus on tvOS.
  • Navigation patterns and screen layout.
  • Platform dependent SDK integrations.
  • System integrations like PiP on iOS and Top Shelf on tvOS, where featured content appears directly on the Apple TV home screen without opening the app.

UI components that are purely visual and data-driven, like content cards and detail screens, can still live in the core module. What belongs to the platform is how those components are interacted with and how they are arranged within a screen.

The goal is not to share as much as possible. It is to share what actually makes sense, keep effort down, and let the codebase stay clean as both platforms grow.

Multiplatform Development with SwiftUI

SwiftUI offers a practical path for sharing UI across the Apple ecosystem. It is designed to work consistently across iOS, iPadOS, tvOS, and macOS, making it a strong foundation for multiplatform OTT applications.

Not every UI component translates across platforms, but many do. Content cards, detail screens, settings, and onboarding flows are good candidates because they are driven by data and layout, both of which stay consistent regardless of platform.

Platform differences are handled through conditional compilation (#if os(tvOS)) or environment-driven adaptations, allowing the same view to support different interaction models without duplication.

Shared Components in Practice

Consider a content card component. It is one of the most common building blocks in OTT applications.

It typically includes a poster image, title, metadata, and layout structure. These remain identical across platforms. The difference lies in interaction. On iOS it is tap and gesture-driven. On tvOS it is focus-driven.

The key is not to reuse views blindly, but to design shared UI primitives with platform-aware behaviour. Interaction differences should be handled at the edges, while the core structure remains reusable.

Practical Example: Shared OTT Content Card

Before implementation, define the boundaries clearly.

What is shared: visual structure, data binding, image loading, and accessibility. What is platform-specific: interaction handling, focus behaviour, and highlight states. This separation keeps the component predictable and reusable.

struct ContentCard: View {
    let title: String
    let imageURL: URL
    @FocusState private var isFocused: Bool
    var body: some View {
        VStack {
            AsyncImage(url: imageURL)
                .scaledToFill()
                .frame(width: 200, height: 300)
                .clipped()
            Text(title)
                .font(.caption)
        }
        .scaleEffect(isFocused ? 1.1 : 1.0)
        .animation(.easeInOut, value: isFocused)
        .applyPlatformInteraction(isFocused: $isFocused)
    }
}

Instead of introducing platform conditions directly inside the view, interaction is abstracted:

extension View {
    @ViewBuilder
    func applyPlatformInteraction(isFocused: FocusState<Bool>.Binding) -> some View {
        #if os(tvOS)
        self.focusable(true).focused(isFocused)
        #else
        self.onTapGesture {
            // iOS action
        }
        #endif
    }
}

This keeps the core view clean and ensures platform-specific logic does not spread across the UI layer. The component stays predictable, the platform logic stays isolated, and both are easier to maintain over time.

Where SwiftUI Stops and UIKit Takes Over

SwiftUI handles a lot in an OTT application, but it does not handle everything. Video playback is the most immediate example. AVPlayerViewController is a UIKit component and there is no native SwiftUI equivalent for it. To use it inside a SwiftUI view, it needs to be bridged:

// Bridging AVPlayerViewController into SwiftUI
struct PlayerView: UIViewControllerRepresentable {
    let player: AVPlayer
    func makeUIViewController(context: Context) -> AVPlayerViewController {
        let controller = AVPlayerViewController()
        controller.player = player
        return controller
    }
    func updateUIViewController(_ uiViewController: AVPlayerViewController, context: Context) {}
}

This is not a workaround. It is the intended approach. SwiftUI provides UIViewControllerRepresentable and UIViewRepresentable specifically for cases like this, where UIKit components need to live inside a SwiftUI view hierarchy.

In an OTT application, the player is not the only place this comes up. Ad frameworks, system playback controls, and certain SDK integrations still depend on UIKit. These are not going away anytime soon.

SwiftUI is the composition layer. It handles structure, layout, and shared UI components. UIKit handles the low level integrations that SwiftUI does not yet cover.

Core Differences Between iOS and tvOS

tvOS is not iOS on a bigger screen. The interaction model, system behaviour, and performance constraints are built differently. Treating them as equivalent is where most single-code OTT implementations start going wrong.

Focus Engine vs Touch Interaction

On tvOS, focus handling is not an enhancement you layer on at the end. It is a core part of how the UI works. Every interactive element needs to be focusable, the navigation path needs to feel natural from a 10-foot distance, and the interface has to respond predictably to every directional input from the Siri Remote.

SwiftUI gives you focusable() and focus state management, but these only work well when focus is designed in from the start. Building the layout first and designing focus later is a pattern that almost never works cleanly. Navigation becomes unpredictable and the experience degrades fast.

The interaction models are different at a fundamental level. Designing for that difference from the beginning is what separates a tvOS app that feels right from one that just technically runs.

A good way to see this boundary in practice is the Continue Watching card. The poster image, title, progress bar, and free or paid badge look and behave the same on both platforms. There is no reason to build this twice.

So the card lives in the core module:

// CoreModule - ContinueWatchingCard.swift
public struct ContinueWatchingCard: View {
    public let item: ContinueWatchingItem
    public var body: some View {
        VStack(alignment: .leading) {
            AsyncImage(url: item.imageURL)
                .frame(width: 300, height: 169)
            Text(item.title)
                .font(.caption)
            ProgressView(value: item.progress)
            if item.accessType == .paid {
                Text("Premium")
                    .font(.caption2)
                    .foregroundColor(.yellow)
            }
        }
    }
}
// iOS Platform
ContinueWatchingCard(item: item)
    .onTapGesture {
        // play content
    }
// tvOS Platform
ContinueWatchingCard(item: item)
    .focusable(true)
    .focused($focusedID, equals: item.id)
    .scaleEffect(focusedID == item.id ? 1.1 : 1.0)
    .animation(.easeInOut, value: focusedID)

The card itself never changes. Focus and touch are handled at the container level in each platform workspace. This is where the boundary sits, and it is a clean one.

How Each Platform Owns Its Navigation

Navigation belongs to the platform workspace, not the core module. SwiftUI gives a unified API through NavigationStack with path-based routing, but how each platform uses it reflects its own interaction model. iOS pushes screens in a stack. tvOS relies on spatial, focus-driven navigation. The card and the data come from the core module. How they are arranged and navigated is a platform decision.

Each platform workspace owns an AppRouter that holds the NavigationPath as state. When a user selects an item, the view tells the ViewModel, and the ViewModel tells the Router. The Router updates the path and NavigationStack responds. The view never knows what screen comes next.

The HomeScreen is where the Continue Watching rail lives and it belongs to each platform, not the core module. Both platforms build their own HomeScreen by importing ContinueWatchingCard and ContinueWatchingViewModel from the core module through SPM. When a user selects an item, the view tells the ViewModel. Navigation from there is handled outside the view layer.

On iOS:

// iOS Platform - HomeScreen.swift
import CoreModule
struct HomeScreen: View {
    @StateObject private var viewModel = ContinueWatchingViewModel()
    var body: some View {
        ScrollView(.horizontal) {
            HStack {
                ForEach(viewModel.items, id: \.id) { item in
                    ContinueWatchingCard(item: item)
                        .onTapGesture {
                            viewModel.itemSelected(item)
                        }
                }
            }
        }
        .task { await viewModel.fetchItems() }
    }
}

On tvOS, the same HomeScreen structure is used but focus handling wraps the card:

// tvOS Platform - HomeScreen.swift
import CoreModule
struct HomeScreen: View {
    @StateObject private var viewModel = ContinueWatchingViewModel()
    @FocusState private var focusedID: String?
    var body: some View {
        ScrollView(.horizontal) {
            HStack {
                ForEach(viewModel.items, id: \.id) { item in
                    ContinueWatchingCard(item: item)
                        .focusable(true)
                        .focused($focusedID, equals: item.id)
                        .scaleEffect(focusedID == item.id ? 1.1 : 1.0)
                        .animation(.easeInOut, value: focusedID)
                        .onTapGesture {
                            viewModel.itemSelected(item)
                        }
                }
            }
        }
        .task { await viewModel.fetchItems() }
    }
}

The HomeScreen is duplicated intentionally. The card, the data, and the routing logic are not. That is the boundary, and it is a clean one.

Performance and Hardware Constraints

Apple TV has tighter memory and GPU constraints than iPhone, and in a shared codebase this difference has to be accounted for at the platform layer. What works fine on iOS can behave differently on tvOS, especially in content-heavy OTT applications where grids, images, scrolling, and playback are all running together.

How each platform consumes and presents that data is its own decision. Apple TV HD and Apple TV 4K also have different memory and GPU profiles, so the tvOS platform workspace needs to be validated across both device generations, not just the latest one.

Startup and Rendering Strategy

Cold launches are more common on tvOS than on iOS because the system frequently suspends apps in the background. This makes startup performance a platform-specific concern that belongs in the tvOS workspace, not in shared code.

The core module delivers the data. How and when the tvOS platform workspace requests and renders that data during launch is its own responsibility. Rendering the initial screen from cache, deferring off-screen rows, and keeping the main thread free during launch are all decisions made at the platform layer, using the same core module that iOS uses, just consumed differently.

Media Playback Expectations

Playback in an OTT application is not just loading a URL and hitting play. Before the player even starts, there is a sequence of operations that every platform has to go through: validating the auth token, checking parental ratings, verifying entitlements, fetching bookmarks, resolving stream concurrency, and finally getting the stream URL. This entire sequence is business logic and it belongs in the core module. It has nothing to do with which platform is running it.

What comes after is owned by each platform: Player initialisation, DRM configuration, ad setup, and other integrations are handled in the platform workspace. The core module hands over a resolved stream URL and each platform takes it from there.

On iOS this includes things like Picture in Picture, background playback, and mobile specific ad behaviour. On tvOS the focus shifts to fast playback startup, system level player UI, and top shelf integration. The stream URL comes from the same core module. What each platform does with it is its own responsibility.

Designing a Shared Architecture

The architecture is split into three workspaces. A core module that holds all shared logic, an iOS platform workspace, and a tvOS platform workspace. Both platforms pull from the core module through SPM. What lives where is not a convention, it is a boundary that is enforced by the structure itself.

The core module holds networking, data models, business logic, and ViewModels. These have no platform awareness. They describe what data is available and what actions can be triggered, nothing more.

The platform workspaces hold everything that touches interaction. Views, navigation, focus handling on tvOS, gestures on iOS, and system integrations. ViewModels tell the view what to show. The view decides how to show it based on the platform it lives in.

This separation becomes especially important as the codebase grows, where enforcing these boundaries requires more than just conventions.

The Core Module

The core module is a separate Swift package that lives in its own workspace. Both the iOS and tvOS platform workspaces declare it as a dependency through SPM. This means any change made in the core module is immediately available to both platforms without any duplication.

The package exposes only what platforms need through public interfaces. Internal implementation details stay private. This keeps the boundary clean and prevents platform workspaces from reaching into logic they should not touch.

Networking is a good place to see this in action. In an OTT application, the same APIs, models, and request flows apply across iOS and tvOS. A single shared client in the core module handles all of this:

let networkClient = NetworkClient()

Feature level APIs are built on top of this. A Details screen, for example, supports multiple content types like VOD, series, episodes. The structure and flow stay consistent across all of them:

enum DetailsAPI {
    case vod(id: String)
    case series(id: String)
    case episode(id: String)
}

And used like:

let details = try await remoteAPIClient.request(
    endpoint: DetailsAPI.vod(id: "123")
)

There is no platform branching. The same request flow works across both platforms.

Shared Analytics

Analytics is one of the clearest examples of what belongs in the core module. Event names, event structures, and the tracking interface are defined once and both platforms use them.

The core module defines a protocol and typed events:

// CoreModule
public protocol AnalyticsEvent {
    static var name: String { get }
    var properties: [String: Any] { get }
}
public struct CarouselItemClickedEvent: AnalyticsEvent {
    public static let name = "Carousel Item Clicked"
    public let contentId: String
    public let contentName: String
    public let railName: String
    public let position: Int
    public var properties: [String: Any] {
        [
            "content_id": contentId,
            "content_name": contentName,
            "rail_name": railName,
            "position": position
        ]
    }
}

Both platforms fire the event the same way:

// iOS or tvOS Platform
analyticsManager.track(
    event: CarouselItemClickedEvent(
        contentId: item.id,
        contentName: item.title,
        railName: currentRail.name,
        position: selectedIndex
    )
)

The event name and properties never diverge between platforms because they come from one place. Reporting stays consistent and any change to an event structure is reflected across both platforms immediately.

Extending for Platform Differences

Not everything fits neatly into shared code. Some features have a common foundation but need platform specific behaviour on top.

Login is a good example. Input validation, API integration, B2C and B2B flows, error handling, these are identical across platforms and belong in a shared ViewModel:

// CoreModule - LoginProtocol.swift
public protocol LoginProtocol {
    func validate(email: String, password: String) -> Bool
    func login() async
}
// CoreModule - LoginViewModel.swift
public final class LoginViewModel: ObservableObject, LoginProtocol {
    public func validate(email: String, password: String) -> Bool {
        // shared validation logic
    }
    public func login() async {
        // shared API call
    }
}

The core module stops here. It has no knowledge of what each platform needs beyond this. On tvOS, QR based login is a platform specific requirement. The tvOS workspace defines its own protocol for this and extends the core ViewModel to conform to it:

// tvOS Platform - QRLoginProtocol.swift
public protocol QRLoginProtocol {
    func handleQRLogin()
}
// tvOS Platform - LoginViewModel+tvOS.swift
import CoreModule
extension LoginViewModel: QRLoginProtocol {
    public func handleQRLogin() {
        // tvOS specific QR code login flow
    }
}

The core LoginViewModel never changes. The tvOS platform defines what it needs, extends the ViewModel to support it, and the core module remains completely unaware of it.

On iOS, no extension is needed. The shared ViewModel covers the full login flow through email, password, or deep links handled at the view layer.

The core defines the contract. Each platform extends only what it needs to.

Third-Party SDK

OTT applications are SDK-heavy. Playback, DRM, analytics, ads, and crash reporting all depend on third-party integrations. Each comes with its own platform support, release cycle, and integration model.

SDKs that behave consistently across both platforms and have no platform specific behaviour belong in the core module. Analytics is a good example, the SDK dependency and its wrapper live in the core module and both platforms initialise it through their own workspace at startup.

In practice though, not all SDKs work this way. Some do not support tvOS at all. Others expose only UIKit based APIs with no SwiftUI compatibility. tvOS compatible versions frequently lag behind iOS releases. For these, the integration belongs in the platform workspace, not the core module.

What Shared Codebase Approach Means in Practice

Building OTT apps across iOS and tvOS does not require two codebases. It requires the right boundaries. The core module holds what both platforms share. Each platform workspace owns what makes it feel native. That separation is what keeps the codebase clean, the features consistent, and the maintenance effort from doubling every time something changes.

Even sharing just the networking and data layer across both platforms cuts development time and reduces the chance of bugs appearing differently on each platform. The further you push that boundary, the more both platforms benefit.

“The goal is not to share everything. It is to share the right things, keep the boundaries clean, and let each platform be what it is built to be.”

Once the shared architecture is in place, the next step is performance. Startup time, playback speed, and memory management on tvOS all need deliberate attention. We go deeper on that in the upcoming blog “Building High-Performance Streaming Apps for Apple TV.”