> For the complete documentation index, see [llms.txt](https://docs.firework.com/firework-for-developers/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.firework.com/firework-for-developers/ios-sdk/integration-guide-for-ios-sdk/player-deck-ios.md).

# Player Deck (iOS)

### **Display Player Deck**

#### Use PlayerDeckView

The `PlayerDeckView` provides a `UIView` wrapper for the `PlayerDeckFeedViewController`.

**Integration**

1. Import `FireworkVideo`.
2. Instantiate `PlayerDeckView` and embed it.

The following are the sample codes:

```swift
import UIKit
import FireworkVideo

class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        self.addPlayerDeckView()
    }

    func addPlayerDeckView() {
        let channelID = "<Encoded Channel ID>"
        let playlistID = "<Encoded Playlist ID>"
        let playerDeckView = PlayerDeckView(source:
            .channelPlaylist(
                channelID: channelID,
                playlistID: playlistID
            )
        )
        playerDeckView.viewConfiguration = getPlayerDeckContentConfiguration()

        playerDeckView.translatesAutoresizingMaskIntoConstraints = false
        self.view.addSubview(playerDeckView)

        NSLayoutConstraint.activate([
            playerDeckView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor),
            playerDeckView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor),
            playerDeckView.heightAnchor.constraint(equalToConstant: 500),
            playerDeckView.centerYAnchor.constraint(equalTo: self.view.centerYAnchor)
        ])
    }

    func getPlayerDeckContentConfiguration() -> PlayerDeckContentConfiguration {
        var viewConfiguration = PlayerDeckContentConfiguration()
        viewConfiguration.itemView.autoplay.isEnabled = true
        viewConfiguration.playerView.playbackButton.isHidden = false
        return viewConfiguration
    }
}
```

#### Use PlayerDeckSwiftUIView(SwiftUI)

The `PlayerDeckSwiftUIView` provides a SwiftUI View wrapper for the `PlayerDeckFeedViewController`.

**Integration**

1. Import `FireworkVideo`.
2. Instantiate `PlayerDeckSwiftUIView` and embed it.

The following are the sample codes:

```swift
import SwiftUI
import FireworkVideo

let channelID = "<Encoded Channel ID>"
let playlistID = "<Encoded Playlist ID>"

struct ContentView: View {
    let playerDeckContainer = PlayerDeckSwiftUIContainer()
    var body: some View {
        List {
            Spacer()
            PlayerDeckSwiftUIView(
                source: .channelPlaylist(channelID: channelID, playlistID: playlistID),
                viewConfiguration: getPlayerDeckContentConfiguration(),
                isPictureInPictureEnabled: true,
                onPlayerDeckLoaded: {
                    debugPrint("Video feed loaded successfully.")
                },
                onPlayerDeckFailedToLoad: { error in
                    debugPrint("Video feed did fail loading.")
                }
            ).frame(height: 600)
            Button("Refresh") {
                playerDeckContainer.handler?.refresh()
            }
            Spacer()
        }
    }

    func getPlayerDeckContentConfiguration() -> PlayerDeckContentConfiguration {
        var viewConfiguration = PlayerDeckContentConfiguration()
        viewConfiguration.itemView.autoplay.isEnabled = true
        viewConfiguration.playerView.playbackButton.isHidden = false
        return viewConfiguration
    }
}
```

### **Autosizing (grid layout)**

By default the deck fills the frame you give it and scrolls its own content. Autosizing inverts that: with a grid layout (`PlayerDeckGridLayout`), the deck grows to the full height of its content, reports that height as its intrinsic content size, and lets **your** scroll view do the scrolling. That is what you want when the deck is one block inside a longer page.

Pass `autosizingEnabled: true` when you create the view.

```swift
func addAutosizingPlayerDeck(to stackView: UIStackView) {
    let channelID = "<Encoded Channel ID>"
    let playlistID = "<Encoded Playlist ID>"
    let playerDeckView = PlayerDeckView(
        layout: PlayerDeckGridLayout(columns: 2),
        source: .channelPlaylist(channelID: channelID, playlistID: playlistID),
        autosizingEnabled: true
    )
    playerDeckView.viewConfiguration = getPlayerDeckContentConfiguration()

    // No height constraint — the deck supplies its own intrinsic height.
    playerDeckView.translatesAutoresizingMaskIntoConstraints = false
    stackView.addArrangedSubview(playerDeckView)
}
```

In SwiftUI, place it in your `ScrollView` without a `.frame(height:)`.

```swift
ScrollView {
    VStack(spacing: 20) {
        MyPageHeader()
        PlayerDeckSwiftUIView(
            source: .channelPlaylist(channelID: channelID, playlistID: playlistID),
            layout: PlayerDeckGridLayout(columns: 2),
            viewConfiguration: getPlayerDeckContentConfiguration(),
            autosizingEnabled: true
        )
        MyPageFooter()
    }
}
```

#### What autosizing changes

* **The deck stops scrolling internally** and reports its content height as its intrinsic content size. Do not give it a fixed height constraint or `.frame(height:)`.
* **At most 20 videos are rendered** — one page. A grid grows taller with every page loaded, so it has to stay bounded. Set `fetchConfiguration.maxVideos` to show fewer; a value above 20 is clamped back to 20. This cap applies only to an autosized grid — without autosizing, or with a row layout, `maxVideos` is not limited.

{% hint style="warning" %}
`autosizingEnabled` and the kind of layout — grid or row — are fixed when the view is created. Assigning a layout of the other kind afterwards is ignored, and trips an assertion in debug builds. To switch, replace the view with a freshly initialized one; in SwiftUI, give it a new `.id()`.
{% endhint %}

### **Content Source**

Please refer to [Video Feed Content Source (iOS)](/firework-for-developers/ios-sdk/integration-guide-for-ios-sdk/video-feed-content-source-ios.md).

### **Custom Call-To-Action Button Handling**

Custom Call-To-Action button handling is done via the `FireworkVideoCTADelegate` protocol. This provides control over what occurs when a call-to-action button is tapped.

1. Set the delegate:

```swift
FireworkVideoSDK.ctaDelegate = self
```

2\. Conform to protocol:

```swift
func handleCustomCTAClick(_ viewController: PlayerViewController, url: URL, for video: VideoDetails) -> Bool {
    // Your custom action code here...
    return true
}
```

### Widget-Level Product Hydration

We support widget-level product hydration in the player deck through the `onProductHydration` closure. This process is triggered immediately after the widget data has finished loading. For more details, please refer to the code snippets below.

```swift
let channelID = "<Encoded Channel ID>"
let playlistID = "<Encoded Playlist ID>"
let playerDeckView = PlayerDeckView(source:
        .channelPlaylist(
            channelID: channelID,
            playlistID: playlistID
        )
)
playerDeckView.onProductHydration = { [weak self] products, videos, hydrator in
    guard let self = self else {
        return
    }
    self.handleWidgetProductHydration(
        products: products,
        videos: videos,
        hydrator: hydrator
    )
}

PlayerDeckSwiftUIView(
    source: .channelPlaylist(channelID: channelID, playlistID: playlistID),
    onProductHydration: { [weak self] products, videos, hydrator in
        guard let self = self else {
            return
        }
        self.handleWidgetProductHydration(
            products: products,
            videos: videos,
            hydrator: hydrator
        )
    }
).frame(height: 600)

func handleWidgetProductHydration(
    products: [Product],
    videos: [VideoDetails],
    hydrator: ProductHydrating
) {
    let productIDs = products.compactMap { $0.externalID }
    // Retrieve the most up-to-date product details
    // based on the product IDs from the host app server
    
    // Introduce a delay to mimic a network request that fetches
    // updated product data after the hydration callback is triggered.
    DispatchQueue.global().asyncAfter(wallDeadline: .now() + 3) {
        // Get product model list
        let productModels = hydrator.products
        // Call hydration API
        for productID in productIDs {
            hydrator.hydrateProduct(productID) { productBuilder in
                // Update product info
                productBuilder
                    .name("Latest product name")
                    .description("Latest product description")
                    .isAvailable(true)

                // Set to true to hide the product, or false to keep it visible
                productBuilder.hidden(true)

                // Update product variants.
                // The strategy can be merge or replace.
                // With merge strategy, we will merge these new variants into existing variants. We use variant id to match the variant.
                // With `replace` strategy, we will replace existing variants with these new variants.
                productBuilder.variants(.merge) { variantsBuilder in
                    // Build variant
                    variantsBuilder.variant("variant id1") { variantBuilder in
                        variantBuilder.formattedPrice(100, currencyCode: "USD")
                            .formattedOriginalPrice(120, currencyCode: "USD")
                            .url("Latest variant url1")
                            .imageUrl("Latest variant image url1")
                            .isAvailable(true)
                            .options([
                                "Color": "Latest variant color1",
                                "Size": "Latest variant size1"
                            ])
                        return variantBuilder
                    }

                    // Build variant
                    variantsBuilder.variant("variant id2") { variantBuilder in
                        variantBuilder.name("Latest variant name2")
                            .formattedPrice(110, currencyCode: "USD")
                            .formattedOriginalPrice(130, currencyCode: "USD")
                            .url("Latest variant url2")
                            .imageUrl("Latest variant image url2")
                            .isAvailable(true)
                            .options([
                                "Color": "Latest variant color2",
                                "Size": "Latest variant size2"
                            ])
                        return variantBuilder
                    }
                    return variantsBuilder
                }
                return productBuilder
            }
        }
    }
}
```

#### Why Is Widget-Level Product Hydration Required?

In the player deck, multiple videos may be displayed at the same time, and each video can have its own associated products.

The existing [global product hydration](/firework-for-developers/ios-sdk/integration-guide-for-ios-sdk/shopping-ios.md#global-product-hydration) mechanism is limited to handling product data for only one video at a time, which becomes a bottleneck in multi-video scenarios.

Widget-level product hydration removes this limitation by enabling the host app to hydrate product data for multiple videos concurrently, ensuring that products across different videos can be processed simultaneously.

#### Can We Implement Only Widget-Level Product Hydration?

No. In general, [global product hydration](/firework-for-developers/ios-sdk/integration-guide-for-ios-sdk/shopping-ios.md#global-product-hydration) should be implemented by default. Widget-level hydration is currently supported only in the player deck. If you need to hydrate products for other widgets—such as Video Feed, Circle Story, or Story Block—global product hydration is required.

Widget-level product hydration is designed specifically for multi-video product scenarios. It is triggered immediately after the widget data has finished loading and operates within a limited scope.

It is not intended to replace global product hydration. Instead, it addresses a specific use case within the player deck.

Therefore, widget-level hydration should be considered a complementary mechanism rather than a full replacement for [global product hydration](/firework-for-developers/ios-sdk/integration-guide-for-ios-sdk/shopping-ios.md#global-product-hydration).

### One-Tap Add to Cart

Player Deck product cards can show a one-tap Add to Cart button that adds the exact unit shown on the card to your cart without opening the product detail page. The button is hidden by default; it appears only when you assign `FireworkVideoSDK.shopping.playerDeckAddToCartDelegate` **and** set `viewConfiguration.itemView.addToCartButton.isHidden = false`, both before the player deck is created.

For the delegate contract, the 10-second reporting window and the full sample code, please refer to [Player Deck one-tap Add to Cart](/firework-for-developers/ios-sdk/integration-guide-for-ios-sdk/shopping-ios.md#player-deck-one-tap-add-to-cart).

### Force Refresh

A `PlayerDeckView` or `PlayerDeckSwiftUIView` can be forced to refreshed by calling the `refresh()` method on the instance that should be refreshed. This functionality is useful if your feed is embedded along with other components that are also updated and you support features like pull to refresh.

### Receive video feed events

1. Set the delegate

```swift
playerDeckView.delegate = self
```

2. Conform to `PlayerDeckFeedViewControllerDelegate` protocol

```swift
func playerDeckFeedDidLoadFeed(_ viewController: PlayerDeckFeedViewController) {
    print(">>> Player deck feed load successfully")
}

func playerDeckFeed(_ viewController: PlayerDeckFeedViewController, didFailToLoadFeed error: VideoFeedError) {
    print(">>> Player deck feed did fail loading")
    if case .contentSourceError(let playerDeckContentSourceError) = error,
       case .emptyFeed = playerDeckContentSourceError {
        // This is a specific error.
        // SDK will trigger this error when the player deck is empty.
        // For example, host app can hide player deck for this error.
    } else {
        // Other error
    }
}
```

### Receive onVideosLoaded callback

The `onVideosLoaded` closure is called whenever videos are loaded into the player deck feed, including the initial page and every subsequent page loaded via pagination. The closure receives a `PlayerDeckVideosLoadedInfo` value whose `videos` property contains the `[VideoDetails]` that were loaded for that page.

#### Use PlayerDeckView

`onVideosLoaded` is exposed as a property on `PlayerDeckView`:

```swift
let playerDeckView = PlayerDeckView(source:
    .channelPlaylist(
        channelID: channelID,
        playlistID: playlistID
    )
)
playerDeckView.onVideosLoaded = { info in
    debugPrint("Loaded \(info.videos.count) videos.")
    for video in info.videos {
        debugPrint("Video ID: \(video.id)")
    }
}
```

#### Use PlayerDeckSwiftUIView(SwiftUI)

`onVideosLoaded` is exposed as an initializer parameter on `PlayerDeckSwiftUIView`:

```swift
PlayerDeckSwiftUIView(
    source: .channelPlaylist(channelID: channelID, playlistID: playlistID),
    viewConfiguration: getPlayerDeckContentConfiguration(),
    isPictureInPictureEnabled: true,
    onVideosLoaded: { info in
        debugPrint("Loaded \(info.videos.count) videos.")
        for video in info.videos {
            debugPrint("Video ID: \(video.id)")
        }
    }
).frame(height: 600)
```

### **Autoplay**

Autoplay lets the player deck automatically start playing eligible thumbnails without requiring user interaction. It is controlled per item by:

```swift
viewConfiguration.itemView.autoplay.isEnabled = true // default
```

This is the prerequisite for everything below — it is `true` by default, so autoplay is on out of the box.

Player Deck's autoplay is viewport-based by default, which delivers a seamless experience when the component is embedded within a `ScrollView`, `TableView`, or `CollectionView`. The first item whose visibility **within the viewport** is greater than or equal to `viewConfiguration.itemView.autoplay.triggerVisibilityPercentage` starts playing automatically; when no item meets the threshold, playback pauses.

When the player deck is embedded in your own scroll container, the viewport is clipped to that container automatically — a deck scrolled out of it correctly pauses, with no extra configuration. The viewport itself is customizable via `safeAreaEdges` (see below).

#### Customize viewport

The default viewport is defined as the screen bounds minus the safe area insets—such as the status bar, top navigation bar, bottom tab bar, and bottom home indicator.

```swift
/// Default Viewport = Screen - Top Safe Area - Bottom Safe Area
/// 
/// Screen (Full Device Screen)
/// ┌─────────────────────────┐ ← Screen Top
/// │   Status Bar            │ ← Safe Area (excluded)
/// ├─────────────────────────┤
/// │   Navigation Bar        │ ← Safe Area (excluded, if present)
/// ├─────────────────────────┤
/// │                         │
/// │                         │
/// │   Default Viewport      │ ← Visible content area
/// │   (Visible Content)     │    (Screen - Safe Area)
/// │                         │
/// │                         │
/// ├─────────────────────────┤
/// │   Tab Bar               │ ← Safe Area (excluded, if present)
/// ├─────────────────────────┤
/// │   Home Indicator        │ ← Safe Area (excluded)
/// └─────────────────────────┘ ← Screen Bottom
```

The viewport is then clipped to whatever your own layout hides. The SDK walks up from the player deck and intersects the bounds of every ancestor view that clips its content, so a player deck scrolled out of your scroll container — or hidden behind a header stacked above that container — is measured as not visible. This is automatic and needs no configuration.

```swift
/// Player deck embedded in your own scroll view:
///
/// ┌─ Screen ────────────────────────┐
/// │ Status Bar / Nav Bar            │ ← Safe Area (excluded)
/// ├─────────────────────────────────┤
/// │ Your page header                │ ← Excluded: outside the scroll
/// ╞═ Your scroll view (clips) ══════╡    view, which clips it away
/// │                                 │
/// │   Viewport                      │ ← What's left
/// │                                 │
/// ╞═════════════════════════════════╡
/// │ Rest of your page               │ ← Excluded, same as above
/// ├─────────────────────────────────┤
/// │ Home Indicator                  │ ← Safe Area (excluded)
/// └─────────────────────────────────┘
```

If you want only some safe-area edges excluded, narrow them with `safeAreaEdges`.

```swift
/// With the configuration below, only the top safe area is excluded:
/// Viewport = Screen - Top Safe Area
///
/// ┌─────────────────────────────────┐
/// │ Status Bar                      │ \
/// ├─────────────────────────────────┤  > Excluded (safeAreaEdges = .top)
/// │ Nav Bar (if present)            │ /
/// ╞═════════════════════════════════╡ ← Viewport Top
/// │                                 │
/// │   Viewport Area                 │ ← Content visible here
/// │                                 │
/// │                                 │
/// └─────────────────────────────────┘ ← Viewport Bottom (bottom safe area kept)
///
viewConfiguration.safeAreaEdges = .top
```

### Player Deck configurations

Please refer to [Player Deck Configurations (iOS)](/firework-for-developers/ios-sdk/integration-guide-for-ios-sdk/customization-ios/player-deck-configurations-ios.md).

### Player configurations

Please refer to [Player configurations (iOS)](/firework-for-developers/ios-sdk/integration-guide-for-ios-sdk/customization-ios/player-configurations-ios.md).
