package com.fireworksdk.bridge.components.videofeed import android.content.Context import android.util.AttributeSet import android.view.Choreographer import android.view.View import android.view.ViewGroup import android.widget.FrameLayout import com.firework.error.FwErrorListener import com.firework.player.common.videoload.OnFeedLoadListener import com.firework.videofeed.FeedViewStateListener import com.firework.videofeed.FwVideoFeedView import com.fireworksdk.bridge.models.* import com.fireworksdk.bridge.utils.FWComponentType import com.fireworksdk.bridge.utils.FWConfigUtil class FWVideoFeed( context: Context, attrs: AttributeSet? ) : FrameLayout(context, attrs) { constructor(context: Context) : this(context, null) val videoFeedView = FwVideoFeedView(context) var videoFeedPropsModel: FWVideoFeedPropsModel = FWVideoFeedPropsModel() var themedReactContext: Any? = null private var isInit = false private var isDisposed = false private var onWindowFocusChanged = false private var feedViewStateListener: FeedViewStateListener? = null private var layoutFrameCallback: Choreographer.FrameCallback? = null private var needsForceLayout = false /** * Latched from the props when the native feed is initialised, like the rest of the view options. * JS re-creates this view whenever `autosizing`, `maxVideos` or `mode` changes. */ private var isAutosizedGrid = false private val autosizingState = FWAutosizingState() private var viewportDirty = false /** * Invoked on the UI thread with the feed's content height in dp whenever it changes, while * autosizing is on. React Native never reads a native view's own measurement, so the height has * to travel to JS and come back as this view's Yoga height. */ var onContentHeightChanged: ((Float) -> Unit)? = null init { // [Fix: PiP overlay locale issue on Android 16] // Removed: This call was ineffective because the returned Context was discarded. // It relied on the deprecated side-effect of updateConfiguration() to mutate the // existing Resources in-place, which corrupts the resource table on Android 16, // causing string lookups (e.g., PiP overlay text) to fallback to English. // The locale is already correctly applied at the Activity level via attachBaseContext(). // FWLanguageUtil.getInstance(context).updateBaseContextLocale(context) videoFeedView.layoutDirection = LAYOUT_DIRECTION_LOCALE addView(videoFeedView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)) } override fun onAttachedToWindow() { super.onAttachedToWindow() needsForceLayout = true viewportDirty = true autosizingState.invalidate() startLayoutUpdates() } override fun onDetachedFromWindow() { super.onDetachedFromWindow() // FlatList can temporarily detach a mounted cell via removeClippedSubviews. Autosized // content still needs to measure and report its full height while clipped; otherwise the // placeholder stays outside the viewport and the cell cannot expand until scrolled back. // Normal passes use Android measure caches; forced subtree measurement remains bounded. if (!isAutosizedGrid) stopLayoutUpdates() } override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { super.onSizeChanged(w, h, oldw, oldh) // The next normal layout pass restores the native child's content constraints after // Yoga applies this wrapper's height. Width changes also start a forced measurement round. viewportDirty = true } private fun startLayoutUpdates() { if (isDisposed || layoutFrameCallback != null) return layoutFrameCallback = Choreographer.FrameCallback { val didLayout = manuallyLayoutChildren() val notifyViewport = didLayout || viewportDirty viewportDirty = false if (notifyViewport && viewTreeObserver.isAlive) { viewTreeObserver.dispatchOnGlobalLayout() } layoutFrameCallback?.let { Choreographer.getInstance().postFrameCallback(it) } } Choreographer.getInstance().postFrameCallback(layoutFrameCallback!!) } private fun stopLayoutUpdates() { layoutFrameCallback?.let { Choreographer.getInstance().removeFrameCallback(it) } layoutFrameCallback = null } private fun manuallyLayoutChildren(): Boolean { if (needsForceLayout) { videoFeedView.forceLayout() needsForceLayout = false } if (isAutosizedGrid) { return layoutAutosizedChild() } videoFeedView.measure( MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY) ) videoFeedView.layout(0, 0, width, height) return true } /** * Measures the feed against an unbounded height so the SDK's `wrap_content` grid reports the * height of its whole content, and reports that height to JS. * * The child is laid out at its own measured height rather than at [getHeight], which still comes * from Yoga and lags a frame behind: laying the feed out at the stale height would lay its * RecyclerView out short too, so it would bind fewer rows and measure shorter again on the next * pass. This view keeps clipping the overflow until JS applies the new height. */ private fun layoutAutosizedChild(): Boolean { val pass = autosizingState.nextPass(width, videoFeedView.isLayoutRequested) if (pass == FWAutosizingState.Pass.NONE) return false if (pass == FWAutosizingState.Pass.FORCE) { // forceLayout does not descend. A content change must bypass every child's measure cache. forceSubtreeLayout(videoFeedView) } videoFeedView.measure( MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED) ) val contentHeight = videoFeedView.measuredHeight // Complete RecyclerView's layout cycle even for a transient zero measurement. Use its own // measured height; the Yoga height is still waiting for the native -> JS round trip. videoFeedView.layout(0, 0, width, contentHeight) autosizingState.didMeasure(contentHeight) reportContentHeight(contentHeight) return true } /** * Marks [view] and everything inside it as needing layout, so the next measure is computed * rather than answered from the view caches. */ private fun forceSubtreeLayout(view: View) { view.forceLayout() if (view is ViewGroup) { for (index in 0 until view.childCount) { forceSubtreeLayout(view.getChildAt(index)) } } } private fun reportContentHeight(heightPx: Int) { // Compare the final dp value before crossing the bridge. Different pixel measurements can // round to the same Yoga height. Keep loading zeros local, but report a confirmed empty feed. val listener = onContentHeightChanged ?: return autosizingState.heightToReport(heightPx, resources.displayMetrics.density)?.let(listener) } fun setVideoFeedLayoutParams(l: LayoutParams?) { l ?: return videoFeedView.layoutParams = l requestLayout() } /** Starts a new bounded measurement round for an explicit SDK content change. */ fun invalidateContentHeight() { autosizingState.invalidate() } fun setContentEmpty(empty: Boolean) { autosizingState.setContentEmpty(empty) } fun dispose() { isDisposed = true stopLayoutUpdates() onContentHeightChanged = null videoFeedView.destroy() } fun initVideoFeedView() { if (isInit) { return } isAutosizedGrid = videoFeedPropsModel.isAutosizedGrid if (isAutosizedGrid) { // The SDK switches its own content to `wrap_content` for an expanded grid; its root has to // follow, or this FrameLayout would still constrain it to a fixed height. videoFeedView.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) } val viewOptionsBuilder = FWConfigUtil.generateViewOptionsBuilder(context, videoFeedPropsModel, FWComponentType.VIDEO_FEED) videoFeedView.init(viewOptionsBuilder.build()) invalidateContentHeight() isInit = true // A new FlatList cell can be clipped before it ever attaches. Its Yoga width is still // supplied by RN, so do not wait for onAttachedToWindow to start autosizing it. if (isAutosizedGrid) startLayoutUpdates() } fun refresh() { setContentEmpty(false) videoFeedView.refresh() } fun getFeedId(): String { return videoFeedView.feedId } fun setOnFeedViewStateListener(feedViewStateListener: FeedViewStateListener?) { this.feedViewStateListener = feedViewStateListener videoFeedView.setOnFeedViewStateListener { feedViewState -> if (!onWindowFocusChanged) { videoFeedView.dispatchWindowFocusChanged(true) onWindowFocusChanged = true } feedViewStateListener?.onLoadStateChanged( feedViewState ) } } /** * Forwards the host listener to the SDK view. Safe to call before [initVideoFeedView]: the SDK * view keeps the listener and registers it in its own `init()`, so the first page is reported too. */ fun setOnFeedLoadListener(listener: OnFeedLoadListener) { videoFeedView.setOnFeedLoadListener(listener) } fun setOnErrorListener(errorListener: FwErrorListener?) { videoFeedView.setOnErrorListener { error -> if (!onWindowFocusChanged) { videoFeedView.dispatchWindowFocusChanged(true) onWindowFocusChanged = true } errorListener?.onFwError(error) } } }