package com.github.libretube.ui.fragments

import android.annotation.SuppressLint
import android.app.Activity
import android.app.Dialog
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.ActivityInfo
import android.content.res.Configuration
import android.graphics.Bitmap
import android.media.session.PlaybackState
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.PowerManager
import android.util.Log
import android.view.KeyEvent
import android.view.PixelCopy
import android.view.SurfaceView
import android.view.View
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams
import android.widget.Toast
import androidx.activity.BackEventCompat
import androidx.activity.OnBackPressedCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.constraintlayout.motion.widget.MotionLayout
import androidx.constraintlayout.motion.widget.TransitionAdapter
import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService
import androidx.core.graphics.drawable.toDrawable
import androidx.core.net.toUri
import androidx.core.os.bundleOf
import androidx.core.os.postDelayed
import androidx.core.view.WindowCompat
import androidx.core.view.isGone
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import com.github.libretube.extensions.navigateVideo
import androidx.fragment.app.activityViewModels
import androidx.mediarouter.app.MediaRouteButton
import androidx.mediarouter.media.MediaControlIntent
import androidx.mediarouter.media.MediaRouteSelector
import androidx.fragment.app.commit
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.session.MediaController
import androidx.recyclerview.widget.LinearLayoutManager
import com.github.libretube.R
import com.github.libretube.api.JsonHelper
import com.github.libretube.api.obj.ChapterSegment
import com.github.libretube.api.obj.Segment
import com.github.libretube.api.obj.StreamItem
import com.github.libretube.api.obj.Streams
import com.github.libretube.compat.PictureInPictureCompat
import com.github.libretube.compat.PictureInPictureParamsCompat
import com.github.libretube.constants.IntentData
import com.github.libretube.databinding.FragmentPlayerBinding
import com.github.libretube.db.DatabaseHelper
import com.github.libretube.db.DatabaseHolder
import com.github.libretube.enums.FileType
import com.github.libretube.enums.PlayerCommand
import com.github.libretube.enums.PlayerEvent
import com.github.libretube.enums.SbSkipOptions
import com.github.libretube.enums.ShareObjectType
import com.github.libretube.extensions.formatShort
import com.github.libretube.extensions.parcelable
import com.github.libretube.extensions.serializableExtra
import com.github.libretube.extensions.TAG
import com.github.libretube.extensions.toID
import com.github.libretube.extensions.togglePlayPauseState
import com.github.libretube.extensions.updateIfChanged
import com.github.libretube.helpers.BackgroundHelper
import com.github.libretube.helpers.DownloadHelper
import com.github.libretube.helpers.ImageHelper
import com.github.libretube.api.MediaServiceRepository
import com.github.libretube.api.TrendingCategory
import com.github.libretube.helpers.NavigationHelper
import com.github.libretube.helpers.NetworkHelper
import com.github.libretube.helpers.PlayerHelper
import com.github.libretube.helpers.PreferenceHelper
import com.github.libretube.helpers.PlayerHelper.getCurrentSegment
import com.github.libretube.helpers.ThemeHelper
import com.github.libretube.api.PlaylistsHelper
import com.github.libretube.helpers.WindowHelper
import com.github.libretube.obj.ShareData
import com.github.libretube.parcelable.PlayerData
import com.github.libretube.services.AbstractPlayerService
import com.github.libretube.services.OfflinePlayerService
import com.github.libretube.services.OnlinePlayerService
import com.github.libretube.ui.activities.AbstractPlayerHostActivity
import com.github.libretube.ui.activities.NoInternetActivity
import com.github.libretube.ui.adapters.VideoCardsAdapter
import com.github.libretube.ui.base.BaseActivity
import com.github.libretube.ui.dialogs.AddToPlaylistDialog
import com.github.libretube.ui.dialogs.ShareDialog
import com.github.libretube.ui.extensions.addOnBottomReachedListener
import com.github.libretube.ui.extensions.animateDown
import com.github.libretube.ui.extensions.getSystemInsets
import com.github.libretube.ui.extensions.setOnBackPressed
import com.github.libretube.ui.extensions.setupSubscriptionButton
import com.github.libretube.ui.interfaces.CustomPlayerCallback
import com.github.libretube.ui.interfaces.TimeFrameReceiver
import com.github.libretube.ui.listeners.SeekbarPreviewListener
import com.github.libretube.ui.models.ChaptersViewModel
import com.github.libretube.ui.models.CommentsViewModel
import com.github.libretube.ui.models.CommonPlayerViewModel
import com.github.libretube.ui.models.PlayerViewModel
import com.github.libretube.ui.sheets.CommentsSheet
import com.github.libretube.util.OfflineTimeFrameReceiver
import com.github.libretube.util.OnlineTimeFrameReceiver
import com.github.libretube.util.PlayingQueue
import com.github.libretube.util.TextUtils
import com.github.libretube.util.TextUtils.toTimeInSeconds
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlin.io.path.exists
import kotlin.math.absoluteValue
import com.google.android.gms.cast.framework.CastContext
import com.google.android.gms.cast.framework.CastButtonFactory
import com.google.android.gms.cast.framework.CastSession
import com.google.android.gms.cast.framework.SessionManagerListener
import com.google.android.gms.cast.MediaInfo
import com.google.android.gms.cast.MediaMetadata as CastMediaMetadata


@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
class PlayerFragment : Fragment(R.layout.fragment_player), CustomPlayerCallback {
    private var _binding: FragmentPlayerBinding? = null
    val binding get() = _binding!!

    private val playerControlsBinding get() = binding.player.binding
    private val playerBackgroundBinding get() = binding.player.backgroundBinding

    private val commonPlayerViewModel: CommonPlayerViewModel by activityViewModels()
    private val viewModel: PlayerViewModel by viewModels()
    private val commentsViewModel: CommentsViewModel by activityViewModels()
    private val chaptersViewModel: ChaptersViewModel by activityViewModels()
    private lateinit var playerController: MediaController

    // Video information passed by the intent
    private lateinit var videoId: String
    private var playlistId: String? = null
    private var previousVideoId: String? = null
    private val endedVideoIds = mutableSetOf<String>()
    private var channelId: String? = null
    var isOffline: Boolean = false
        private set

    // data and objects stored for the player
    private lateinit var streams: Streams

    private var relatedStreamItems: List<StreamItem> = emptyList()
    private var relatedStreamPageIndex = 0
    private var relatedStreamPageSize = 8
    private var relatedStreamAdapter: VideoCardsAdapter? = null
    private var isLoadingRelatedStreams = false
    private var relatedLoadJob: Job? = null
    private var recommendationLoadJob: Job? = null

    // Additional carousels
    private var similarStreamItems: List<StreamItem> = emptyList()
    private var similarStreamAdapter: VideoCardsAdapter? = null
    
    private var trendingStreamItems: List<StreamItem> = emptyList()
    private var trendingStreamAdapter: VideoCardsAdapter? = null
    
    private var channelLatestItems: List<StreamItem> = emptyList()
    private var channelLatestAdapter: VideoCardsAdapter? = null
    
    private var recommendationsItems: List<StreamItem> = emptyList()
    private var recommendationsAdapter: VideoCardsAdapter? = null

    private val handler = Handler(Looper.getMainLooper())

    // Cast support
    private var castContext: CastContext? = null
    private var castSessionListener: SessionManagerListener<CastSession>? = null

    private var seekBarPreviewListener: SeekbarPreviewListener? = null

    // True when the video was closed through the close button on PiP mode
    private var closedVideo = false

    private var autoPlayCountdownEnabled = PlayerHelper.autoPlayCountdown

    /**
     * The orientation of the `fragment_player.xml` that's currently used
     * This is needed in order to figure out if the current layout is the landscape one or not.
     */
    private var playerLayoutOrientation = Int.MIN_VALUE

    // Activity that's active during PiP, can be used for controlling its lifecycle.
    private var pipActivity: Activity? = null

    // check if pip is entered via the dedicated button
    private var isEnteringPiPMode = false

    private val baseActivity get() = activity as AbstractPlayerHostActivity
    private val windowInsetsControllerCompat
        get() = WindowCompat
            .getInsetsController(requireActivity().window, requireActivity().window.decorView)

    private val fullscreenDialog by lazy {
        object : Dialog(requireContext(), android.R.style.Theme_Black_NoTitleBar_Fullscreen) {
            @Deprecated("Deprecated in Java", ReplaceWith("onbackpressedispatcher and callback"))
            override fun onBackPressed() {
                unsetFullscreen()
            }

            override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean {
                if (_binding?.player?.onKeyUp(keyCode, event) == true) {
                    return true
                }

                return super.onKeyUp(keyCode, event)
            }
        }
    }

    /**
     * Receiver for all actions in the PiP mode
     */
    private val playerActionReceiver = object : BroadcastReceiver() {
        override fun onReceive(context: Context, intent: Intent) {
            if (!::playerController.isInitialized) return
            val event = intent.serializableExtra<PlayerEvent>(PlayerHelper.CONTROL_TYPE) ?: return

            if (PlayerHelper.handlePlayerAction(playerController, event)) return

            when (event) {
                PlayerEvent.Next -> {
                    advanceToNextTrack()
                }

                PlayerEvent.Prev -> {
                    advanceToPreviousTrack()
                }

                PlayerEvent.Background -> {
                    switchToAudioMode()
                    // wait some time in order for the service to get started properly
                    handler.postDelayed(500) {
                        pipActivity?.moveTaskToBack(false)
                        pipActivity = null
                    }
                }

                else -> Unit
            }
        }
    }

    private var bufferingTimeoutTask: Runnable? = null

