package com.fireworksdk.bridge.components.playerdeck import android.content.Context import android.util.AttributeSet import android.view.MotionEvent import android.view.ViewConfiguration import android.view.ViewGroup import android.widget.FrameLayout import androidx.fragment.app.FragmentManager import com.firework.error.FwErrorListener import com.firework.player.common.videoload.OnFeedLoadListener import com.firework.videofeed.FeedItemClickListener import com.firework.videofeed.FeedViewStateListener import com.firework.videofeed.FwPlayerDeckView import com.firework.videofeed.PlayerDeckShoppingOverlayListener import com.firework.viewoptions.PlayerDeckOption import com.firework.viewoptions.ViewOptions import com.fireworksdk.bridge.models.FWVideoFeedConfigModel import com.fireworksdk.bridge.models.FWVideoFeedPropsModel import com.fireworksdk.bridge.models.enums.FWVideoFeedMode import com.fireworksdk.bridge.utils.FWColorUtil import com.fireworksdk.bridge.utils.FWCommonUtil import com.fireworksdk.bridge.utils.FWComponentType import com.fireworksdk.bridge.utils.FWConfigUtil import com.fireworksdk.bridge.utils.FWLanguageUtil import org.json.JSONObject import kotlin.math.abs class PlayerDeckContainerView( context: Context, attrs: AttributeSet? = null, ) : FrameLayout(context, attrs) { var videoFeedPropsModel: FWVideoFeedPropsModel = FWVideoFeedPropsModel() var playerDeckConfigMap: Map? = null var isShoppingOverlayOpen = false private val playerDeckView = FwPlayerDeckView(context) private var isInit = false private var onWindowFocusChanged = false private var feedViewStateListener: FeedViewStateListener? = null private var errorListener: FwErrorListener? = null private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop private var downX = 0f private var downY = 0f private var directionDetermined = false private var isHorizontalGesture = false private var touchStartedInsideSheet = false init { FWLanguageUtil.getInstance(context).updateBaseContextLocale(context) playerDeckView.layoutDirection = LAYOUT_DIRECTION_LOCALE addView(playerDeckView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)) } // Resolves nested-scroll conflicts when this view sits inside a (RN) ScrollView. // // The internal horizontal RecyclerView ships with an OnItemTouchListener that, on every // ACTION_MOVE, calls parent.requestDisallowInterceptTouchEvent(true) — which propagates up // the view tree and locks the outer ScrollView out of the gesture. We override that decision // after super.dispatchTouchEvent runs, so our request is the last word for this MotionEvent: // * touch started inside an open shopping overlay -> keep parents blocked (sheet scrolls) // * horizontal gesture -> keep parents blocked (PlayerDeck pages) // * vertical gesture -> release parents (outer ScrollView gets to intercept and scroll) override fun dispatchTouchEvent(ev: MotionEvent): Boolean { when (ev.actionMasked) { MotionEvent.ACTION_DOWN -> { downX = ev.x downY = ev.y directionDetermined = false isHorizontalGesture = false touchStartedInsideSheet = isShoppingOverlayOpen && playerDeckView.isTouchInsideActiveOverlay(ev.rawX, ev.rawY) } MotionEvent.ACTION_MOVE -> { if (!touchStartedInsideSheet && !directionDetermined) { val dx = abs(ev.x - downX) val dy = abs(ev.y - downY) if (dx > touchSlop || dy > touchSlop) { directionDetermined = true isHorizontalGesture = dx > dy } } } } val handled = super.dispatchTouchEvent(ev) // Apply our disallow-intercept decision AFTER children processed the event so it // overrides any call made by the internal RecyclerView's OnItemTouchListener. when (ev.actionMasked) { MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> { val disallowParent = when { touchStartedInsideSheet -> true !directionDetermined -> false else -> isHorizontalGesture } parent?.requestDisallowInterceptTouchEvent(disallowParent) if (touchStartedInsideSheet) { playerDeckView.requestRecyclerViewDisallowInterceptTouchEvent(true) } } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { parent?.requestDisallowInterceptTouchEvent(false) playerDeckView.requestRecyclerViewDisallowInterceptTouchEvent(false) directionDetermined = false isHorizontalGesture = false touchStartedInsideSheet = false } } return handled } fun initPlayerDeck(fragmentManager: FragmentManager) { if (isInit) return val enrichedProps = enrichPropsWithDeckConfig() val viewOptionsBuilder = FWConfigUtil.generateViewOptionsBuilder( context, enrichedProps, FWComponentType.PLAYER_DECK ) applyPlayerDeckOption(viewOptionsBuilder) val viewOptions = viewOptionsBuilder.build() playerDeckView.init(viewOptions, fragmentManager) isInit = true applyContentMargin(enrichedProps) setFeedViewStateListener(feedViewStateListener) setOnErrorListener(errorListener) } fun getFeedId(): String = playerDeckView.feedId fun refresh() { playerDeckView.refresh() } fun setFeedViewStateListener(listener: FeedViewStateListener?) { this.feedViewStateListener = listener if (isInit) { playerDeckView.setOnFeedViewStateListener { feedViewState -> if (!onWindowFocusChanged) { playerDeckView.dispatchWindowFocusChanged(true) onWindowFocusChanged = true } listener?.onLoadStateChanged(feedViewState) } } } fun setOnErrorListener(listener: FwErrorListener?) { this.errorListener = listener if (isInit) { playerDeckView.setOnErrorListener { error -> if (!onWindowFocusChanged) { playerDeckView.dispatchWindowFocusChanged(true) onWindowFocusChanged = true } listener?.onFwError(error) } } } // Forwarded straight away, unlike the state/error listeners which the SDK view only accepts // once initialised: it keeps this one and registers it inside its own init(), so the first // page is reported too. addPlayerDeckListeners() runs before initPlayerDeck(), which is // exactly that case. destroy() unregisters it on the SDK view side. fun setOnFeedLoadListener(listener: OnFeedLoadListener?) { listener?.let { playerDeckView.setOnFeedLoadListener(it) } } fun setOnFeedItemClickListener(listener: FeedItemClickListener?) { listener?.let { playerDeckView.setOnFeedItemClickListener(it) } } fun setShoppingOverlayListener(listener: PlayerDeckShoppingOverlayListener?) { playerDeckView.setShoppingOverlayListener(listener) } // Destroy the SDK view while it is still attached to this container, so its internal // RecyclerView cleanup runs in a normal view hierarchy state. Remove from parent afterwards. fun destroy() { feedViewStateListener = null errorListener = null isShoppingOverlayOpen = false touchStartedInsideSheet = false playerDeckView.destroy() removeView(playerDeckView) isInit = false onWindowFocusChanged = false } private fun enrichPropsWithDeckConfig(): FWVideoFeedPropsModel { val configMap = playerDeckConfigMap val json = configMap?.let { JSONObject(it) } val backgroundColor = json?.optString("backgroundColor")?.takeIf { it.isNotBlank() } val cornerRadius = json?.optInt("cornerRadius", -1)?.takeIf { it >= 0 } val itemSpacing = json?.optInt("itemSpacing", -1)?.takeIf { it >= 0 } val enableAutoplay = parseAutoplay(json) ?: true val showAdBadge = json?.let { if (it.has("showAdBadge")) it.optBoolean("showAdBadge") else null } val playIcon = parsePlayIcon(json) val contentPadding = parseContentPadding(json) val feedConfig = FWVideoFeedConfigModel( backgroundColor = backgroundColor, cornerRadius = cornerRadius, itemSpacing = itemSpacing, enableAutoplay = enableAutoplay, showAdBadge = showAdBadge, playIcon = playIcon, contentPadding = contentPadding, ) return videoFeedPropsModel.copy( mode = FWVideoFeedMode.Row, videoFeedConfiguration = feedConfig, ) } private fun parseAutoplay(json: JSONObject?): Boolean? { json ?: return null if (json.has("autoplay")) { val autoplayObj = json.optJSONObject("autoplay") if (autoplayObj != null && autoplayObj.has("isEnabled")) { return autoplayObj.optBoolean("isEnabled") } } if (json.has("enableAutoplay")) { return json.optBoolean("enableAutoplay") } return null } private fun parsePlayIcon(json: JSONObject?): FWVideoFeedConfigModel.FWPlayIconModel? { json ?: return null if (json.has("playIcon")) { val playIconObj = json.optJSONObject("playIcon") ?: return null val hidden = if (playIconObj.has("hidden")) playIconObj.optBoolean("hidden") else null val iconWidth = if (playIconObj.has("iconWidth")) playIconObj.optInt("iconWidth", -1).takeIf { it >= 0 } else null val model = FWVideoFeedConfigModel.FWPlayIconModel( hidden = hidden, iconWidth = iconWidth, imageName = playIconObj.optString("imageName").takeIf { it.isNotBlank() }, ) return model.takeIf { it != FWVideoFeedConfigModel.FWPlayIconModel() } } val hidePlayIcon = if (json.has("hidePlayIcon")) json.optBoolean("hidePlayIcon") else null val playIconWidth = if (json.has("playIconWidth")) json.optInt("playIconWidth", -1).takeIf { it >= 0 } else null return if (hidePlayIcon != null || playIconWidth != null) { FWVideoFeedConfigModel.FWPlayIconModel(hidden = hidePlayIcon, iconWidth = playIconWidth) } else null } private fun parseContentPadding(json: JSONObject?): FWVideoFeedConfigModel.FWPaddingModel? { val paddingJson = json?.optJSONObject("contentPadding") ?: return null return FWVideoFeedConfigModel.FWPaddingModel( top = if (paddingJson.has("top")) paddingJson.optInt("top") else null, right = if (paddingJson.has("right")) paddingJson.optInt("right") else null, bottom = if (paddingJson.has("bottom")) paddingJson.optInt("bottom") else null, left = if (paddingJson.has("left")) paddingJson.optInt("left") else null, ) } private fun applyPlayerDeckOption(viewOptionsBuilder: ViewOptions.Builder) { val configMap = playerDeckConfigMap ?: return val json = JSONObject(configMap) val playerDeckOptionBuilder = PlayerDeckOption.Builder() val enableFullScreen = parseFullScreen(json) if (enableFullScreen != null) { playerDeckOptionBuilder.showFullScreenIcon(enableFullScreen) } val showShareButton = parseShareButton(json) if (showShareButton != null) { playerDeckOptionBuilder.showShareButton(showShareButton) } if (json.has("subtitleConfiguration")) { val subtitleJson = json.optJSONObject("subtitleConfiguration") if (subtitleJson != null) { val textColor = subtitleJson.optString("textColor").takeIf { it.isNotBlank() } if (textColor != null) { playerDeckOptionBuilder.subtitleTextColor(FWColorUtil.parseColor(textColor)) } val bgColor = subtitleJson.optString("backgroundColor").takeIf { it.isNotBlank() } if (bgColor != null) { playerDeckOptionBuilder.subtitleBackgroundColor(FWColorUtil.parseColor(bgColor)) } } } viewOptionsBuilder.playerDeckOption(playerDeckOptionBuilder.build()) } private fun parseFullScreen(json: JSONObject): Boolean? { if (json.has("fullScreen")) { val fsObj = json.optJSONObject("fullScreen") if (fsObj != null && fsObj.has("isEnabled")) { return fsObj.optBoolean("isEnabled") } } if (json.has("enableFullScreen")) { return json.optBoolean("enableFullScreen") } return null } private fun parseShareButton(json: JSONObject): Boolean? { if (json.has("shareButton")) { val sbObj = json.optJSONObject("shareButton") if (sbObj != null && sbObj.has("hidden")) { return !sbObj.optBoolean("hidden") } } if (json.has("showShareButton")) { return json.optBoolean("showShareButton") } return null } private fun applyContentMargin(enrichedProps: FWVideoFeedPropsModel) { val right = enrichedProps.marginRight if (right != null && right > 0) { val layoutParams = FrameLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT ) layoutParams.setMargins(0, 0, FWCommonUtil.dpToPx(right.toFloat(), context), 0) playerDeckView.layoutParams = layoutParams } } }