> 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/android-sdk/integration-guide/01-basic-usage-and-api/02-autoplay-in-scrollable-containers.md).

# Handling Autoplay in Scrollable Containers

When `FwPlayerDeckView` is placed inside a scrollable parent (e.g., `NestedScrollView`, Compose `Column` with `verticalScroll`, or `LazyColumn`), it **cannot automatically detect that the outer container is scrolling**. This page explains the problem, provides two approaches to solve it, and documents known limitations in Jetpack Compose.

***

## Table of Contents

1. [Background](#background)
2. [Approach 1: Manual Viewport Notification](#approach-1-manual-viewport-notification)
   * [Demo 1A: Traditional View (Fragment + NestedScrollView)](#demo-1a-traditional-view-fragment--nestedscrollview)
   * [Demo 1B: Compose (Activity + Scrollable Column)](#demo-1b-compose-activity--scrollable-column)
3. [Approach 2: Automatic Visibility Tracking](#approach-2-automatic-visibility-tracking)
   * [Demo 2A: Traditional View (Fragment + NestedScrollView)](#demo-2a-traditional-view-fragment--nestedscrollview)
   * [Demo 2B: Compose (Activity + Scrollable Column)](#demo-2b-compose-activity--scrollable-column)
4. [How Automatic Tracking Works Internally](#how-automatic-tracking-works-internally)
5. [Known Limitations of Automatic Tracking in Compose](#known-limitations-of-automatic-tracking-in-compose)
6. [Recommendation Summary](#recommendation-summary)

***

## Background

`FwPlayerDeckView` has built-in lifecycle callbacks that handle window-level visibility:

* `onAttachedToWindow()` / `onDetachedFromWindow()` — fires when the view is added to or removed from the window.
* `onWindowFocusChanged()` — fires when the hosting window gains or loses focus.

These callbacks work correctly for basic scenarios (e.g., the Activity goes to the background). However, they **do not fire when a parent scrollable container clips the view out of the visible area**. When the user scrolls the `FwPlayerDeckView` off-screen inside a `NestedScrollView` or Compose scrollable `Column`, the view remains attached to the window and the window retains focus. As a result, **autoplay continues even though the PlayerDeck is no longer visible to the user**.

We provide two approaches to solve this problem.

***

## Approach 1: Manual Viewport Notification

The host code detects scrolling itself and calls `onViewPortEntered()` / `onViewPortLeft()` on the `FwPlayerDeckView` when it crosses a visibility threshold (e.g., 50%).

**Pros:**

* Full control over visibility detection logic.
* Works reliably in all environments (traditional Views and Compose).
* Compatible with any scrollable container, including `LazyColumn`.

**Cons:**

* Requires manual scroll tracking and visibility calculation.
* More boilerplate code in the host.

***

### Demo 1A: Traditional View (Fragment + NestedScrollView)

**Layout XML** (`fragment_player_deck_scrollable.xml`):

```xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/scrollView"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:fillViewport="true">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="vertical">

        <!-- Content above the PlayerDeck -->
        <TextView
            android:layout_width="match_parent"
            android:layout_height="300dp"
            android:gravity="center"
            android:text="Scroll down to see PlayerDeckView" />

        <!-- FwPlayerDeckView -->
        <com.firework.videofeed.FwPlayerDeckView
            android:id="@+id/playerDeck"
            android:layout_width="match_parent"
            android:layout_height="200dp" />

        <!-- Content below the PlayerDeck -->
        <TextView
            android:layout_width="match_parent"
            android:layout_height="800dp"
            android:gravity="center"
            android:text="Bottom content" />

    </LinearLayout>
</androidx.core.widget.NestedScrollView>
```

**Fragment** (`PlayerDeckScrollableFragment.kt`):

```kotlin
import android.graphics.Rect
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.ViewTreeObserver
import androidx.fragment.app.Fragment
import com.firework.common.feed.FeedResource
import com.firework.videofeed.FwPlayerDeckView
import com.firework.viewoptions.BaseOption
import com.firework.viewoptions.PlayerOption
import com.firework.viewoptions.ViewOptions
import kotlin.math.max
import kotlin.math.min

class PlayerDeckScrollableFragment : Fragment(),
    ViewTreeObserver.OnScrollChangedListener {

    private var playerDeckView: FwPlayerDeckView? = null
    private var scrollView: View? = null

    // Track whether the PlayerDeck was visible in the last check
    private var wasPlayerDeckVisible = false

    // Throttle scroll checks to avoid excessive computation
    private var lastScrollTime = 0L
    private val scrollThrottleMs = 100L

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?,
    ): View {
        val root = inflater.inflate(
            R.layout.fragment_player_deck_scrollable, container, false
        )
        playerDeckView = root.findViewById(R.id.playerDeck)
        scrollView = root.findViewById(R.id.scrollView)
        return root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        val viewOptions = ViewOptions.Builder()
            .baseOption(
                BaseOption.Builder()
                    .feedResource(FeedResource.Channel("your_channel_id"))
                    .build()
            )
            .playerOption(
                PlayerOption.Builder()
                    .autoplay(true)
                    .build()
            )
            .build()

        playerDeckView?.setOnErrorListener { error ->
            Log.e(TAG, "Error: $error")
        }

        playerDeckView?.init(viewOptions, childFragmentManager)
    }

    override fun onStart() {
        super.onStart()
        // Register scroll listener on the parent NestedScrollView
        scrollView?.viewTreeObserver?.addOnScrollChangedListener(this)
    }

    override fun onStop() {
        // Unregister when the fragment is no longer visible
        scrollView?.viewTreeObserver?.removeOnScrollChangedListener(this)
        super.onStop()
    }

    override fun onScrollChanged() {
        // Throttle: skip if called too frequently
        val now = System.currentTimeMillis()
        if (now - lastScrollTime < scrollThrottleMs) return
        lastScrollTime = now

        val deck = playerDeckView ?: return
        val isHalfVisible = deck.visibleRatio() >= 0.5f

        when {
            wasPlayerDeckVisible && !isHalfVisible -> {
                // Was visible, now scrolled out — pause playback
                deck.onViewPortLeft()
                Log.d(TAG, "PlayerDeck left viewport")
            }
            !wasPlayerDeckVisible && isHalfVisible -> {
                // Was hidden, now scrolled in — resume playback
                deck.onViewPortEntered()
                Log.d(TAG, "PlayerDeck entered viewport")
            }
        }
        wasPlayerDeckVisible = isHalfVisible
    }

    /**
     * Calculates the fraction of the view that is currently visible on screen.
     * Returns a value between 0.0 (fully hidden) and 1.0 (fully visible).
     */
    private fun View.visibleRatio(): Float {
        if (!isShown || height == 0) return 0f
        val rect = Rect()
        return if (getGlobalVisibleRect(rect)) {
            min(1f, max(0f, rect.height().toFloat() / height.toFloat()))
        } else {
            0f
        }
    }

    override fun onDestroyView() {
        try {
            scrollView?.viewTreeObserver?.removeOnScrollChangedListener(this)
        } catch (e: Exception) {
            Log.w(TAG, "Cleanup error", e)
        }
        playerDeckView?.destroy()
        playerDeckView = null
        scrollView = null
        super.onDestroyView()
    }

    companion object {
        private const val TAG = "PlayerDeckScrollable"
    }
}
```

**How it works:**

1. An `OnScrollChangedListener` is registered on the `NestedScrollView`'s `ViewTreeObserver`.
2. On each scroll event (throttled to every 100ms), `getGlobalVisibleRect()` computes how much of the `FwPlayerDeckView` is on screen.
3. When the visible ratio drops below 50%, `onViewPortLeft()` pauses playback.
4. When the visible ratio reaches 50% or above, `onViewPortEntered()` resumes playback.

***

### Demo 1B: Compose (Activity + Scrollable Column)

This example shows manual viewport notification in a Compose-based UI, using `rememberScrollState()` and the `update` block of `AndroidView` to track visibility.

```kotlin
import android.content.Context
import android.graphics.Rect
import android.os.Bundle
import android.util.Log
import android.view.ViewGroup
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.fragment.app.FragmentActivity
import com.firework.common.feed.FeedResource
import com.firework.videofeed.FwPlayerDeckView
import com.firework.viewoptions.BaseOption
import com.firework.viewoptions.PlayerOption
import com.firework.viewoptions.ViewOptions
import kotlin.math.max
import kotlin.math.min

class ManualScrollComposeActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MaterialTheme {
                val scrollState = rememberScrollState()
                Column(
                    modifier = Modifier
                        .fillMaxSize()
                        .verticalScroll(scrollState),
                ) {
                    // Top spacer to push PlayerDeck below the fold
                    Spacer(modifier = Modifier.fillMaxWidth().height(800.dp))

                    // PlayerDeck with manual scroll tracking
                    ScrollablePlayerDeckItem(
                        modifier = Modifier.fillMaxWidth().height(200.dp),
                        scrollState = scrollState,
                    )

                    // Bottom spacer
                    Spacer(modifier = Modifier.fillMaxWidth().height(800.dp))
                }
            }
        }
    }
}

@Composable
private fun ScrollablePlayerDeckItem(
    modifier: Modifier = Modifier,
    scrollState: ScrollState,
) {
    val context = LocalContext.current

    val viewOptions = remember {
        ViewOptions.Builder()
            .baseOption(
                BaseOption.Builder()
                    .feedResource(FeedResource.Channel("your_channel_id"))
                    .build()
            )
            .playerOption(
                PlayerOption.Builder()
                    .autoplay(true)
                    .build()
            )
            .build()
    }

    // Track the last known visibility state to avoid redundant calls
    var lastVisibilityState by remember { mutableStateOf(false) }

    // Read scrollState.value so that recomposition (and thus the update block)
    // is triggered whenever the scroll position changes.
    val currentScrollValue = scrollState.value

    AndroidView(
        modifier = modifier,
        factory = { androidContext ->
            FwPlayerDeckView(androidContext).apply {
                layoutParams = ViewGroup.LayoutParams(
                    ViewGroup.LayoutParams.MATCH_PARENT,
                    ViewGroup.LayoutParams.MATCH_PARENT,
                )

                setOnErrorListener { error ->
                    Log.e("ManualScrollCompose", "Error: $error")
                }

                // Initialize the view
                val fragmentManager =
                    (context as FragmentActivity).supportFragmentManager
                init(viewOptions, fragmentManager)
            }
        },
        update = { view ->
            // IMPORTANT: Reference currentScrollValue here so the Compose compiler
            // sees this lambda captures a value that changes on every scroll.
            // Without this, strong-skipping mode treats the lambda as unchanged
            // (its only other capture, lastVisibilityState, is a stable State object)
            // and skips calling update entirely.
            @Suppress("UNUSED_EXPRESSION")
            currentScrollValue

            // Compute visibility using getGlobalVisibleRect (consistent with Demo 1A and the SDK internals).
            val rect = Rect()
            val isHalfVisible = if (view.getGlobalVisibleRect(rect) && view.height > 0) {
                min(1f, max(0f, rect.height().toFloat() / view.height.toFloat())) >= 0.5f
            } else {
                false
            }

            // Only notify the PlayerDeck when visibility state actually changes
            if (isHalfVisible != lastVisibilityState) {
                if (isHalfVisible) {
                    view.onViewPortEntered()
                } else {
                    view.onViewPortLeft()
                }
                lastVisibilityState = isHalfVisible
            }
        },
        onRelease = { view ->
            view.destroy()
        },
    )
}
```

**How it works:**

1. `rememberScrollState()` tracks the scroll position of the `Column`. Reading `scrollState.value` in the composable body triggers recomposition whenever the scroll position changes.
2. **Critical:** `currentScrollValue` is referenced inside the `update` lambda. This is required because Compose's **strong-skipping mode** (enabled by default since Compose compiler 1.5.4+) remembers lambdas based on their captured values. Without this reference, the lambda only captures `lastVisibilityState` (a `MutableState` object whose reference never changes), so Compose considers the lambda unchanged and **skips calling `update` entirely** — even though the parent composable recomposed.
3. In the `update` block, `getGlobalVisibleRect()` computes the visible portion of the `AndroidView` on screen (consistent with Demo 1A and the SDK's internal tracker).
4. The `lastVisibilityState` guard prevents redundant `onViewPortEntered()` / `onViewPortLeft()` calls — only actual state transitions trigger notification.
5. `onViewPortEntered()` / `onViewPortLeft()` are called when the visible ratio crosses the 50% threshold.

***

## Approach 2: Automatic Visibility Tracking

Call `setVisibilityTrackingEnabled(true)` before or after `init()`. The SDK will internally track visibility and pause/resume playback automatically. No manual scroll-tracking code is needed.

**Pros:**

* Zero manual scroll tracking code required.
* Single line of configuration.

**Cons:**

* May not work in certain Compose scenarios (see [Known Limitations](#known-limitations-of-automatic-tracking-in-compose) below).

***

### Demo 2A: Traditional View (Fragment + NestedScrollView)

Use the same XML layout as Demo 1A. The Fragment is much simpler because no scroll listener is needed:

```kotlin
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import com.firework.common.feed.FeedResource
import com.firework.videofeed.FwPlayerDeckView
import com.firework.viewoptions.BaseOption
import com.firework.viewoptions.PlayerOption
import com.firework.viewoptions.ViewOptions

class PlayerDeckScrollableFragmentAuto : Fragment() {

    private var playerDeckView: FwPlayerDeckView? = null

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?,
    ): View {
        val root = inflater.inflate(
            R.layout.fragment_player_deck_scrollable, container, false
        )
        playerDeckView = root.findViewById(R.id.playerDeck)
        return root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        val viewOptions = ViewOptions.Builder()
            .baseOption(
                BaseOption.Builder()
                    .feedResource(FeedResource.Channel("your_channel_id"))
                    .build()
            )
            .playerOption(
                PlayerOption.Builder()
                    .autoplay(true)
                    .build()
            )
            .build()

        playerDeckView?.setOnErrorListener { error ->
            Log.e("PlayerDeckAuto", "Error: $error")
        }

        // Enable automatic visibility tracking — this is the only extra line needed
        playerDeckView?.setVisibilityTrackingEnabled(true)

        playerDeckView?.init(viewOptions, childFragmentManager)
    }

    override fun onDestroyView() {
        playerDeckView?.destroy()
        playerDeckView = null
        super.onDestroyView()
    }
}
```

Compared to Demo 1A, the only addition is:

```kotlin
playerDeckView?.setVisibilityTrackingEnabled(true)
```

No `OnScrollChangedListener`, no `visibleRatio()` calculation, no manual `onViewPortEntered()`/`onViewPortLeft()` calls.

***

### Demo 2B: Compose (Activity + Scrollable Column)

```kotlin
import android.os.Bundle
import android.util.Log
import android.view.ViewGroup
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.fragment.app.FragmentActivity
import com.firework.common.feed.FeedResource
import com.firework.videofeed.FwPlayerDeckView
import com.firework.viewoptions.BaseOption
import com.firework.viewoptions.PlayerOption
import com.firework.viewoptions.ViewOptions

class AutoTrackingComposeActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MaterialTheme {
                val scrollState = rememberScrollState()
                // Use a regular scrollable Column (NOT LazyColumn — see limitations below)
                Column(
                    modifier = Modifier
                        .fillMaxSize()
                        .verticalScroll(scrollState),
                ) {
                    Spacer(modifier = Modifier.fillMaxWidth().height(800.dp))
                    PlayerDeckAutoSection()
                    Spacer(modifier = Modifier.fillMaxWidth().height(800.dp))
                }
            }
        }
    }
}

@Composable
private fun PlayerDeckAutoSection() {
    val context = LocalContext.current

    val viewOptions = remember {
        ViewOptions.Builder()
            .baseOption(
                BaseOption.Builder()
                    .feedResource(FeedResource.Channel("your_channel_id"))
                    .build()
            )
            .playerOption(
                PlayerOption.Builder()
                    .autoplay(true)
                    .build()
            )
            .build()
    }

    AndroidView(
        modifier = Modifier
            .fillMaxWidth()
            .height(200.dp),
        factory = { androidContext ->
            FwPlayerDeckView(androidContext).apply {
                layoutParams = ViewGroup.LayoutParams(
                    ViewGroup.LayoutParams.MATCH_PARENT,
                    ViewGroup.LayoutParams.MATCH_PARENT,
                )

                setOnErrorListener { error ->
                    Log.e("AutoTrackingCompose", "Error: $error")
                }

                // Enable automatic visibility tracking
                setVisibilityTrackingEnabled(true)

                // Initialize the view
                val fragmentManager =
                    (context as FragmentActivity).supportFragmentManager
                init(viewOptions, fragmentManager)
            }
        },
        onRelease = { view ->
            view.destroy()
        },
    )
}
```

**Key point:** `setVisibilityTrackingEnabled(true)` can be called before or after `init()`. In the example above it is called before `init()` inside the `factory` block so the tracker starts as soon as the view is initialized.

> **Important:** Use a regular scrollable `Column` (with `Modifier.verticalScroll()`), **not** `LazyColumn`. See the limitations section below.

***

## How Automatic Tracking Works Internally

When `setVisibilityTrackingEnabled(true)` is called, the SDK creates a `ViewVisibilityTracker` that monitors the view using the Android View system:

1. **Listeners registered:** `OnGlobalLayoutListener` and `OnScrollChangedListener` on the view's `ViewTreeObserver`. An `OnAttachStateChangeListener` handles window attach/detach events.
2. **Visibility computation:** On each scroll or layout event (throttled to every 50ms to avoid excessive computation), the tracker calls `getGlobalVisibleRect()` on the view and computes:

   ```
   visibleFraction = (visibleRect.width * visibleRect.height) / (view.width * view.height)
   ```
3. **Threshold:** If `visibleFraction >= 0.5` (50%), the view is considered visible. If it drops below 50%, the view is considered hidden.
4. **Callback:** When the visibility state changes, the tracker notifies the `FwPlayerDeckView`, which calls `playerManager.pauseTemporarily()` or `playerManager.resumeFromTemporaryPause()` accordingly.
5. **Cleanup:** The tracker is automatically stopped when `destroy()` is called.

***

## Known Limitations of Automatic Tracking in Compose

The automatic visibility tracking (`setVisibilityTrackingEnabled`) relies on Android View system mechanisms (`ViewTreeObserver`, `getGlobalVisibleRect`). In certain Jetpack Compose scenarios, these mechanisms do not work correctly.

### 1. LazyColumn / LazyRow / LazyVerticalGrid

`LazyColumn` and similar lazy composables **dispose** the `AndroidView` entirely when items scroll out of the visible area. The view is destroyed (removed from the composition tree) rather than merely scrolled off-screen.

**Impact:**

* The `ViewVisibilityTracker` never gets a chance to detect "scrolled out" — the view is simply gone.
* Autoplay does stop (because the view is detached from the window), but re-entering the viewport creates a **brand new** view instance, losing all internal state.
* The `onRelease` callback fires on every scroll-out, calling `destroy()`, and `factory` fires on every scroll-in, calling `init()` again.

### 2. Deeply Nested Compose Layout Nodes with Clipping

If the `AndroidView` is nested inside multiple Compose containers that apply `Modifier.clip()` or custom clipping, `getGlobalVisibleRect()` may not accurately reflect Compose-level clipping.

**Impact:**

* The Android View system and Compose layout system track clipping independently.
* The visible rect may report the view as "visible" when Compose has actually clipped it, causing autoplay to continue when the view is not visible to the user.

### 3. Compose Scroll Containers That Do Not Trigger OnScrollChangedListener

While `Modifier.verticalScroll()` typically propagates scroll events through the underlying `AndroidComposeView`, there are edge cases where offset changes are handled purely in Compose's layout system and never dispatched as traditional scroll events to `ViewTreeObserver`:

* **`Modifier.offset` with animated values** — the offset is applied at the Compose layout level.
* **Custom `NestedScrollConnection`** — scroll deltas may be consumed before reaching the View layer.
* **`HorizontalPager` / `VerticalPager`** — page transitions use Compose-internal animation.

**Impact:**

* The `OnScrollChangedListener` never fires, so the tracker cannot detect visibility changes.
* Autoplay continues even though the view has been scrolled off-screen.

***

## Recommendation Summary

| Scenario                                     | Recommended Approach                                               |
| -------------------------------------------- | ------------------------------------------------------------------ |
| `NestedScrollView` (traditional View)        | **Approach 2** (automatic) — simplest setup                        |
| Compose `Column` with `verticalScroll`       | **Approach 2** (automatic) — generally works                       |
| `LazyColumn` / `LazyRow`                     | **Approach 1** (manual) — required because views are disposed      |
| `HorizontalPager` / `VerticalPager`          | **Approach 1** (manual) — scroll events may not propagate          |
| Complex nested Compose layouts with clipping | **Approach 1** (manual) — `getGlobalVisibleRect` may be inaccurate |

**General rule of thumb:**

* For simple layouts, prefer **Approach 2** for its simplicity.
* For `LazyColumn`/`LazyRow` scenarios, always use **Approach 1** or avoid `LazyColumn` entirely by using a regular scrollable `Column`.
* When in doubt, **Approach 1** is the safest choice since it gives you full control over the visibility detection logic.