    private val playerListener = object : Player.Listener {
        override fun onIsPlayingChanged(isPlaying: Boolean) {
            if (PlayerHelper.pipEnabled || PictureInPictureCompat.isInPictureInPictureMode(
                    baseActivity
                )
            ) {
                PictureInPictureCompat.setPictureInPictureParams(requireActivity(), pipParams)
            }

            if (isPlaying && PlayerHelper.sponsorBlockEnabled) {
                handler.postDelayed(
                    this@PlayerFragment::checkForSegments,
                    100
                )
            }
        }

        override fun onEvents(player: Player, events: Player.Events) {
            super.onEvents(player, events)

            if (events.containsAny(
                    Player.EVENT_PLAYBACK_STATE_CHANGED,
                    Player.EVENT_IS_PLAYING_CHANGED,
                    Player.EVENT_PLAY_WHEN_READY_CHANGED
                ) && _binding != null
            ) {
                updatePlayPauseButton()
            }
        }

        override fun onPlaybackStateChanged(playbackState: Int) {
            // set the playback speed to one if having reached the end of a livestream
            if (playbackState == Player.STATE_BUFFERING && streams.isLive &&
                playerController.duration - playerController.currentPosition < 700
            ) {
                playerController.setPlaybackSpeed(1f)
            }

            // check if video has ended, next video is available and autoplay is enabled/the video is part of a played playlist.
            if (playbackState == Player.STATE_ENDED) {
                if (endedVideoIds.add(videoId)) {
                    relatedStreamItems = relatedStreamItems.filter { it.url?.toID() != videoId }
                    relatedStreamAdapter?.submitList(relatedStreamItems)

                    similarStreamItems = similarStreamItems.filter { it.url?.toID() != videoId }
                    similarStreamAdapter?.submitList(similarStreamItems)
                }

                playerBackgroundBinding.sbSkipBtn.isGone = true

                // if the current tracks are empty, the player is transitioning at the moment
                val isTransitioning = playerController.currentTracks.isEmpty
                if (PlayerHelper.isAutoPlayEnabled(playlistId != null) && !isTransitioning) {
                    if (autoPlayCountdownEnabled) {
                        // With a countdown enabled the service deliberately yields autoplay to the
                        // UI; the countdown callback performs the single transition.
                        showAutoPlayCountdown()
                    }
                    // Without a countdown, Online/OfflinePlayerService is the only owner of
                    // STATE_ENDED autoplay. Do not also call advanceToNextTrack() here: doing both
                    // races two navigate requests for the same end-of-song event and can leave the
                    // player in an error/idle state instead of continuing.
                } else {
                    binding.player.showControllerPermanently()
                }
            }

            // listen for the stop button in the notification
            if (playbackState == PlaybackState.STATE_STOPPED && PlayerHelper.pipEnabled &&
                PictureInPictureCompat.isInPictureInPictureMode(requireActivity())
            ) {
                // keep the app alive when playback stops in PiP mode
            }

            // Buffering timeout after 10 Minutes
            if (playbackState == Player.STATE_BUFFERING) {
                if (bufferingTimeoutTask == null) {
                    bufferingTimeoutTask = Runnable {
                        playerController.pause()
                    }
                }

                handler.postDelayed(bufferingTimeoutTask!!, PlayerHelper.MAX_BUFFER_DELAY)
            } else {
                bufferingTimeoutTask?.let { handler.removeCallbacks(it) }
            }

            // Keep the UI in sync with the player's real state: show buffering indicator only
            // when the player is actually buffering and hide it when ready.
            try {
                if (_binding != null) {
                    if (playbackState == Player.STATE_BUFFERING) {
                        // Use a single loading indicator tied to the real player state.
                        playerBackgroundBinding.videoTransitionProgress.visibility = View.GONE
                        playerBackgroundBinding.exoBuffering.visibility = View.VISIBLE
                    } else {
                        playerBackgroundBinding.videoTransitionProgress.visibility = View.GONE
                        playerBackgroundBinding.exoBuffering.visibility = View.GONE
                    }
                }
            } catch (_: Exception) {
            }

            super.onPlaybackStateChanged(playbackState)
        }

        override fun onMediaMetadataChanged(mediaMetadata: MediaMetadata) {
            super.onMediaMetadataChanged(mediaMetadata)

            // Media-item metadata arrives immediately on seamless/preloaded transitions. Update the
            // fragment id here before rebuilding the carousels so the new video's UI never uses
            // the previous video's id while playlist metadata is still catching up.
            mediaMetadata.extras?.getString(IntentData.videoId)?.let { transitionedVideoId ->
                videoId = transitionedVideoId
                if (_binding != null) playerBackgroundBinding.autoplayCountdown.cancelAndHideCountdown()
                arguments?.run {
                    val currentData = parcelable<PlayerData>(IntentData.playerData)
                    if (currentData != null) {
                        putParcelable(IntentData.playerData, currentData.copy(videoId = videoId))
                    }
                }
            }

            // JSON-encode as work-around for https://github.com/androidx/media/issues/564
            val maybeStreams: Streams? = mediaMetadata.extras?.getString(IntentData.streams)?.let {
                JsonHelper.json.decodeFromString(it)
            }
            maybeStreams?.let { streams ->
                this@PlayerFragment.streams = streams
                viewModel.segments.postValue(emptyList())
                updatePlayerView()
            }
        }

        override fun onPlaylistMetadataChanged(mediaMetadata: MediaMetadata) {
            super.onPlaylistMetadataChanged(mediaMetadata)

            mediaMetadata.extras?.getString(IntentData.videoId)?.let {
                videoId = it
                if (_binding != null) playerBackgroundBinding.autoplayCountdown.cancelAndHideCountdown()

                // fix: if the fragment is recreated, play the current video, and not the initial one
                arguments?.run {
                    val playerData =
                        parcelable<PlayerData>(IntentData.playerData)!!.copy(videoId = videoId)
                    putParcelable(IntentData.playerData, playerData)
                }
            }

            // JSON-encode as work-around for https://github.com/androidx/media/issues/564
            val segments: List<Segment>? =
                mediaMetadata.extras?.getString(IntentData.segments)?.let {
                    JsonHelper.json.decodeFromString(it)
                }
            viewModel.segments.postValue(segments.orEmpty())
        }

        /**
         * Catch player errors to prevent the app from stopping
         */
        override fun onPlayerError(error: PlaybackException) {
            super.onPlayerError(error)
            try {
                playerController.play()
            } catch (e: Exception) {
                e.printStackTrace()
            }
        }

        override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
            super.onMediaItemTransition(mediaItem, reason)
            if (mediaItem == null) {
                toggleVideoInfoVisibility(false)
                disableController()
                binding.titleTextView.text = ""
            } else {
                // Force refresh of PlayerView when media item changes to fix black screen issue
                // Detach and reattach the player to force UI rendering of new content
                try {
                    binding.player.apply {
                        player = null
                        player = playerController
                    }
                } catch (e: Exception) {
                    // Silently handle any binding errors
                }
            }
        }
    }

    private val lockedOrientations = listOf(
        ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT,
        ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
    )

    private var screenshotBitmap: Bitmap? = null
    private val openScreenshotFile =
        registerForActivityResult(ActivityResultContracts.CreateDocument("image/png")) { uri ->
            if (uri == null) {
                screenshotBitmap = null
                return@registerForActivityResult
            }

            CoroutineScope(Dispatchers.IO).launch {
                context?.contentResolver?.openOutputStream(uri)?.use { outputStream ->
                    screenshotBitmap?.compress(Bitmap.CompressFormat.PNG, 100, outputStream)
                }
                screenshotBitmap = null

                withContext(Dispatchers.Main) {
                    Snackbar.make(
                        requireView(),
                        R.string.screenshot_saved,
                        2500
                    ).apply {
                        setAction(R.string.share) {
                            startActivity(Intent.createChooser(with(Intent()) {
                                setAction(Intent.ACTION_SEND)
                                setType("image/png")
                                putExtra(Intent.EXTRA_STREAM, uri)
                            }, null))
                        }
                        show()
                    }
                }
            }
        }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // broadcast receiver for PiP actions
        ContextCompat.registerReceiver(
            requireContext(),
            playerActionReceiver,
            IntentFilter(PlayerHelper.getIntentActionName(requireContext())),
            ContextCompat.RECEIVER_NOT_EXPORTED
        )
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        _binding = FragmentPlayerBinding.bind(view)
        super.onViewCreated(view, savedInstanceState)

        // manually apply additional padding for edge-to-edge compatibility
        activity?.getSystemInsets()?.let { systemBars ->
            with(binding.root) {
                setPadding(
                    paddingLeft,
                    paddingTop + systemBars.top,
                    paddingRight,
                    paddingBottom
                )
            }
        }


        val playerData = requireArguments().parcelable<PlayerData>(IntentData.playerData)!!
        videoId = playerData.videoId!!
        isOffline = playerData.isOffline
        playlistId = playerData.playlistId
        channelId = playerData.channelId

        // remember if playback already started once and only restart playback if that's the first run
        val createNewSession = !requireArguments().getBoolean(IntentData.alreadyStarted)
        requireArguments().putBoolean(IntentData.alreadyStarted, true)

        changeOrientationMode()

        playerLayoutOrientation = resources.configuration.orientation

        initializeTransitionLayout()
        initializeOnClickActions()

        if (PlayerHelper.autoFullscreenEnabled && resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) {
            setFullscreen()
        }

        chaptersViewModel.chaptersLiveData.observe(viewLifecycleOwner) {
            binding.player.setCurrentChapterName()
            playerControlsBinding.exoProgress.setChapters(it.orEmpty())
        }

        viewModel.segments.observe(viewLifecycleOwner) { segments ->
            binding.descriptionLayout.setSegments(segments)
            playerControlsBinding.exoProgress.setSegments(segments)
            playerControlsBinding.sbToggle.isVisible = segments.isNotEmpty()
            getHighlight(segments)?.let {
                lifecycleScope.launch(Dispatchers.IO) { initializeHighlight(it) }
            }
        }

        val localDownloadVersion = runBlocking(Dispatchers.IO) {
            DatabaseHolder.Database.downloadDao().findById(videoId)
        }

        val hasLocalFiles = localDownloadVersion?.downloadItems
            ?.any { item ->
                (item.type == FileType.VIDEO || item.type == FileType.AUDIO) &&
                        item.path.exists()
            } == true

        val networkAvailable = NetworkHelper.isNetworkAvailable(requireContext())
        // Respect an explicit download/offline launch even when Wi-Fi/mobile data is available.
        // If the user opened a normal video while disconnected, transparently use its local copy.
        val shouldPlayOffline = playerData.isOffline || (!networkAvailable && hasLocalFiles)

        isOffline = shouldPlayOffline
        val playbackPlayerData = playerData.copy(isOffline = shouldPlayOffline)

        attachToPlayerService(playbackPlayerData, createNewSession)

        val onBackPressedCallback = object : OnBackPressedCallback(true) {
            override fun handleOnBackPressed() {
                if (commonPlayerViewModel.isFullscreen.value == true) unsetFullscreen()
                else {
                    binding.playerMotionLayout.setTransitionDuration(250)
                    binding.playerMotionLayout.transitionToEnd()
                    baseActivity.minimizePlayerContainerLayout()
                    baseActivity.requestOrientationChange()
                }
            }

            override fun handleOnBackProgressed(backEvent: BackEventCompat) {
                binding.playerMotionLayout.progress = backEvent.progress
            }

            override fun handleOnBackCancelled() {
                binding.playerMotionLayout.transitionToStart()
            }
        }
        setOnBackPressed(onBackPressedCallback)

        commonPlayerViewModel.isMiniPlayerVisible.observe(viewLifecycleOwner) { isMiniPlayerVisible ->
            // re-add the callback on top of the back pressed dispatcher listeners stack,
            // so that it's the first one to become called while the full player is visible
            if (!isMiniPlayerVisible) {
                onBackPressedCallback.remove()
                setOnBackPressed(onBackPressedCallback)
            }

            // if the player is minimized, the fragment behind the player should handle the event
            onBackPressedCallback.isEnabled = isMiniPlayerVisible != true
        }

        connectToPlayerView()

        toggleVideoInfoVisibility(false)
    }

    private fun attachToPlayerService(playerData: PlayerData, startNewSession: Boolean) {
        val (serviceClass, args) = if (isOffline) {
            val isNoInternet = activity is NoInternetActivity

            OfflinePlayerService::class.java to bundleOf(
                IntentData.videoId to videoId,
                IntentData.playerData to playerData
                    .copy(downloadTab = playerData.downloadTab ?: DownloadTab.VIDEO),
                IntentData.noInternet to isNoInternet
            )
        } else {
            OnlinePlayerService::class.java to bundleOf(
                IntentData.playerData to playerData,
                IntentData.audioOnly to false
            )
        }

        BackgroundHelper.startMediaService(
            requireContext(),
            serviceClass,
            if (startNewSession) args else Bundle.EMPTY,
        ) {
            if (_binding == null) {
                playerController.sendCustomCommand(
                    AbstractPlayerService.stopServiceCommand,
                    Bundle.EMPTY
                )
                playerController.release()
                return@startMediaService
            }

            playerController = it
            playerController.repeatMode = if (PlayingQueue.repeatMode == Player.REPEAT_MODE_ONE) {
                Player.REPEAT_MODE_ONE
            } else {
                Player.REPEAT_MODE_OFF
            }
            playerController.addListener(playerListener)
            updatePlayPauseButton()

            if (!startNewSession) {
                // JSON-encode as work-around for https://github.com/androidx/media/issues/564
                val streams: Streams? =
                    playerController.mediaMetadata.extras?.getString(IntentData.streams)
                        ?.let { json ->
                            JsonHelper.json.decodeFromString(json)
                        }

                // reload the streams data and playback, metadata apparently no longer exists
                if (streams == null) {
                    playNextVideo(videoId)
                    return@startMediaService
                }

                this.streams = streams
                updatePlayerView()
            }
        }
    }

    @SuppressLint("ClickableViewAccessibility")
    private fun initializeTransitionLayout() {
        baseActivity.setPlayerContainerProgress(0f)

        var transitionStartId = 0
        var transitionEndId = 0

        binding.playerMotionLayout.addTransitionListener(object : TransitionAdapter() {
            override fun onTransitionChange(
                motionLayout: MotionLayout?,
                startId: Int,
                endId: Int,
                progress: Float
            ) {
                if (_binding == null) return

                baseActivity.setPlayerContainerProgress(progress.absoluteValue)
                disableController()
                commonPlayerViewModel.setSheetExpand(false)
                transitionEndId = endId
                transitionStartId = startId
            }

            override fun onTransitionCompleted(motionLayout: MotionLayout?, currentId: Int) {
                if (_binding == null) return

                if (currentId == transitionStartId) {
                    commonPlayerViewModel.isMiniPlayerVisible.value = false
                    // re-enable captions
                    binding.player.updateCurrentSubtitle(viewModel.currentCaptionId)
                    binding.player.useController = true
                    commonPlayerViewModel.setSheetExpand(true)
                    baseActivity.setPlayerContainerProgress(0f)
                    changeOrientationMode()

                    // clear search bar focus to avoid keyboard popups
                    baseActivity.clearSearchViewFocus()
                } else if (currentId == transitionEndId) {
                    commonPlayerViewModel.isMiniPlayerVisible.value = true
                    // disable captions temporarily
                    binding.player.updateCurrentSubtitle(null)
                    disableController()
                    commonPlayerViewModel.setSheetExpand(null)
                    playerBackgroundBinding.sbSkipBtn.isGone = true

                    baseActivity.setPlayerContainerProgress(1f)
                    baseActivity.requestOrientationChange()
                }

                updateMaxSheetHeight()
            }
        })

        binding.playerMotionLayout
            .addSwipeDownListener {
                if (commonPlayerViewModel.isMiniPlayerVisible.value == true) {
                    closeMiniPlayer()
                }
            }

        binding.playerMotionLayout.progress = 1F
        binding.playerMotionLayout.transitionToStart()

        val activity = requireActivity()
        if (PlayerHelper.pipEnabled) {
            PictureInPictureCompat.setPictureInPictureParams(activity, pipParams)
        }
    }

    private fun closeMiniPlayer() {
        binding
            .playerMotionLayout
            .animateDown(
                duration = 300L,
                dy = 500F,
                onEnd = { killPlayerFragment(stopPlayer = true) }
            )
    }

    // actions that don't depend on video information
    private fun initializeOnClickActions() {
        binding.closeImageView.setOnClickListener {
            killPlayerFragment(stopPlayer = true)
        }
        playerControlsBinding.closeImageButton.setOnClickListener {
            killPlayerFragment(stopPlayer = true)
        }

        binding.playImageView.setOnClickListener {
            if (::playerController.isInitialized) playerController.togglePlayPauseState()
        }

        activity?.supportFragmentManager
            ?.setFragmentResultListener(
                CommentsSheet.HANDLE_LINK_REQUEST_KEY,
                viewLifecycleOwner
            ) { _, bundle ->
                bundle.getString(IntentData.url)?.let { handleLink(it) }
            }

        binding.commentsToggle.setOnClickListener {
            if (!this::streams.isInitialized) return@setOnClickListener
            // set the max height to not cover the currently playing video
            updateMaxSheetHeight()
            commentsViewModel.videoIdLiveData.updateIfChanged(videoId)
            CommentsSheet()
                .apply { arguments = bundleOf(IntentData.channelAvatar to streams.uploaderAvatar) }
                .show(childFragmentManager)
        }

        // share button
        binding.relPlayerShare.setOnClickListener {
            if (!this::streams.isInitialized) return@setOnClickListener
            val bundle = bundleOf(
                IntentData.id to videoId,
                IntentData.shareObjectType to ShareObjectType.VIDEO,
                IntentData.shareData to ShareData(
                    currentVideo = streams.title,
                    currentPosition = playerController.currentPosition / 1000
                )
            )
            val newShareDialog = ShareDialog()
            newShareDialog.arguments = bundle
            newShareDialog.show(childFragmentManager, ShareDialog::class.java.name)
        }

        binding.relPlayerBackground.setOnClickListener {
            // start the background mode
            switchToAudioMode()
        }

        binding.relPlayerPip.isVisible =
            PictureInPictureCompat.isPictureInPictureAvailable(requireContext())

        binding.relPlayerPip.setOnClickListener {
            PictureInPictureCompat.enterPictureInPictureMode(requireActivity(), pipParams)
            isEnteringPiPMode = true
        }

        binding.relatedRecView.layoutManager = LinearLayoutManager(
            context,
            if (resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT) {
                LinearLayoutManager.HORIZONTAL
            } else {
                LinearLayoutManager.VERTICAL
            },
            false
        )

        binding.relatedRecView.addOnBottomReachedListener {
            if (isLoadingRelatedStreams) return@addOnBottomReachedListener
            val adapter = relatedStreamAdapter ?: return@addOnBottomReachedListener
            if (adapter.currentList.size >= relatedStreamItems.size) return@addOnBottomReachedListener
            loadMoreRelatedStreams()
        }

        binding.relPlayerSave.setOnClickListener {
            if (!::streams.isInitialized) return@setOnClickListener

            AddToPlaylistDialog().apply {
                arguments = bundleOf(IntentData.videoInfo to streams.toStreamItem(videoId))
            }.show(childFragmentManager, AddToPlaylistDialog::class.java.name)
        }

        playerControlsBinding.skipPrev.setOnClickListener {
            advanceToPreviousTrack()
        }

        playerControlsBinding.skipNext.setOnClickListener {
            advanceToNextTrack()
        }

        playerControlsBinding.root.findViewById<MediaRouteButton>(R.id.exo_cast_button)?.apply {
            try {
                CastButtonFactory.setUpMediaRouteButton(requireContext(), this)
            } catch (e: Exception) {
            }
            isVisible = true
        }

        binding.relPlayerDownload.setOnClickListener {
            if (!this::streams.isInitialized) return@setOnClickListener

            DownloadHelper.startDownloadDialog(requireContext(), childFragmentManager, videoId)
        }

        binding.relPlayerScreenshot.setOnClickListener {
            if (!this::streams.isInitialized) return@setOnClickListener
            val surfaceView =
                binding.player.videoSurfaceView as? SurfaceView ?: return@setOnClickListener

            val bmp = Bitmap.createBitmap(
                surfaceView.width,
                surfaceView.height,
                Bitmap.Config.ARGB_8888
            )

            PixelCopy.request(surfaceView, bmp, { _ ->
                screenshotBitmap = bmp
                val currentPosition =
                    playerController.currentPosition.toFloat() / 1000
                openScreenshotFile.launch("${streams.title}-${currentPosition}.png")
            }, handler)
        }

        binding.playerChannel.setOnClickListener {
            if (!this::streams.isInitialized) return@setOnClickListener

            NavigationHelper.navigateChannel(requireContext(), streams.uploaderUrl)
        }

        binding.titleTextView.setOnClickListener {
            val isCurrentlyVisible = binding.descriptionLayout.isVisible
            toggleVideoInfoVisibility(!isCurrentlyVisible)
        }

        binding.descriptionLayout.handleLink = this::handleLink
    }

    private fun updateMaxSheetHeight() {
        val systemBars = baseActivity.getSystemInsets() ?: return
        val maxHeight = binding.root.height - (binding.player.height + systemBars.top)
        commonPlayerViewModel.maxSheetHeightPx = maxHeight
        chaptersViewModel.maxSheetHeightPx = maxHeight
    }

    fun switchToAudioMode() {
        playerController.sendCustomCommand(
            AbstractPlayerService.runPlayerActionCommand,
            bundleOf(PlayerCommand.TOGGLE_AUDIO_ONLY_MODE.name to true)
        )
        // disable autoplay countdown while the audio player is running
        // otherwise playback of the next video wouldn't start automatically because
        // it awaits the start of the autoplay countdown
        playerController.sendCustomCommand(
            AbstractPlayerService.runPlayerActionCommand, bundleOf(
                PlayerCommand.SET_AUTOPLAY_COUNTDOWN_ENABLED.name to false
            )
        )

        binding.player.player = null
        playerController.release()
        killPlayerFragment()

        NavigationHelper.openAudioPlayerFragment(requireContext(), offlinePlayer = isOffline)
    }

    private fun updateFullscreenOrientation() {
        if (PlayerHelper.autoFullscreenEnabled || !this::streams.isInitialized) return

        baseActivity.requestedOrientation = PlayerHelper.getFullscreenOrientation(streams.isShort)
    }

    private fun setFullscreen() {
        // set status bar icon color to white
        windowInsetsControllerCompat.isAppearanceLightStatusBars = false

        commonPlayerViewModel.isFullscreen.value = true
        updateFullscreenOrientation()

        commonPlayerViewModel.setSheetExpand(null)

        openOrCloseFullscreenDialog(true)

        binding.player.updateMarginsByFullscreenMode()
    }

    @SuppressLint("SourceLockedOrientationActivity")
    fun unsetFullscreen() {
        if (activity == null || _binding == null) return

        commonPlayerViewModel.isFullscreen.value = false

        if (!PlayerHelper.autoFullscreenEnabled) {
            baseActivity.requestedOrientation = baseActivity.screenOrientationPref
        }

        openOrCloseFullscreenDialog(false)

        binding.player.updateMarginsByFullscreenMode()

        // set status bar icon color back to theme color after fullscreen dialog closed!
        windowInsetsControllerCompat.isAppearanceLightStatusBars =
            !ThemeHelper.isDarkMode(requireContext())
    }

    /**
     * Enter/exit fullscreen or toggle it depending on the current state
     */
    override fun toggleFullscreen() {
        binding.player.hideController()

        val isFullscreen = commonPlayerViewModel.isFullscreen.value == true
        if (!isFullscreen) {
            // go to fullscreen mode
            setFullscreen()
        } else {
            // exit fullscreen mode
            unsetFullscreen()
        }
    }

    private fun openOrCloseFullscreenDialog(open: Boolean) {
        val playerView = binding.player
        (playerView.parent as ViewGroup).removeView(playerView)

        if (open) {
            fullscreenDialog.addContentView(
                binding.player,
                LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
            )
            fullscreenDialog.show()
            playerView.currentWindow = fullscreenDialog.window
        } else {
            binding.playerMotionLayout.addView(playerView)
            playerView.currentWindow = null
            fullscreenDialog.dismiss()
        }

        WindowHelper.toggleFullscreen(fullscreenDialog.window!!, open)
    }

    override fun onPause() {
        // unregister cast listener early to avoid leaks
        try {
            castSessionListener?.let {
                castContext?.sessionManager?.removeSessionManagerListener(it, CastSession::class.java)
            }
        } catch (e: Exception) {
        }

        // check whether the screen is on
        val isInteractive = requireContext().getSystemService<PowerManager>()!!.isInteractive

        // disable video stream since it's not needed when screen off or when PiP is not
        // enabled, except when the user is intentionally entering PiP mode via the dedicated button
        if ((!isInteractive || !PlayerHelper.pipEnabled) && !isEnteringPiPMode) {
            // disable the autoplay countdown while the screen is off or when PiP is not enabled
            setAutoPlayCountdownEnabled(false)

            // disable loading the video track while screen is off or when PiP is not enabled
            setVideoTrackTypeDisabled(true)
        }

        val shouldPausePlayer =
            (isInteractive && PlayerHelper.pauseOnQuit) ||
                    (!isInteractive && PlayerHelper.pausePlayerOnScreenOffEnabled)

        // pause player if screen off or app is put the background, except when
        // the user is intentionally entering PiP mode via the dedicated button
        if (shouldPausePlayer && !isEnteringPiPMode) {
            playerController.pause()
        }

        isEnteringPiPMode = false

        super.onPause()
    }

    override fun onResume() {
        super.onResume()

        if (closedVideo) {
            closedVideo = false
        }

        // re-enable the autoplay countdown
        setAutoPlayCountdownEnabled(PlayerHelper.autoPlayCountdown)

        // re-enable and load video stream
        setVideoTrackTypeDisabled(false)

        // register cast listener (late, after resume)
        try {
            castContext = CastContext.getSharedInstance(requireContext())
            if (castSessionListener == null) {
                castSessionListener = object : SessionManagerListener<CastSession> {
                    override fun onSessionStarting(session: CastSession) {}
                    override fun onSessionStarted(session: CastSession, sessionId: String) {
                        onCastSessionStarted(session)
                    }
                    override fun onSessionStartFailed(session: CastSession, error: Int) {}
                    override fun onSessionEnding(session: CastSession) {}
                    override fun onSessionEnded(session: CastSession, error: Int) {}
                    override fun onSessionResuming(session: CastSession, sessionId: String) {
                        onCastSessionStarted(session)
                    }
                    override fun onSessionResumed(session: CastSession, wasSuspended: Boolean) {
                        onCastSessionStarted(session)
                    }
                    override fun onSessionResumeFailed(session: CastSession, error: Int) {}
                    override fun onSessionSuspended(session: CastSession, reason: Int) {}
                }
            }
            castSessionListener?.let {
                castContext?.sessionManager?.addSessionManagerListener(it, CastSession::class.java)
            }
        } catch (e: Exception) {
        }
    }

    private fun setAutoPlayCountdownEnabled(enabled: Boolean) {
        if (!::playerController.isInitialized) return

        this.autoPlayCountdownEnabled = enabled

        playerController.sendCustomCommand(
            AbstractPlayerService.runPlayerActionCommand, bundleOf(
                PlayerCommand.SET_AUTOPLAY_COUNTDOWN_ENABLED.name to enabled
            )
        )
    }

    private fun setVideoTrackTypeDisabled(disabled: Boolean) {
        if (!::playerController.isInitialized) return

        playerController.sendCustomCommand(
            AbstractPlayerService.runPlayerActionCommand, bundleOf(
                PlayerCommand.SET_VIDEO_TRACK_TYPE_DISABLED.name to disabled
            )
        )
    }

    private fun onCastSessionStarted(session: CastSession) {
        try {
            val remoteMediaClient = session.remoteMediaClient ?: return
            if (!::streams.isInitialized) return

            val streamUrl = if (streams.videoStreams.isNotEmpty()) {
                PlayerHelper.createDashSource(streams, requireContext()).toString()
            } else {
                streams.hls ?: return
            }

            val metadata = CastMediaMetadata(CastMediaMetadata.MEDIA_TYPE_MOVIE)
            metadata.putString(CastMediaMetadata.KEY_TITLE, streams.title)

            val mediaInfo = MediaInfo.Builder(streamUrl)
                .setContentType("application/dash+xml")
                .setStreamType(MediaInfo.STREAM_TYPE_BUFFERED)
                .setMetadata(metadata)
                .build()

            // load and play on the cast device
            remoteMediaClient.load(mediaInfo)
        } catch (e: Exception) {
            // ignore cast errors
        }
    }

    override fun onDestroy() {
        super.onDestroy()

        handler.removeCallbacksAndMessages(null)

        if (::playerController.isInitialized && playerController.isConnected) {
            playerController.removeListener(playerListener)

            if (!playerController.isPlaying) {
                playerController.sendCustomCommand(
                    AbstractPlayerService.stopServiceCommand,
                    Bundle.EMPTY
                )
            }
            // onDestroyView() may already have cleared the binding when this fragment is being
            // replaced (for example, switching from online playback to a downloaded item).
            // Never dereference binding with !! during teardown.
            _binding?.player?.player = null
            playerController.release()
        }

        if (PlayerHelper.pipEnabled) {
            // disable the auto PiP mode for SDK >= 32
            PictureInPictureCompat
                .setPictureInPictureParams(requireActivity(), pipParams)
        }

        runCatching {
            if (fullscreenDialog.isShowing) fullscreenDialog.dismiss()
        }

        runCatching {
            // unregister the receiver for player actions
            context?.unregisterReceiver(playerActionReceiver)
        }

        // restore the orientation that's used by the main activity
        baseActivity.requestOrientationChange()

        _binding = null
    }

    /**
     * Manually kill the player fragment - call instead of using onDestroy directly
     */
    private fun killPlayerFragment(stopPlayer: Boolean = false) {
        try {
            if (stopPlayer && ::playerController.isInitialized && playerController.isConnected) {
                try {
                    playerController.sendCustomCommand(
                        AbstractPlayerService.stopServiceCommand,
                        Bundle.EMPTY
                    )
                } catch (e: Exception) {
                    Log.w(TAG(), "Error stopping player service: ${e.message}")
                }
            }

            try {
                binding.playerMotionLayout.transitionToEnd()
            } catch (e: Exception) {
                Log.w(TAG(), "Error transitioning motion layout: ${e.message}")
            }

            commonPlayerViewModel.isMiniPlayerVisible.value = false

            if (commonPlayerViewModel.isFullscreen.value == true) {
                binding.playerMotionLayout.addTransitionListener(object : TransitionAdapter() {
                    override fun onTransitionCompleted(motionLayout: MotionLayout?, currentId: Int) {
                        super.onTransitionCompleted(motionLayout, currentId)

                        try {
                            baseActivity.supportFragmentManager.commit {
                                remove(this@PlayerFragment)
                            }
                        } catch (e: Exception) {
                            Log.w(TAG(), "Error removing fragment from fullscreen: ${e.message}")
                        }
                    }
                })

                try {
                    unsetFullscreen()
                } catch (e: Exception) {
                    Log.w(TAG(), "Error unsetting fullscreen: ${e.message}")
                }
            } else {
                try {
                    baseActivity.supportFragmentManager.commit {
                        remove(this@PlayerFragment)
                    }
                } catch (e: Exception) {
                    Log.w(TAG(), "Error removing fragment: ${e.message}")
                }
            }
        } catch (e: Exception) {
            Log.e(TAG(), "Fatal error in killPlayerFragment: ${e.message}", e)
        }
    }

    private fun checkForSegments() {
        if (!playerController.isPlaying || !PlayerHelper.sponsorBlockEnabled) return

        handler.postDelayed(this::checkForSegments, 100)
        if (viewModel.segments.value.isNullOrEmpty()) return

        val segmentData = playerController.getCurrentSegment(
            viewModel.segments.value.orEmpty(),
            viewModel.sponsorBlockConfig
        )

        if (segmentData != null && commonPlayerViewModel.isMiniPlayerVisible.value != true) {
            val (segment, sbSkipOption) = segmentData

            val autoSkipTemporarilyDisabled =
                !binding.player.sponsorBlockAutoSkip && sbSkipOption != SbSkipOptions.OFF

            if (sbSkipOption in arrayOf(
                    SbSkipOptions.AUTOMATIC_ONCE,
                    SbSkipOptions.MANUAL
                ) || autoSkipTemporarilyDisabled
            ) {
                playerBackgroundBinding.sbSkipBtn.isVisible = true
                playerBackgroundBinding.sbSkipBtn.setOnClickListener {
                    playerController.seekTo((segment.segmentStartAndEnd.second * 1000f).toLong())
                    segment.skipped = true
                }
            }
        } else {
            playerBackgroundBinding.sbSkipBtn.isGone = true
        }
    }

    private fun setPlayerDefaults() {
        // reset the player view
        playerControlsBinding.exoProgress.clearSegments()
        playerControlsBinding.sbToggle.isGone = true

        // reset the comments to become reloaded later
        commentsViewModel.reset()

        // hide the button to skip SponsorBlock segments manually
        playerBackgroundBinding.sbSkipBtn.isGone = true

        // use the video's default audio track when starting playback
        playerController.sendCustomCommand(
            AbstractPlayerService.runPlayerActionCommand, bundleOf(
                PlayerCommand.SET_AUDIO_ROLE_FLAGS.name to C.ROLE_FLAG_MAIN
            )
        )

        setAutoPlayCountdownEnabled(PlayerHelper.autoPlayCountdown)

        // set the default subtitle if available
        binding.player.updateCurrentSubtitle(viewModel.currentCaptionId)

        if (streams.category == Streams.CATEGORY_MUSIC) {
            playerController.setPlaybackSpeed(1f)
        }
    }

    /**
     * Manually skip to another video.
     *
     * You many only call this if the video is of the same type as the type of the currently running
     * video, i.e. either both are online or both are offline.
     */
    fun playNextVideo(nextId: String, streamsJson: String? = null) {
        // Prefer using the MediaController extension which can include prefetched streams
        playerController?.navigateVideo(nextId, streamsJson)
    }

    private fun isExcludedStreamId(id: String?): Boolean {
        if (id.isNullOrBlank()) return false
        return id == videoId || id == previousVideoId || endedVideoIds.contains(id)
    }

    private fun getFirstRelatedVideoId(): String? {
        // The service preloads PlayingQueue.getNext(). Prefer that exact item so UI autoplay,
        // notification controls and the prepared ExoPlayer playlist never disagree. This is also
        // essential for preserving playlist order.
        PlayingQueue.getNext()?.takeIf { !isExcludedStreamId(it) }?.let { return it }

        val similarList = similarStreamAdapter?.currentList?.takeIf { it.isNotEmpty() } ?: similarStreamItems
        val fromSimilar = similarList.firstOrNull {
            it.url?.toID().isNullOrBlank().not() && !isExcludedStreamId(it.url?.toID())
        }?.url?.toID()
        if (!fromSimilar.isNullOrBlank()) return fromSimilar

        val relatedList = relatedStreamAdapter?.currentList?.takeIf { it.isNotEmpty() } ?: relatedStreamItems
        return relatedList.firstOrNull {
            it.url?.toID().isNullOrBlank().not() && !isExcludedStreamId(it.url?.toID())
        }?.url?.toID()
    }

    private fun advanceToNextTrack() {
        val nextId = PlayingQueue.getNext() ?: getFirstRelatedVideoId() ?: return
        previousVideoId = videoId

        relatedStreamItems = relatedStreamItems.filter { it.url?.toID() != nextId }
        relatedStreamAdapter?.submitList(relatedStreamItems)
        similarStreamItems = similarStreamItems.filter { it.url?.toID() != nextId }
        similarStreamAdapter?.submitList(similarStreamItems)

        if (::playerController.isInitialized && PlayingQueue.getNext() == nextId) {
            // One command only. The service jumps to the already-preloaded MediaSource when it
            // exists; otherwise it rebuilds exactly this same queue item.
            playerController.seekToNext()
        } else {
            playNextVideo(nextId)
        }
    }

    private fun advanceToPreviousTrack() {
        if (::playerController.isInitialized && PlayingQueue.getPrev() != null) {
            playerController.seekToPrevious()
            playerController.play()
        }
    }

    private fun dismissCommentsSheet() {
        // close comment bottom sheet if opened for next video
        childFragmentManager.fragments
            .filterIsInstance<CommentsSheet>()
            .firstOrNull()
            ?.dismiss()
    }

    private fun toggleVideoInfoVisibility(show: Boolean) {
        binding.descriptionLayout.collapseDescription()
        binding.descriptionLayout.isInvisible = !show
        binding.relatedRecView.isInvisible = !show
        binding.playerChannel.isInvisible = !show
        // Loading indicators are controlled exclusively by the actual player buffering state.
        // Toggling/minimizing the video information must never leave a spinner over the artwork.
    }

    private fun connectToPlayerView() {
        // initialize the player view actions
        binding.player.initialize(
            chaptersViewModel,
            commonPlayerViewModel,
            viewModel,
            viewLifecycleOwner,
            this
        )
    }

    @SuppressLint("SetTextI18n")
    private fun updatePlayerView() {
        dismissCommentsSheet()

        setPlayerDefaults()

        binding.player.apply {
            useController = false
            player = null
            player = playerController
        }

        if (binding.playerMotionLayout.progress != 1.0f) {
            // show controllers when not in picture in picture mode
            val inPipMode = PlayerHelper.pipEnabled &&
                    PictureInPictureCompat.isInPictureInPictureMode(requireActivity())
            if (!inPipMode) {
                binding.player.useController = true
            }
        }

        viewModel.isOrientationChangeInProgress = false

        binding.descriptionLayout.setStreams(streams)

        toggleVideoInfoVisibility(true)

        binding.apply {
            ImageHelper.loadImage(streams.uploaderAvatar, binding.playerChannelImage, true)
            binding.playerChannelImage.isVisible = streams.uploaderAvatar != null

            playerChannelName.text = streams.uploader
            titleTextView.text = streams.title

            playerChannelSubCount.text = context?.getString(
                R.string.subscribers,
                streams.uploaderSubscriberCount.formatShort()
            )
            playerChannelSubCount.isVisible = streams.uploaderSubscriberCount >= 0

            relPlayerDownload.isVisible = !streams.isLive && !isOffline
        }
        playerControlsBinding.exoTitle.text = streams.title

        // init the chapters recyclerview
        chaptersViewModel.chaptersLiveData.postValue(streams.chapters)

        resetRecommendationRows()
        relatedLoadJob?.cancel()
        val relatedVideoId = videoId
        relatedLoadJob = lifecycleScope.launch {
            showRelatedStreams(relatedVideoId)
        }

        // update the subscribed state
        if (streams.uploaderUrl != null) {
            binding.playerSubscribe.setupSubscriptionButton(
                streams.uploaderUrl!!.toID(),
                streams.uploader,
                streams.uploaderAvatar,
                streams.uploaderVerified
            )
        } else {
            binding.playerSubscribe.isGone = true
        }

        // seekbar preview setup
        playerControlsBinding.seekbarPreview.isGone = true
        seekBarPreviewListener?.let { playerControlsBinding.exoProgress.removeSeekBarListener(it) }

        lifecycleScope.launch {
            val timeFrameReceiver = getTimeFrameReceiver() ?: return@launch
            val listener = SeekbarPreviewListener(
                timeFrameReceiver,
                playerControlsBinding,
                streams.duration * 1000
            )

            seekBarPreviewListener = listener
            playerControlsBinding.exoProgress.addSeekBarListener(listener)
        }

        if (binding.playerMotionLayout.progress == 0f && PlayerHelper.autoFullscreenShortsEnabled && streams.isShort) {
            setFullscreen()
        }

        // it's possible that the highlight segment was loaded before the streams info finished loading
        // in this case, we have to initialize the video highlight chapter once again
        getHighlight(viewModel.segments.value.orEmpty())?.let {
            lifecycleScope.launch(Dispatchers.IO) { initializeHighlight(it) }
        }
    }

    private suspend fun showRelatedStreams(targetVideoId: String) {
        if (!PlayerHelper.relatedStreamsEnabled || videoId != targetVideoId) return

        // determine previous video id from playing queue (may be null)
        val prevId = PlayingQueue.getPrev()
        previousVideoId = prevId

        // If we're playing from a playlist, load the playlist streams from repository/Room
        if (playlistId != null) {
            val playlistStreams = withContext(Dispatchers.IO) {
                try {
                    PlaylistsHelper.getPlaylist(playlistId!!).relatedStreams
                } catch (e: Exception) {
                    emptyList<com.github.libretube.api.obj.StreamItem>()
                }
            }

            if (videoId != targetVideoId) return

            val playerData = requireArguments().parcelable<PlayerData>(IntentData.playerData)
            val displayStreams = playlistStreams
                .filter(::shouldDisplayRelatedStream)
                .filter { it.url?.toID().isNullOrBlank().not() && !isExcludedStreamId(it.url?.toID()) }

            val queueStreams = if (playerData?.shuffle == true) playlistStreams.shuffled() else playlistStreams
            relatedStreamItems = if (playerData?.shuffle == true) displayStreams.shuffled() else displayStreams
            relatedStreamPageIndex = 0

            val relatedLayoutManager = binding.relatedRecView.layoutManager as LinearLayoutManager
            val adapter = VideoCardsAdapter(
                columnWidthDp = if (relatedLayoutManager.orientation == LinearLayoutManager.HORIZONTAL) 250f else null,
                onVideoClick = { stream -> handleRelatedStreamClick(stream) }
            )
            relatedStreamAdapter = adapter
            binding.relatedRecView.adapter = adapter
            adapter.submitList(relatedStreamItems)

            // Populate PlayingQueue explicitly with playlist streams so Next/Prev are playlist-aware
            lifecycleScope.launch(Dispatchers.IO) {
                try {
                    val currentStream = streams?.toStreamItem(videoId)
                    if (queueStreams.isNotEmpty()) {
                        PlayingQueue.setStreams(queueStreams)
                        currentStream?.let { PlayingQueue.updateCurrent(it) }
                    }
                } catch (_: Exception) {}
            }

            // Playlist playback still gets the discovery rows underneath the ordered playlist row.
            loadAdditionalCarousels(targetVideoId)
            return
        }

        val initialRelatedStreams = if (isOffline) {
            withContext(Dispatchers.IO) {
                DatabaseHolder.Database.downloadDao().getAll()
                    .filter { it.download.videoId != videoId }
                    .map { it.download.toStreamItem() }
            }
        } else {
            try {
                streams.relatedStreams.filter { !it.title.isNullOrBlank() }
            } catch (e: Exception) {
                Log.w(TAG(), "Error fetching relatedStreams from API: ${e.message}")
                emptyList()
            }
        }

        val filteredRelatedStreams = initialRelatedStreams
            .filter(::shouldDisplayRelatedStream)
            .filter { it.url?.toID() != videoId }
            .filter { it.url?.toID() != previousVideoId }

        var relatedStreams = filteredRelatedStreams

        if (relatedStreams.isEmpty()) {
            relatedStreams = getRelatedStreamsFromQueue()
        }

        if (relatedStreams.isEmpty() && !isOffline) {
            relatedStreams = loadFallbackFromWatchHistory()
        }

        // Final fallback: if still empty, use search-based fallback
        if (relatedStreams.isEmpty() && !isOffline) {
            relatedStreams = loadFallbackRelatedStreams()
        }

        if (videoId != targetVideoId) return

        relatedStreamItems = relatedStreams
        relatedStreamPageIndex = 0

        val relatedLayoutManager = binding.relatedRecView.layoutManager as LinearLayoutManager
        val adapter = VideoCardsAdapter(
            columnWidthDp = if (relatedLayoutManager.orientation == LinearLayoutManager.HORIZONTAL) 250f else null,
            onVideoClick = this::handleRelatedStreamClick
        )
        binding.relatedRecView.adapter = adapter
        relatedStreamAdapter = adapter

        loadMoreRelatedStreams()
        
        loadAdditionalCarousels(targetVideoId)
    }

    private fun loadAdditionalCarousels(targetVideoId: String) {
        recommendationLoadJob?.cancel()
        recommendationLoadJob = lifecycleScope.launch {
            // Similar depends only on the current response. Load it first so the other rows can
            // exclude duplicates, then fetch the independent cached rows in parallel.
            if (videoId != targetVideoId) return@launch
            loadSimilarStreams(targetVideoId)
            coroutineScope {
                launch { loadTrendingStreams(targetVideoId) }
                launch { loadChannelLatestStreams(targetVideoId) }
                launch { loadRecommendationsStreams(targetVideoId) }
            }
        }
    }

    private fun resetRecommendationRows() {
        recommendationLoadJob?.cancel()
        recommendationLoadJob = null
        relatedStreamAdapter?.submitList(emptyList())
        similarStreamItems = emptyList()
        trendingStreamItems = emptyList()
        channelLatestItems = emptyList()
        recommendationsItems = emptyList()

        listOf(
            binding.similarRecView,
            binding.trendingRecView,
            binding.channelLatestRecView,
            binding.recommendationsRecView
        ).filterNotNull().forEach { recycler ->
            recycler.adapter = null
            (recycler.parent as? View)?.isGone = true
        }
    }

    private fun buildSimilarKeywords(): Set<String> {
        val keywords = mutableSetOf<String>()
        streams.tags.forEach { if (it.isNotBlank()) keywords.add(it.trim().lowercase()) }
        if (!streams.category.isNullOrBlank() && streams.category != Streams.CATEGORY_MUSIC) {
            keywords.add(streams.category.trim().lowercase())
        }
        return keywords
    }

    private fun isSimilarGenreItem(item: StreamItem, keywords: Set<String>): Boolean {
        if (keywords.isEmpty()) return false
        val title = item.title?.lowercase().orEmpty()
        val desc = item.shortDescription?.lowercase().orEmpty()
        val uploader = item.uploaderName?.lowercase().orEmpty()
        return keywords.any { kw -> title.contains(kw) || desc.contains(kw) || uploader.contains(kw) }
    }

    private suspend fun loadSimilarStreams(targetVideoId: String) {
        if (videoId != targetVideoId) return
        try {
            val keywords = buildSimilarKeywords()

            val related = streams.relatedStreams
                .filter(::shouldDisplayRelatedStream)
                .filter { it.url?.toID().isNullOrBlank().not() && !isExcludedStreamId(it.url?.toID()) }

            val matched = if (keywords.isNotEmpty()) {
                related.filter { isSimilarGenreItem(it, keywords) }
            } else emptyList()

            // Strictly keep only matched items for the 'similar' carousel to preserve genre
            val similarStreams = matched.distinctBy { it.url }.take(15)

            if (videoId != targetVideoId) return
            similarStreamItems = similarStreams
            setupCarousel(R.id.similar_rec_view, similarStreams) {
                similarStreamAdapter = it
            }
        } catch (e: Exception) {
            Log.w(TAG(), "Error loading similar streams: ${e.message}")
        }
    }

    private suspend fun loadTrendingStreams(targetVideoId: String) {
        if (videoId != targetVideoId) return
        try {
            val excludedSimilarIds = similarStreamItems.mapNotNull { it.url?.toID() }.toSet()
            val fallback = streams.relatedStreams
                .filter(::shouldDisplayRelatedStream)
                .filter { !isExcludedStreamId(it.url?.toID()) }
                .filter { it.url?.toID() !in excludedSimilarIds }
                .distinctBy { it.url?.toID() }
                .take(15)

            val freshTrending = if (!isOffline) {
                val appContext = context?.applicationContext
                if (appContext == null) {
                    emptyList()
                } else {
                    withContext(Dispatchers.IO) {
                        runCatching {
                            val region = PreferenceHelper.getTrendingRegion(appContext)
                            val category = when {
                                streams.category == Streams.CATEGORY_MUSIC -> TrendingCategory.MUSIC
                                streams.category.orEmpty().contains("podcast", ignoreCase = true) -> TrendingCategory.PODCASTS
                                else -> TrendingCategory.TRAILERS
                            }
                            MediaServiceRepository.instance.getTrending(region, category)
                        }.getOrDefault(emptyList())
                    }
                }
            } else {
                emptyList()
            }

            val trendingStreams = freshTrending
                .filter(::shouldDisplayRelatedStream)
                .filter { !isExcludedStreamId(it.url?.toID()) }
                .filter { it.url?.toID() !in excludedSimilarIds }
                .distinctBy { it.url?.toID() }
                .take(15)
                .ifEmpty { fallback }

            if (videoId != targetVideoId) return
            trendingStreamItems = trendingStreams
            setupCarousel(R.id.trending_rec_view, trendingStreams) {
                trendingStreamAdapter = it
            }
        } catch (e: Exception) {
            Log.w(TAG(), "Error loading trending streams: ${e.message}")
        }
    }

    private suspend fun loadChannelLatestStreams(targetVideoId: String) {
        if (videoId != targetVideoId) return
        try {
            val channelId = streams.uploaderUrl?.toID()
            val fromChannel = if (!isOffline && !channelId.isNullOrBlank()) {
                withContext(Dispatchers.IO) {
                    runCatching {
                        MediaServiceRepository.instance.getChannel(channelId).relatedStreams
                    }.getOrDefault(emptyList())
                }
            } else {
                emptyList()
            }

            val channelStreams = fromChannel
                .filter(::shouldDisplayRelatedStream)
                .filter { !isExcludedStreamId(it.url?.toID()) }
                .distinctBy { it.url?.toID() }
                .take(15)
                .ifEmpty {
                    streams.relatedStreams
                        .filter(::shouldDisplayRelatedStream)
                        .filter { !isExcludedStreamId(it.url?.toID()) }
                        .filter { it.uploaderUrl == streams.uploaderUrl }
                        .distinctBy { it.url?.toID() }
                        .take(15)
                }

            if (videoId != targetVideoId) return
            channelLatestItems = channelStreams
            setupCarousel(R.id.channel_latest_rec_view, channelStreams) {
                channelLatestAdapter = it
            }
        } catch (e: Exception) {
            Log.w(TAG(), "Error loading channel latest streams: ${e.message}")
        }
    }

    private suspend fun loadRecommendationsStreams(targetVideoId: String) {
        if (videoId != targetVideoId) return
        try {
            val keywords = buildSimilarKeywords()
            val excludedSimilarIds = similarStreamItems.mapNotNull { it.url?.toID() }.toSet()

            // Use a mix of related and watch history as recommendations,
            // but avoid the similar-genre items used in Similares.
            val recommendedStreams = mutableListOf<StreamItem>()
            recommendedStreams.addAll(
                streams.relatedStreams
                    .filter(::shouldDisplayRelatedStream)
                    .filter { it.url?.toID().isNullOrBlank().not() && !isExcludedStreamId(it.url?.toID()) }
                    .filter { it.url?.toID() !in excludedSimilarIds }
                    .filter { !isSimilarGenreItem(it, keywords) }
                    .take(8)
            )

            // Add some from watch history as recommendations
            if (recommendedStreams.size < 15) {
                val historyStreams = withContext(Dispatchers.IO) {
                    try {
                        DatabaseHolder.Database.watchHistoryDao().getAll()
                            .map { it.toStreamItem() }
                            .filter(::shouldDisplayRelatedStream)
                            .filter { it.url?.toID() != targetVideoId }
                            .filter { it.url?.toID() !in excludedSimilarIds }
                            .filter { !isSimilarGenreItem(it, keywords) }
                            .distinctBy { it.url?.toID() }
                            .take(15 - recommendedStreams.size)
                    } catch (e: Exception) {
                        emptyList()
                    }
                }
                recommendedStreams.addAll(historyStreams)
            }

            if (videoId != targetVideoId) return
            val distinctRecommendations = recommendedStreams.distinctBy { it.url?.toID() }
            recommendationsItems = distinctRecommendations
            setupCarousel(R.id.recommendations_rec_view, distinctRecommendations) {
                recommendationsAdapter = it
            }
        } catch (e: Exception) {
            Log.w(TAG(), "Error loading recommendations: ${e.message}")
        }
    }

    private fun loadMoreRelatedStreams() {
        if (isLoadingRelatedStreams || relatedStreamAdapter == null) return
        if (relatedStreamPageIndex * relatedStreamPageSize >= relatedStreamItems.size) return

        isLoadingRelatedStreams = true
        val startIndex = relatedStreamPageIndex * relatedStreamPageSize
        val endIndex = minOf(startIndex + relatedStreamPageSize, relatedStreamItems.size)
        val nextBatch = relatedStreamItems.subList(startIndex, endIndex)

        val adapter = relatedStreamAdapter ?: return
        val currentList = adapter.currentList.toMutableList()
        currentList.addAll(nextBatch)
        adapter.submitList(currentList)

        relatedStreamPageIndex++
        isLoadingRelatedStreams = false
    }

    private fun setupCarousel(
        recyclerViewId: Int,
        items: List<StreamItem>,
        onAdapterCreated: (VideoCardsAdapter) -> Unit
    ) {
        val recyclerView = when (recyclerViewId) {
            R.id.similar_rec_view -> binding.similarRecView
            R.id.trending_rec_view -> binding.trendingRecView
            R.id.channel_latest_rec_view -> binding.channelLatestRecView
            R.id.recommendations_rec_view -> binding.recommendationsRecView
            else -> return
        }

        recyclerView?.let {
            // Do not leave an empty section title behind when a backend has no data for that row.
            (it.parent as? View)?.isVisible = items.isNotEmpty()
            if (items.isEmpty()) {
                onAdapterCreated(VideoCardsAdapter(onVideoClick = this::handleRelatedStreamClick))
                it.adapter = null
                return@let
            }

            if (it.layoutManager == null) {
                it.layoutManager = LinearLayoutManager(
                    context,
                    LinearLayoutManager.HORIZONTAL,
                    false
                )
            }

            val layoutManager = it.layoutManager as? LinearLayoutManager
            val adapter = VideoCardsAdapter(
                columnWidthDp = if (layoutManager?.orientation == LinearLayoutManager.HORIZONTAL) 250f else null,
                onVideoClick = this::handleRelatedStreamClick
            )
            it.adapter = adapter
            adapter.submitList(items)
            onAdapterCreated(adapter)

            // Prefetch thumbnails in the background so the carousel displays artwork reliably.
            lifecycleScope.launch {
                val ctx = it.context
                withContext(Dispatchers.IO) {
                    for (item in items.take(15)) {
                        try {
                            ImageHelper.getImage(ctx, item.thumbnail)
                        } catch (_: Exception) {
                            // ignore individual image failures
                        }
                    }
                }
            }
        }
    }

    private fun shouldDisplayRelatedStream(streamItem: StreamItem): Boolean {
        // Keep the standard video carousel broad like YouTube: long videos, podcasts, albums and
        // mixes are valid recommendations. Live items stay out of automatic next-video selection
        // because switching into an endless livestream would break normal continuous playback.
        return !streamItem.url.isNullOrBlank() &&
            !streamItem.title.isNullOrBlank() &&
            !streamItem.isShort &&
            !streamItem.isLive
    }

    private fun getRelatedStreamsFromQueue(): List<StreamItem> {
        val prevId = previousVideoId ?: PlayingQueue.getPrev()
        return PlayingQueue.getStreams()
            .filter { it.url?.toID().isNullOrBlank().not() }
            .filter { !isExcludedStreamId(it.url?.toID()) }
            .filter { it.url?.toID() != prevId }
            .distinctBy { it.url?.toID() }
            .filter(::shouldDisplayRelatedStream)
    }

    private suspend fun loadFallbackFromWatchHistory(): List<StreamItem> {
        return withContext(Dispatchers.IO) {
            try {
                // Load last 10 watch history items as fallback
                val watchHistory = DatabaseHolder.Database.watchHistoryDao().getN(10, 0)
                watchHistory
                    .map { it.toStreamItem() }
                    .filter(::shouldDisplayRelatedStream)
                    .filter { item -> item.url?.toID().isNullOrBlank().not() && !isExcludedStreamId(item.url?.toID()) }
                    .distinctBy { item -> item.url?.toID() }
            } catch (e: Exception) {
                Log.w("PlayerFragment", "Error loading watch history fallback: ${e.message}")
                emptyList()
            }
        }
    }

    private suspend fun loadFallbackRelatedStreams(): List<StreamItem> {
        return withContext(Dispatchers.IO) {
            val searchQueries = listOfNotNull(
                buildRelatedSearchQuery(),
                buildFallbackSearchQuery()
            ).distinct()

            for (query in searchQueries) {
                val results = runCatching {
                    MediaServiceRepository.instance.getSearchResults(query, "videos")
                }.getOrNull()

                val candidates = results?.items.orEmpty()
                    .filter {
                        (it.type == StreamItem.TYPE_STREAM || it.type == "video") &&
                            !it.title.isNullOrBlank()
                    }
                    .map { it.toStreamItem() }
                    .filter(::shouldDisplayRelatedStream)
                    .filter { it.url?.toID() != videoId }
                    .filter { it.url?.toID() != previousVideoId }

                if (candidates.isNotEmpty()) {
                    return@withContext candidates.distinctBy { it.url?.toID() }
                }
            }

            emptyList()
        }
    }

    private fun buildRelatedSearchQuery(): String {
        val text = streams.title.takeIf(String::isNotBlank) ?: streams.uploader
        val tokens = text.orEmpty()
            .replace(Regex("[^\\p{L}\\d\\s]"), " ")
            .split(Regex("\\s+"))
            .filter { it.length > 2 }
            .take(4)

        return tokens.joinToString(" ").ifBlank { streams.uploader ?: "popular videos" }
    }

    private fun buildFallbackSearchQuery(): String {
        val base = streams.uploader?.takeIf(String::isNotBlank) ?: streams.category.takeIf(String::isNotBlank)
        return "$base videos".trim().ifBlank { "popular videos" }
    }

    private fun handleRelatedStreamClick(streamItem: StreamItem) {
        val videoId = streamItem.url.orEmpty().toID()
        if (videoId.isBlank()) return

        if (NetworkHelper.isNetworkAvailable(requireContext())) {
            NavigationHelper.navigateVideo(
                requireContext(),
                PlayerData(videoId = videoId, playlistId = playlistId, keepQueue = playlistId != null, isOffline = false)
            )
            return
        }

        lifecycleScope.launch(Dispatchers.IO) {
            val hasLocal = DatabaseHolder.Database.downloadDao().findById(videoId)
                ?.downloadItems
                ?.any { item ->
                    (item.type == FileType.VIDEO || item.type == FileType.AUDIO) &&
                            item.path.exists()
                } == true

            withContext(Dispatchers.Main) {
                if (!hasLocal) {
                    Toast.makeText(requireContext(), "Este contenido requiere conexión", Toast.LENGTH_SHORT).show()
                    return@withContext
                }

                NavigationHelper.navigateVideo(
                    requireContext(),
                    PlayerData(videoId = videoId, playlistId = playlistId, keepQueue = playlistId != null, isOffline = true)
                )
            }
        }
    }

    private fun showAutoPlayCountdown() {
        if (getFirstRelatedVideoId() == null) return

        disableController()
        playerBackgroundBinding.autoplayCountdown.setHideSelfListener {
            // could fail if the video already got closed before
            runCatching {
                playerBackgroundBinding.autoplayCountdown.isGone = true
                binding.player.useController = true
            }
        }
        playerBackgroundBinding.autoplayCountdown.startCountdown {
            advanceToNextTrack()
        }
    }

    /**
     * Handle a link clicked in the description
     */
    private fun handleLink(link: String) {
        // get video id if the link is a valid youtube video link
        val uri = link.toUri()
        val videoId = TextUtils.getVideoIdFromUri(uri)

        if (videoId.isNullOrEmpty()) {
            // not a YouTube video link, thus handle normally
            val intent = Intent(Intent.ACTION_VIEW, uri)

            // start PiP mode if enabled
            onUserLeaveHint()
            startActivity(intent)

            return
        }

        // check if the video is the current video and has a valid time
        if (videoId == this.videoId) {
            // try finding the time stamp of the url and seek to it if found
            uri.getQueryParameter("t")?.toTimeInSeconds()?.let {
                playerController.seekTo(it * 1000)
            }
        } else {
            // YouTube video link without time or not the current video, thus load in player
            playNextVideo(videoId)
        }
    }

    private fun updatePlayPauseButton() {
        val playPauseAction = PlayerHelper.getPlayPauseActionIcon(playerController)
        binding.playImageView.setImageResource(playPauseAction)
    }

    private suspend fun getTimeFrameReceiver(): TimeFrameReceiver? = withContext(Dispatchers.IO) {
        return@withContext if (isOffline) {
            val downloadItems =
                DatabaseHolder.Database.downloadDao().getDownloadById(videoId)?.downloadItems
            downloadItems?.firstOrNull { it.path.exists() && it.type == FileType.VIDEO }?.path?.let {
                OfflineTimeFrameReceiver(requireContext(), it)
            }
        } else {
            if (!::streams.isInitialized) return@withContext null

            OnlineTimeFrameReceiver(requireContext(), streams.previewFrames)
        }
    }

    private fun getHighlight(segments: List<Segment>): Segment? {
        return segments.firstOrNull { it.category == PlayerHelper.SPONSOR_HIGHLIGHT_CATEGORY }
    }

    private suspend fun initializeHighlight(highlight: Segment) {
        val frameReceiver = getTimeFrameReceiver() ?: return

        val highlightStart = highlight.segmentStartAndEnd.first.toLong()
        val frame = withContext(Dispatchers.IO) {
            frameReceiver.getFrameAtTime(highlightStart * 1000)
        }
        val highlightChapter = ChapterSegment(
            title = getString(R.string.chapters_videoHighlight),
            start = highlightStart,
            highlightDrawable = frame?.toDrawable(requireContext().resources)
        )
        chaptersViewModel.chaptersLiveData.postValue(
            chaptersViewModel.chapters.plus(highlightChapter).sortedBy { it.start }
        )
    }

    /**
     * Use the sensor mode if auto fullscreen is enabled
     */
    @SuppressLint("SourceLockedOrientationActivity")
    private fun changeOrientationMode() {
        if (PlayerHelper.autoFullscreenEnabled) {
            // enable auto rotation
            baseActivity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR
        } else {
            // go to portrait mode
            baseActivity.requestedOrientation =
                (requireActivity() as BaseActivity).screenOrientationPref
        }
    }

    /**
     * Public helper to hide the video transition/loading indicator from external callers.
     * Useful when restoring the player size to ensure no orphaned loader remains visible.
     */
    fun hideVideoLoadingIndicator() {
        if (_binding == null) return
        try {
            playerBackgroundBinding.videoTransitionProgress.visibility = View.GONE
            // also ensure ExoPlayer buffering indicator is hidden
            playerBackgroundBinding.exoBuffering.visibility = View.GONE
        } catch (_: Exception) {
        }
    }


    override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) {
        super.onPictureInPictureModeChanged(isInPictureInPictureMode)
        if (isInPictureInPictureMode) {
            // hide and disable exoPlayer controls
            disableController()

            binding.player.updateCurrentSubtitle(null)

            openOrCloseFullscreenDialog(true)
            pipActivity = activity
        } else {
            binding.player.useController = true

            // close button got clicked in PiP mode
            // pause the video and keep the app alive
            if (lifecycle.currentState == Lifecycle.State.CREATED) {
                playerController.pause()
                closedVideo = true
            }

            binding.player.updateCurrentSubtitle(viewModel.currentCaptionId)

            // unset fullscreen if it's not been enabled before the start of PiP
            if (commonPlayerViewModel.isFullscreen.value != true) {
                openOrCloseFullscreenDialog(false)
            }
        }
    }

    fun onUserLeaveHint() {
        if (shouldStartPiP()) {
            PictureInPictureCompat.enterPictureInPictureMode(requireActivity(), pipParams)
        } else if (PlayerHelper.pauseOnQuit) {
            playerController.pause()
        }
    }

    private val pipParams: PictureInPictureParamsCompat
        get() = run {
            val isPlaying = ::playerController.isInitialized && playerController.isPlaying

            PictureInPictureParamsCompat.Builder()
                .setActions(
                    PlayerHelper.getPiPModeActions(
                        requireActivity(),
                        isPlaying
                    )
                )
                .setAutoEnterEnabled(PlayerHelper.pipEnabled && isPlaying)
                .apply {
                    if (isPlaying) {
                        setAspectRatio(playerController.videoSize)
                    }
                }
                .build()
        }

    /**
     * Detect whether PiP is supported and enabled
     */
    private fun shouldUsePip(): Boolean {
        return PictureInPictureCompat.isPictureInPictureAvailable(requireContext()) && PlayerHelper.pipEnabled
    }

    private fun shouldStartPiP(): Boolean {
        return shouldUsePip() && ::playerController.isInitialized && playerController.isPlaying
    }

    /**
     * Check if the activity needs to be recreated due to an orientation change
     * If true, the activity will be automatically restarted
     */
    private fun restartActivityIfNeeded() {
        if (baseActivity.screenOrientationPref in lockedOrientations || viewModel.isOrientationChangeInProgress) return

        val orientation = resources.configuration.orientation
        if (commonPlayerViewModel.isFullscreen.value != true && orientation != playerLayoutOrientation) {
            // remember the current position before recreating the activity
            playerLayoutOrientation = orientation

            viewModel.isOrientationChangeInProgress = true

            // detach player view from player to stop surface rendering
            binding.player.player = null

            if (::playerController.isInitialized) playerController.release()

            activity?.recreate()
        }
    }

    override fun onConfigurationChanged(newConfig: Configuration) {
        super.onConfigurationChanged(newConfig)

        if (_binding == null ||
            // If in PiP mode, orientation is given as landscape.
            PictureInPictureCompat.isInPictureInPictureMode(requireActivity())
        ) {
            return
        }

        if (PlayerHelper.autoFullscreenEnabled) {
            when (newConfig.orientation) {
                // go to fullscreen mode
                Configuration.ORIENTATION_LANDSCAPE -> setFullscreen()
                // exit fullscreen if not landscape
                else -> unsetFullscreen()
            }
        }

        restartActivityIfNeeded()
    }

    private fun disableController() {
        binding.player.useController = false
        binding.player.hideController()
    }

    fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean {
        return _binding?.player?.onKeyUp(keyCode, event) ?: false
    }

    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
    }

    override fun getVideoId(): String {
        return videoId
    }

    override fun isVideoShort(): Boolean {
        return ::streams.isInitialized && streams.isShort
    }

    override fun isVideoLive(): Boolean {
        return ::streams.isInitialized && streams.isLive
    }
}
