NewPipe-app-android/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt

2215 lines
105 KiB
Kotlin

package org.schabi.newpipe.fragments.detail
import android.animation.ValueAnimator
import android.animation.ValueAnimator.AnimatorUpdateListener
import android.annotation.SuppressLint
import android.app.Activity
import android.content.BroadcastReceiver
import android.content.Context
import android.content.DialogInterface
import android.content.Intent
import android.content.IntentFilter
import android.content.SharedPreferences
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
import android.content.pm.ActivityInfo
import android.database.ContentObserver
import android.graphics.Color
import android.graphics.Rect
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.provider.Settings
import android.text.TextUtils
import android.util.DisplayMetrics
import android.util.Log
import android.util.TypedValue
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.View.OnLongClickListener
import android.view.View.OnTouchListener
import android.view.ViewGroup
import android.view.ViewTreeObserver
import android.view.WindowManager
import android.view.animation.DecelerateInterpolator
import android.widget.FrameLayout
import android.widget.RelativeLayout
import android.widget.Toast
import androidx.annotation.AttrRes
import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.content.res.AppCompatResources
import androidx.appcompat.widget.Toolbar
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.FragmentManager
import androidx.preference.PreferenceManager
import com.google.android.exoplayer2.PlaybackException
import com.google.android.exoplayer2.PlaybackParameters
import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.appbar.AppBarLayout.OnOffsetChangedListener
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback
import com.google.android.material.tabs.TabLayout
import icepick.State
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.functions.Action
import io.reactivex.rxjava3.schedulers.Schedulers
import org.schabi.newpipe.App
import org.schabi.newpipe.BaseFragment
import org.schabi.newpipe.R
import org.schabi.newpipe.database.stream.model.StreamEntity
import org.schabi.newpipe.database.stream.model.StreamStateEntity
import org.schabi.newpipe.databinding.FragmentVideoDetailBinding
import org.schabi.newpipe.download.DownloadDialog
import org.schabi.newpipe.error.ErrorInfo
import org.schabi.newpipe.error.ErrorUtil.Companion.showSnackbar
import org.schabi.newpipe.error.ErrorUtil.Companion.showUiErrorSnackbar
import org.schabi.newpipe.error.ReCaptchaActivity
import org.schabi.newpipe.error.UserAction
import org.schabi.newpipe.extractor.Image
import org.schabi.newpipe.extractor.NewPipe
import org.schabi.newpipe.extractor.StreamingService.ServiceInfo.MediaCapability
import org.schabi.newpipe.extractor.comments.CommentsInfoItem
import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException
import org.schabi.newpipe.extractor.exceptions.ExtractionException
import org.schabi.newpipe.extractor.stream.AudioStream
import org.schabi.newpipe.extractor.stream.Stream
import org.schabi.newpipe.extractor.stream.StreamExtractor
import org.schabi.newpipe.extractor.stream.StreamInfo
import org.schabi.newpipe.extractor.stream.StreamType
import org.schabi.newpipe.extractor.stream.VideoStream
import org.schabi.newpipe.fragments.BackPressable
import org.schabi.newpipe.fragments.BaseStateFragment
import org.schabi.newpipe.fragments.EmptyFragment
import org.schabi.newpipe.fragments.MainFragment
import org.schabi.newpipe.fragments.list.comments.CommentsFragment
import org.schabi.newpipe.fragments.list.videos.RelatedItemsFragment
import org.schabi.newpipe.ktx.AnimationType
import org.schabi.newpipe.ktx.animate
import org.schabi.newpipe.ktx.animateRotation
import org.schabi.newpipe.local.dialog.PlaylistDialog
import org.schabi.newpipe.local.feed.FeedFragment.Companion.newInstance
import org.schabi.newpipe.local.history.HistoryRecordManager
import org.schabi.newpipe.local.playlist.LocalPlaylistFragment
import org.schabi.newpipe.player.Player
import org.schabi.newpipe.player.PlayerService
import org.schabi.newpipe.player.PlayerType
import org.schabi.newpipe.player.event.OnKeyDownListener
import org.schabi.newpipe.player.event.PlayerServiceExtendedEventListener
import org.schabi.newpipe.player.helper.PlayerHelper
import org.schabi.newpipe.player.helper.PlayerHolder
import org.schabi.newpipe.player.playqueue.PlayQueue
import org.schabi.newpipe.player.playqueue.PlayQueueItem
import org.schabi.newpipe.player.playqueue.SinglePlayQueue
import org.schabi.newpipe.player.playqueue.events.PlayQueueEvent
import org.schabi.newpipe.player.ui.MainPlayerUi
import org.schabi.newpipe.player.ui.VideoPlayerUi
import org.schabi.newpipe.util.DependentPreferenceHelper
import org.schabi.newpipe.util.DeviceUtils
import org.schabi.newpipe.util.ExtractorHelper
import org.schabi.newpipe.util.InfoCache
import org.schabi.newpipe.util.ListHelper
import org.schabi.newpipe.util.Localization
import org.schabi.newpipe.util.NavigationHelper
import org.schabi.newpipe.util.PermissionHelper
import org.schabi.newpipe.util.PlayButtonHelper
import org.schabi.newpipe.util.StreamTypeUtil
import org.schabi.newpipe.util.ThemeHelper
import org.schabi.newpipe.util.external_communication.KoreUtils
import org.schabi.newpipe.util.external_communication.ShareUtils
import org.schabi.newpipe.util.image.PicassoHelper
import java.util.LinkedList
import java.util.Objects
import java.util.Optional
import java.util.concurrent.TimeUnit
import java.util.function.Function
import java.util.function.IntFunction
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
class VideoDetailFragment() : BaseStateFragment<StreamInfo>(), BackPressable, PlayerServiceExtendedEventListener, OnKeyDownListener {
// tabs
private var showComments: Boolean = false
private var showRelatedItems: Boolean = false
private var showDescription: Boolean = false
private var selectedTabTag: String? = null
@AttrRes
val tabIcons: MutableList<Int> = ArrayList()
@StringRes
val tabContentDescriptions: MutableList<Int> = ArrayList()
private var tabSettingsChanged: Boolean = false
private var lastAppBarVerticalOffset: Int = Int.MAX_VALUE // prevents useless updates
private val preferenceChangeListener: OnSharedPreferenceChangeListener = OnSharedPreferenceChangeListener({ sharedPreferences: SharedPreferences, key: String? ->
if ((getString(R.string.show_comments_key) == key)) {
showComments = sharedPreferences.getBoolean(key, true)
tabSettingsChanged = true
} else if ((getString(R.string.show_next_video_key) == key)) {
showRelatedItems = sharedPreferences.getBoolean(key, true)
tabSettingsChanged = true
} else if ((getString(R.string.show_description_key) == key)) {
showDescription = sharedPreferences.getBoolean(key, true)
tabSettingsChanged = true
}
})
@State
protected var serviceId: Int = NO_SERVICE_ID
@State
protected var title: String = ""
@State
protected var url: String? = null
protected var playQueue: PlayQueue? = null
@State
var bottomSheetState: Int = BottomSheetBehavior.STATE_EXPANDED
@State
var lastStableBottomSheetState: Int = BottomSheetBehavior.STATE_EXPANDED
@State
protected var autoPlayEnabled: Boolean = true
private var currentInfo: StreamInfo? = null
private var currentWorker: Disposable? = null
private val disposables: CompositeDisposable = CompositeDisposable()
private var positionSubscriber: Disposable? = null
private var bottomSheetBehavior: BottomSheetBehavior<FrameLayout>? = null
private var bottomSheetCallback: BottomSheetCallback? = null
private var broadcastReceiver: BroadcastReceiver? = null
/*//////////////////////////////////////////////////////////////////////////
// Views
////////////////////////////////////////////////////////////////////////// */
private var binding: FragmentVideoDetailBinding? = null
private var pageAdapter: TabAdapter? = null
private var settingsContentObserver: ContentObserver? = null
private var playerService: PlayerService? = null
private var player: Player? = null
private val playerHolder: PlayerHolder? = PlayerHolder.Companion.getInstance()
/*//////////////////////////////////////////////////////////////////////////
// Service management
////////////////////////////////////////////////////////////////////////// */
public override fun onServiceConnected(connectedPlayer: Player?,
connectedPlayerService: PlayerService?,
playAfterConnect: Boolean) {
player = connectedPlayer
playerService = connectedPlayerService
// It will do nothing if the player is not in fullscreen mode
hideSystemUiIfNeeded()
val playerUi: Optional<MainPlayerUi?>? = player!!.UIs().(get<MainPlayerUi>(MainPlayerUi::class.java))!!
if (!player!!.videoPlayerSelected() && !playAfterConnect) {
return
}
if (DeviceUtils.isLandscape(requireContext())) {
// If the video is playing but orientation changed
// let's make the video in fullscreen again
checkLandscape()
} else if ((playerUi!!.map(Function({ ui: MainPlayerUi? -> ui!!.isFullscreen() && !ui.isVerticalVideo() })).orElse(false) // Tablet UI has orientation-independent fullscreen
&& !DeviceUtils.isTablet((activity)!!))) {
// Device is in portrait orientation after rotation but UI is in fullscreen.
// Return back to non-fullscreen state
playerUi.ifPresent(java.util.function.Consumer({ obj: MainPlayerUi? -> obj!!.toggleFullscreen() }))
}
if ((playAfterConnect
|| (((currentInfo != null
) && isAutoplayEnabled()
&& playerUi!!.isEmpty())))) {
autoPlayEnabled = true // forcefully start playing
openVideoPlayerAutoFullscreen()
}
updateOverlayPlayQueueButtonVisibility()
}
public override fun onServiceDisconnected() {
playerService = null
player = null
restoreDefaultBrightness()
}
/*//////////////////////////////////////////////////////////////////////////
// Fragment's Lifecycle
////////////////////////////////////////////////////////////////////////// */
public override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val prefs: SharedPreferences = PreferenceManager.getDefaultSharedPreferences((activity)!!)
showComments = prefs.getBoolean(getString(R.string.show_comments_key), true)
showRelatedItems = prefs.getBoolean(getString(R.string.show_next_video_key), true)
showDescription = prefs.getBoolean(getString(R.string.show_description_key), true)
selectedTabTag = prefs.getString(
getString(R.string.stream_info_selected_tab_key), COMMENTS_TAB_TAG)
prefs.registerOnSharedPreferenceChangeListener(preferenceChangeListener)
setupBroadcastReceiver()
settingsContentObserver = object : ContentObserver(Handler()) {
public override fun onChange(selfChange: Boolean) {
if (activity != null && !PlayerHelper.globalScreenOrientationLocked(activity)) {
activity!!.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED)
}
}
}
activity!!.getContentResolver().registerContentObserver(
Settings.System.getUriFor(Settings.System.ACCELEROMETER_ROTATION), false,
settingsContentObserver)
}
public override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?): View? {
binding = FragmentVideoDetailBinding.inflate(inflater, container, false)
return binding!!.getRoot()
}
public override fun onPause() {
super.onPause()
if (currentWorker != null) {
currentWorker!!.dispose()
}
restoreDefaultBrightness()
PreferenceManager.getDefaultSharedPreferences(requireContext())
.edit()
.putString(getString(R.string.stream_info_selected_tab_key),
pageAdapter!!.getItemTitle(binding!!.viewPager.getCurrentItem()))
.apply()
}
public override fun onResume() {
super.onResume()
if (BaseFragment.Companion.DEBUG) {
Log.d(TAG, "onResume() called")
}
activity!!.sendBroadcast(Intent(ACTION_VIDEO_FRAGMENT_RESUMED))
updateOverlayPlayQueueButtonVisibility()
setupBrightness()
if (tabSettingsChanged) {
tabSettingsChanged = false
initTabs()
if (currentInfo != null) {
updateTabs(currentInfo!!)
}
}
// Check if it was loading when the fragment was stopped/paused
if (wasLoading.getAndSet(false) && !wasCleared()) {
startLoading(false)
}
}
public override fun onStop() {
super.onStop()
if (!activity!!.isChangingConfigurations()) {
activity!!.sendBroadcast(Intent(ACTION_VIDEO_FRAGMENT_STOPPED))
}
}
public override fun onDestroy() {
super.onDestroy()
// Stop the service when user leaves the app with double back press
// if video player is selected. Otherwise unbind
if (activity!!.isFinishing() && isPlayerAvailable() && player!!.videoPlayerSelected()) {
playerHolder!!.stopService()
} else {
playerHolder!!.setListener(null)
}
PreferenceManager.getDefaultSharedPreferences((activity)!!)
.unregisterOnSharedPreferenceChangeListener(preferenceChangeListener)
activity!!.unregisterReceiver(broadcastReceiver)
activity!!.getContentResolver().unregisterContentObserver((settingsContentObserver)!!)
if (positionSubscriber != null) {
positionSubscriber!!.dispose()
}
if (currentWorker != null) {
currentWorker!!.dispose()
}
disposables.clear()
positionSubscriber = null
currentWorker = null
bottomSheetBehavior!!.removeBottomSheetCallback((bottomSheetCallback)!!)
if (activity!!.isFinishing()) {
playQueue = null
currentInfo = null
stack = LinkedList()
}
}
public override fun onDestroyView() {
super.onDestroyView()
binding = null
}
public override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
when (requestCode) {
ReCaptchaActivity.Companion.RECAPTCHA_REQUEST -> if (resultCode == Activity.RESULT_OK) {
NavigationHelper.openVideoDetailFragment(requireContext(), getFM(),
serviceId, url, title, null, false)
} else {
Log.e(TAG, "ReCaptcha failed")
}
else -> Log.e(TAG, "Request code from activity not supported [" + requestCode + "]")
}
}
/*//////////////////////////////////////////////////////////////////////////
// OnClick
////////////////////////////////////////////////////////////////////////// */
private fun setOnClickListeners() {
binding!!.detailTitleRootLayout.setOnClickListener(View.OnClickListener({ v: View? -> toggleTitleAndSecondaryControls() }))
binding!!.detailUploaderRootLayout.setOnClickListener(makeOnClickListener(java.util.function.Consumer<StreamInfo>({ info: StreamInfo ->
if (TextUtils.isEmpty(info.getSubChannelUrl())) {
if (!TextUtils.isEmpty(info.getUploaderUrl())) {
openChannel(info.getUploaderUrl(), info.getUploaderName())
}
if (BaseFragment.Companion.DEBUG) {
Log.i(TAG, "Can't open sub-channel because we got no channel URL")
}
} else {
openChannel(info.getSubChannelUrl(), info.getSubChannelName())
}
})))
binding!!.detailThumbnailRootLayout.setOnClickListener(View.OnClickListener({ v: View? ->
autoPlayEnabled = true // forcefully start playing
// FIXME Workaround #7427
if (isPlayerAvailable()) {
player!!.setRecovery()
}
openVideoPlayerAutoFullscreen()
}))
binding!!.detailControlsBackground.setOnClickListener(View.OnClickListener({ v: View? -> openBackgroundPlayer(false) }))
binding!!.detailControlsPopup.setOnClickListener(View.OnClickListener({ v: View? -> openPopupPlayer(false) }))
binding!!.detailControlsPlaylistAppend.setOnClickListener(makeOnClickListener(java.util.function.Consumer<StreamInfo>({ info: StreamInfo? ->
if (getFM() != null && currentInfo != null) {
val fragment: Fragment? = getParentFragmentManager().findFragmentById(R.id.fragment_holder)
// commit previous pending changes to database
if (fragment is LocalPlaylistFragment) {
fragment.saveImmediate()
} else if (fragment is MainFragment) {
fragment.commitPlaylistTabs()
}
disposables.add(PlaylistDialog.Companion.createCorrespondingDialog(requireContext(),
java.util.List.of<StreamEntity?>(StreamEntity((info)!!)),
java.util.function.Consumer<PlaylistDialog>({ dialog: PlaylistDialog -> dialog.show(getParentFragmentManager(), TAG) })))
}
})))
binding!!.detailControlsDownload.setOnClickListener(View.OnClickListener({ v: View? ->
if (PermissionHelper.checkStoragePermissions(activity,
PermissionHelper.DOWNLOAD_DIALOG_REQUEST_CODE)) {
openDownloadDialog()
}
}))
binding!!.detailControlsShare.setOnClickListener(makeOnClickListener(java.util.function.Consumer({ info: StreamInfo ->
ShareUtils.shareText(requireContext(), info.getName(), info.getUrl(),
info.getThumbnails())
})))
binding!!.detailControlsOpenInBrowser.setOnClickListener(makeOnClickListener(java.util.function.Consumer({ info: StreamInfo -> ShareUtils.openUrlInBrowser(requireContext(), info.getUrl()) })))
binding!!.detailControlsPlayWithKodi.setOnClickListener(makeOnClickListener(java.util.function.Consumer({ info: StreamInfo -> KoreUtils.playWithKore(requireContext(), Uri.parse(info.getUrl())) })))
if (BaseFragment.Companion.DEBUG) {
binding!!.detailControlsCrashThePlayer.setOnClickListener(View.OnClickListener({ v: View? -> VideoDetailPlayerCrasher.onCrashThePlayer(requireContext(), player) }))
}
val overlayListener: View.OnClickListener = View.OnClickListener({ v: View? ->
bottomSheetBehavior
.setState(BottomSheetBehavior.STATE_EXPANDED)
})
binding!!.overlayThumbnail.setOnClickListener(overlayListener)
binding!!.overlayMetadataLayout.setOnClickListener(overlayListener)
binding!!.overlayButtonsLayout.setOnClickListener(overlayListener)
binding!!.overlayCloseButton.setOnClickListener(View.OnClickListener({ v: View? ->
bottomSheetBehavior
.setState(BottomSheetBehavior.STATE_HIDDEN)
}))
binding!!.overlayPlayQueueButton.setOnClickListener(View.OnClickListener({ v: View? -> NavigationHelper.openPlayQueue(requireContext()) }))
binding!!.overlayPlayPauseButton.setOnClickListener(View.OnClickListener({ v: View? ->
if (playerIsNotStopped()) {
player!!.playPause()
player!!.UIs().get((VideoPlayerUi::class.java)).ifPresent(java.util.function.Consumer({ ui: VideoPlayerUi? -> ui!!.hideControls(0, 0) }))
showSystemUi()
} else {
autoPlayEnabled = true // forcefully start playing
openVideoPlayer(false)
}
setOverlayPlayPauseImage(isPlayerAvailable() && player!!.isPlaying())
}))
}
private fun makeOnClickListener(consumer: java.util.function.Consumer<StreamInfo>): View.OnClickListener {
return View.OnClickListener({ v: View? ->
if (!isLoading.get() && currentInfo != null) {
consumer.accept(currentInfo!!)
}
})
}
private fun setOnLongClickListeners() {
binding!!.detailTitleRootLayout.setOnLongClickListener(makeOnLongClickListener(java.util.function.Consumer({ info: StreamInfo? ->
ShareUtils.copyToClipboard(requireContext(),
binding!!.detailVideoTitleView.getText().toString())
})))
binding!!.detailUploaderRootLayout.setOnLongClickListener(makeOnLongClickListener(java.util.function.Consumer({ info: StreamInfo ->
if (TextUtils.isEmpty(info.getSubChannelUrl())) {
Log.w(TAG, "Can't open parent channel because we got no parent channel URL")
} else {
openChannel(info.getUploaderUrl(), info.getUploaderName())
}
})))
binding!!.detailControlsBackground.setOnLongClickListener(makeOnLongClickListener(java.util.function.Consumer({ info: StreamInfo? -> openBackgroundPlayer(true) })
))
binding!!.detailControlsPopup.setOnLongClickListener(makeOnLongClickListener(java.util.function.Consumer({ info: StreamInfo? -> openPopupPlayer(true) })
))
binding!!.detailControlsDownload.setOnLongClickListener(makeOnLongClickListener(java.util.function.Consumer({ info: StreamInfo? -> NavigationHelper.openDownloads((activity)!!) })))
val overlayListener: OnLongClickListener = makeOnLongClickListener(java.util.function.Consumer({ info: StreamInfo -> openChannel(info.getUploaderUrl(), info.getUploaderName()) }))
binding!!.overlayThumbnail.setOnLongClickListener(overlayListener)
binding!!.overlayMetadataLayout.setOnLongClickListener(overlayListener)
}
private fun makeOnLongClickListener(consumer: java.util.function.Consumer<StreamInfo>): OnLongClickListener {
return OnLongClickListener({ v: View? ->
if (isLoading.get() || currentInfo == null) {
return@OnLongClickListener false
}
consumer.accept(currentInfo!!)
true
})
}
private fun openChannel(subChannelUrl: String, subChannelName: String) {
try {
NavigationHelper.openChannelFragment(getFM(), currentInfo!!.getServiceId(),
subChannelUrl, subChannelName)
} catch (e: Exception) {
showUiErrorSnackbar(this, "Opening channel fragment", e)
}
}
private fun toggleTitleAndSecondaryControls() {
if (binding!!.detailSecondaryControlPanel.getVisibility() == View.GONE) {
binding!!.detailVideoTitleView.setMaxLines(10)
binding!!.detailToggleSecondaryControlsView.animateRotation(VideoPlayerUi.Companion.DEFAULT_CONTROLS_DURATION, 180)
binding!!.detailSecondaryControlPanel.setVisibility(View.VISIBLE)
} else {
binding!!.detailVideoTitleView.setMaxLines(1)
binding!!.detailToggleSecondaryControlsView.animateRotation(VideoPlayerUi.Companion.DEFAULT_CONTROLS_DURATION, 0)
binding!!.detailSecondaryControlPanel.setVisibility(View.GONE)
}
// view pager height has changed, update the tab layout
updateTabLayoutVisibility()
}
/*//////////////////////////////////////////////////////////////////////////
// Init
////////////////////////////////////////////////////////////////////////// */
// called from onViewCreated in {@link BaseFragment#onViewCreated}
override fun initViews(rootView: View, savedInstanceState: Bundle?) {
super.initViews(rootView, savedInstanceState)
pageAdapter = TabAdapter(getChildFragmentManager())
binding!!.viewPager.setAdapter(pageAdapter)
binding!!.tabLayout.setupWithViewPager(binding!!.viewPager)
binding!!.detailThumbnailRootLayout.requestFocus()
binding!!.detailControlsPlayWithKodi.setVisibility(
if (KoreUtils.shouldShowPlayWithKodi(requireContext(), serviceId)) View.VISIBLE else View.GONE
)
binding!!.detailControlsCrashThePlayer.setVisibility(
if (BaseFragment.Companion.DEBUG && PreferenceManager.getDefaultSharedPreferences((getContext())!!)
.getBoolean(getString(R.string.show_crash_the_player_key), false)) View.VISIBLE else View.GONE
)
accommodateForTvAndDesktopMode()
}
@SuppressLint("ClickableViewAccessibility")
override fun initListeners() {
super.initListeners()
setOnClickListeners()
setOnLongClickListeners()
val controlsTouchListener: OnTouchListener = OnTouchListener({ view: View?, motionEvent: MotionEvent ->
if ((motionEvent.getAction() == MotionEvent.ACTION_DOWN
&& PlayButtonHelper.shouldShowHoldToAppendTip((activity)!!))) {
binding!!.touchAppendDetail.animate(true, 250, AnimationType.ALPHA, 0, Runnable({ binding!!.touchAppendDetail.animate(false, 1500, AnimationType.ALPHA, 1000) }))
}
false
})
binding!!.detailControlsBackground.setOnTouchListener(controlsTouchListener)
binding!!.detailControlsPopup.setOnTouchListener(controlsTouchListener)
binding!!.appBarLayout.addOnOffsetChangedListener(OnOffsetChangedListener({ layout: AppBarLayout?, verticalOffset: Int ->
// prevent useless updates to tab layout visibility if nothing changed
if (verticalOffset != lastAppBarVerticalOffset) {
lastAppBarVerticalOffset = verticalOffset
// the view was scrolled
updateTabLayoutVisibility()
}
}))
setupBottomPlayer()
if (!playerHolder!!.isBound()) {
setHeightThumbnail()
} else {
playerHolder.startService(false, this)
}
}
public override fun onKeyDown(keyCode: Int): Boolean {
return (isPlayerAvailable()
&& player!!.UIs().get((VideoPlayerUi::class.java))
.map(Function({ playerUi: VideoPlayerUi? -> playerUi!!.onKeyDown(keyCode) })).orElse(false))
}
public override fun onBackPressed(): Boolean {
if (BaseFragment.Companion.DEBUG) {
Log.d(TAG, "onBackPressed() called")
}
// If we are in fullscreen mode just exit from it via first back press
if (isFullscreen()) {
if (!DeviceUtils.isTablet((activity)!!)) {
player!!.pause()
}
restoreDefaultOrientation()
setAutoPlay(false)
return true
}
// If we have something in history of played items we replay it here
if ((isPlayerAvailable()
&& (player!!.getPlayQueue() != null
) && player!!.videoPlayerSelected()
&& player!!.getPlayQueue()!!.previous())) {
return true // no code here, as previous() was used in the if
}
// That means that we are on the start of the stack,
if (stack.size <= 1) {
restoreDefaultOrientation()
return false // let MainActivity handle the onBack (e.g. to minimize the mini player)
}
// Remove top
stack.pop()
// Get stack item from the new top
setupFromHistoryItem(Objects.requireNonNull(stack.peek()))
return true
}
private fun setupFromHistoryItem(item: StackItem) {
setAutoPlay(false)
hideMainPlayerOnLoadingNewStream()
setInitialData(item.getServiceId(), item.getUrl(),
(if (item.getTitle() == null) "" else item.getTitle())!!, item.getPlayQueue())
startLoading(false)
// Maybe an item was deleted in background activity
if (item.getPlayQueue()!!.getItem() == null) {
return
}
val playQueueItem: PlayQueueItem? = item.getPlayQueue()!!.getItem()
// Update title, url, uploader from the last item in the stack (it's current now)
val isPlayerStopped: Boolean = !isPlayerAvailable() || player!!.isStopped()
if (playQueueItem != null && isPlayerStopped) {
updateOverlayData(playQueueItem.getTitle(),
playQueueItem.getUploader(), playQueueItem.getThumbnails())
}
}
/*//////////////////////////////////////////////////////////////////////////
// Info loading and handling
////////////////////////////////////////////////////////////////////////// */
override fun doInitialLoadLogic() {
if (wasCleared()) {
return
}
if (currentInfo == null) {
prepareAndLoadInfo()
} else {
prepareAndHandleInfoIfNeededAfterDelay(currentInfo, false, 50)
}
}
fun selectAndLoadVideo(newServiceId: Int,
newUrl: String?,
newTitle: String,
newQueue: PlayQueue?) {
if (isPlayerAvailable() && (newQueue != null) && (playQueue != null
) && (playQueue!!.getItem() != null) && !(playQueue!!.getItem().getUrl() == newUrl)) {
// Preloading can be disabled since playback is surely being replaced.
player!!.disablePreloadingOfCurrentTrack()
}
setInitialData(newServiceId, newUrl, newTitle, newQueue)
startLoading(false, true)
}
private fun prepareAndHandleInfoIfNeededAfterDelay(info: StreamInfo?,
scrollToTop: Boolean,
delay: Long) {
Handler(Looper.getMainLooper()).postDelayed(Runnable({
if (activity == null) {
return@postDelayed
}
// Data can already be drawn, don't spend time twice
if ((info!!.getName() == binding!!.detailVideoTitleView.getText().toString())) {
return@postDelayed
}
prepareAndHandleInfo(info, scrollToTop)
}), delay)
}
private fun prepareAndHandleInfo(info: StreamInfo?, scrollToTop: Boolean) {
if (BaseFragment.Companion.DEBUG) {
Log.d(TAG, ("prepareAndHandleInfo() called with: "
+ "info = [" + info + "], scrollToTop = [" + scrollToTop + "]"))
}
showLoading()
initTabs()
if (scrollToTop) {
scrollToTop()
}
handleResult((info)!!)
showContent()
}
protected fun prepareAndLoadInfo() {
scrollToTop()
startLoading(false)
}
public override fun startLoading(forceLoad: Boolean) {
super.startLoading(forceLoad)
initTabs()
currentInfo = null
if (currentWorker != null) {
currentWorker!!.dispose()
}
runWorker(forceLoad, stack.isEmpty())
}
private fun startLoading(forceLoad: Boolean, addToBackStack: Boolean) {
super.startLoading(forceLoad)
initTabs()
currentInfo = null
if (currentWorker != null) {
currentWorker!!.dispose()
}
runWorker(forceLoad, addToBackStack)
}
private fun runWorker(forceLoad: Boolean, addToBackStack: Boolean) {
val prefs: SharedPreferences = PreferenceManager.getDefaultSharedPreferences((activity)!!)
currentWorker = ExtractorHelper.getStreamInfo(serviceId, url, forceLoad)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(io.reactivex.rxjava3.functions.Consumer({ result: StreamInfo ->
isLoading.set(false)
hideMainPlayerOnLoadingNewStream()
if (result.getAgeLimit() != StreamExtractor.NO_AGE_LIMIT && !prefs.getBoolean(
getString(R.string.show_age_restricted_content), false)) {
hideAgeRestrictedContent()
} else {
handleResult(result)
showContent()
if (addToBackStack) {
if (playQueue == null) {
playQueue = SinglePlayQueue(result)
}
if (stack.isEmpty() || !stack.peek().getPlayQueue()
.equalStreams(playQueue)) {
stack.push(StackItem(serviceId, url, title, playQueue))
}
}
if (isAutoplayEnabled()) {
openVideoPlayerAutoFullscreen()
}
}
}), io.reactivex.rxjava3.functions.Consumer({ throwable: Throwable? ->
showError(ErrorInfo((throwable)!!, UserAction.REQUESTED_STREAM,
(if (url == null) "no url" else url)!!, serviceId))
}))
}
/*//////////////////////////////////////////////////////////////////////////
// Tabs
////////////////////////////////////////////////////////////////////////// */
private fun initTabs() {
if (pageAdapter!!.getCount() != 0) {
selectedTabTag = pageAdapter!!.getItemTitle(binding!!.viewPager.getCurrentItem())
}
pageAdapter!!.clearAllItems()
tabIcons.clear()
tabContentDescriptions.clear()
if (shouldShowComments()) {
pageAdapter!!.addFragment(
CommentsFragment.Companion.getInstance(serviceId, url, title), COMMENTS_TAB_TAG)
tabIcons.add(R.drawable.ic_comment)
tabContentDescriptions.add(R.string.comments_tab_description)
}
if (showRelatedItems && binding!!.relatedItemsLayout == null) {
// temp empty fragment. will be updated in handleResult
pageAdapter!!.addFragment(EmptyFragment.Companion.newInstance(false), RELATED_TAB_TAG)
tabIcons.add(R.drawable.ic_art_track)
tabContentDescriptions.add(R.string.related_items_tab_description)
}
if (showDescription) {
// temp empty fragment. will be updated in handleResult
pageAdapter!!.addFragment(EmptyFragment.Companion.newInstance(false), DESCRIPTION_TAB_TAG)
tabIcons.add(R.drawable.ic_description)
tabContentDescriptions.add(R.string.description_tab_description)
}
if (pageAdapter!!.getCount() == 0) {
pageAdapter!!.addFragment(EmptyFragment.Companion.newInstance(true), EMPTY_TAB_TAG)
}
pageAdapter!!.notifyDataSetUpdate()
if (pageAdapter!!.getCount() >= 2) {
val position: Int = pageAdapter!!.getItemPositionByTitle(selectedTabTag)
if (position != -1) {
binding!!.viewPager.setCurrentItem(position)
}
updateTabIconsAndContentDescriptions()
}
// the page adapter now contains tabs: show the tab layout
updateTabLayoutVisibility()
}
/**
* To be called whenever [.pageAdapter] is modified, since that triggers a refresh in
* [FragmentVideoDetailBinding.tabLayout] resetting all tab's icons and content
* descriptions. This reads icons from [.tabIcons] and content descriptions from
* [.tabContentDescriptions], which are all set in [.initTabs].
*/
private fun updateTabIconsAndContentDescriptions() {
for (i in tabIcons.indices) {
val tab: TabLayout.Tab? = binding!!.tabLayout.getTabAt(i)
if (tab != null) {
tab.setIcon(tabIcons.get(i))
tab.setContentDescription(tabContentDescriptions.get(i))
}
}
}
private fun updateTabs(info: StreamInfo) {
if (showRelatedItems) {
if (binding!!.relatedItemsLayout == null) { // phone
pageAdapter!!.updateItem(RELATED_TAB_TAG, RelatedItemsFragment.Companion.getInstance(info))
} else { // tablet + TV
getChildFragmentManager().beginTransaction()
.replace(R.id.relatedItemsLayout, RelatedItemsFragment.Companion.getInstance(info))
.commitAllowingStateLoss()
binding!!.relatedItemsLayout!!.setVisibility(if (isFullscreen()) View.GONE else View.VISIBLE)
}
}
if (showDescription) {
pageAdapter!!.updateItem(DESCRIPTION_TAB_TAG, DescriptionFragment(info))
}
binding!!.viewPager.setVisibility(View.VISIBLE)
// make sure the tab layout is visible
updateTabLayoutVisibility()
pageAdapter!!.notifyDataSetUpdate()
updateTabIconsAndContentDescriptions()
}
private fun shouldShowComments(): Boolean {
try {
return showComments && NewPipe.getService(serviceId)
.getServiceInfo()
.getMediaCapabilities()
.contains(MediaCapability.COMMENTS)
} catch (e: ExtractionException) {
return false
}
}
fun updateTabLayoutVisibility() {
if (binding == null) {
//If binding is null we do not need to and should not do anything with its object(s)
return
}
if (pageAdapter!!.getCount() < 2 || binding!!.viewPager.getVisibility() != View.VISIBLE) {
// hide tab layout if there is only one tab or if the view pager is also hidden
binding!!.tabLayout.setVisibility(View.GONE)
} else {
// call `post()` to be sure `viewPager.getHitRect()`
// is up to date and not being currently recomputed
binding!!.tabLayout.post(Runnable({
val activity: FragmentActivity? = getActivity()
if (activity != null) {
val pagerHitRect: Rect = Rect()
binding!!.viewPager.getHitRect(pagerHitRect)
val height: Int = DeviceUtils.getWindowHeight(activity.getWindowManager())
val viewPagerVisibleHeight: Int = height - pagerHitRect.top
// see TabLayout.DEFAULT_HEIGHT, which is equal to 48dp
val tabLayoutHeight: Float = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP, 48f, getResources().getDisplayMetrics())
if (viewPagerVisibleHeight > tabLayoutHeight * 2) {
// no translation at all when viewPagerVisibleHeight > tabLayout.height * 3
binding!!.tabLayout.setTranslationY(max(0.0, (tabLayoutHeight * 3 - viewPagerVisibleHeight).toDouble()).toFloat())
binding!!.tabLayout.setVisibility(View.VISIBLE)
} else {
// view pager is not visible enough
binding!!.tabLayout.setVisibility(View.GONE)
}
}
}))
}
}
fun scrollToTop() {
binding!!.appBarLayout.setExpanded(true, true)
// notify tab layout of scrolling
updateTabLayoutVisibility()
}
fun scrollToComment(comment: CommentsInfoItem?) {
val commentsTabPos: Int = pageAdapter!!.getItemPositionByTitle(COMMENTS_TAB_TAG)
val fragment: Fragment = pageAdapter!!.getItem(commentsTabPos)
if (!(fragment is CommentsFragment)) {
return
}
// unexpand the app bar only if scrolling to the comment succeeded
if (fragment.scrollToComment(comment)) {
binding!!.appBarLayout.setExpanded(false, false)
binding!!.viewPager.setCurrentItem(commentsTabPos, false)
}
}
/*//////////////////////////////////////////////////////////////////////////
// Play Utils
////////////////////////////////////////////////////////////////////////// */
private fun toggleFullscreenIfInFullscreenMode() {
// If a user watched video inside fullscreen mode and than chose another player
// return to non-fullscreen mode
if (isPlayerAvailable()) {
player!!.UIs().get((MainPlayerUi::class.java)).ifPresent(java.util.function.Consumer({ playerUi: MainPlayerUi? ->
if (playerUi!!.isFullscreen()) {
playerUi.toggleFullscreen()
}
}))
}
}
private fun openBackgroundPlayer(append: Boolean) {
val useExternalAudioPlayer: Boolean = PreferenceManager
.getDefaultSharedPreferences((activity)!!)
.getBoolean(activity!!.getString(R.string.use_external_audio_player_key), false)
toggleFullscreenIfInFullscreenMode()
if (isPlayerAvailable()) {
// FIXME Workaround #7427
player!!.setRecovery()
}
if (useExternalAudioPlayer) {
showExternalAudioPlaybackDialog()
} else {
openNormalBackgroundPlayer(append)
}
}
private fun openPopupPlayer(append: Boolean) {
if (!PermissionHelper.isPopupEnabledElseAsk(activity)) {
return
}
// See UI changes while remote playQueue changes
if (!isPlayerAvailable()) {
playerHolder!!.startService(false, this)
} else {
// FIXME Workaround #7427
player!!.setRecovery()
}
toggleFullscreenIfInFullscreenMode()
val queue: PlayQueue = setupPlayQueueForIntent(append)
if (append) { //resumePlayback: false
NavigationHelper.enqueueOnPlayer((activity)!!, queue, PlayerType.POPUP)
} else {
replaceQueueIfUserConfirms(Runnable({ NavigationHelper.playOnPopupPlayer(activity, queue, true) }))
}
}
/**
* Opens the video player, in fullscreen if needed. In order to open fullscreen, the activity
* is toggled to landscape orientation (which will then cause fullscreen mode).
*
* @param directlyFullscreenIfApplicable whether to open fullscreen if we are not already
* in landscape and screen orientation is locked
*/
fun openVideoPlayer(directlyFullscreenIfApplicable: Boolean) {
if ((directlyFullscreenIfApplicable
&& !DeviceUtils.isLandscape(requireContext())
&& PlayerHelper.globalScreenOrientationLocked(requireContext()))) {
// Make sure the bottom sheet turns out expanded. When this code kicks in the bottom
// sheet could not have fully expanded yet, and thus be in the STATE_SETTLING state.
// When the activity is rotated, and its state is saved and then restored, the bottom
// sheet would forget what it was doing, since even if STATE_SETTLING is restored, it
// doesn't tell which state it was settling to, and thus the bottom sheet settles to
// STATE_COLLAPSED. This can be solved by manually setting the state that will be
// restored (i.e. bottomSheetState) to STATE_EXPANDED.
updateBottomSheetState(BottomSheetBehavior.STATE_EXPANDED)
// toggle landscape in order to open directly in fullscreen
onScreenRotationButtonClicked()
}
if (PreferenceManager.getDefaultSharedPreferences((activity)!!)
.getBoolean(this.getString(R.string.use_external_video_player_key), false)) {
showExternalVideoPlaybackDialog()
} else {
replaceQueueIfUserConfirms(Runnable({ openMainPlayer() }))
}
}
/**
* If the option to start directly fullscreen is enabled, calls
* [.openVideoPlayer] with `directlyFullscreenIfApplicable = true`, so that
* if the user is not already in landscape and he has screen orientation locked the activity
* rotates and fullscreen starts. Otherwise, if the option to start directly fullscreen is
* disabled, calls [.openVideoPlayer] with `directlyFullscreenIfApplicable
* = false`, hence preventing it from going directly fullscreen.
*/
fun openVideoPlayerAutoFullscreen() {
openVideoPlayer(PlayerHelper.isStartMainPlayerFullscreenEnabled(requireContext()))
}
private fun openNormalBackgroundPlayer(append: Boolean) {
// See UI changes while remote playQueue changes
if (!isPlayerAvailable()) {
playerHolder!!.startService(false, this)
}
val queue: PlayQueue = setupPlayQueueForIntent(append)
if (append) {
NavigationHelper.enqueueOnPlayer((activity)!!, queue, PlayerType.AUDIO)
} else {
replaceQueueIfUserConfirms(Runnable({ NavigationHelper.playOnBackgroundPlayer(activity, queue, true) }))
}
}
private fun openMainPlayer() {
if (!isPlayerServiceAvailable()) {
playerHolder!!.startService(autoPlayEnabled, this)
return
}
if (currentInfo == null) {
return
}
val queue: PlayQueue = setupPlayQueueForIntent(false)
tryAddVideoPlayerView()
val playerIntent: Intent = NavigationHelper.getPlayerIntent(requireContext(),
PlayerService::class.java, queue, true, autoPlayEnabled)
ContextCompat.startForegroundService((activity)!!, playerIntent)
}
/**
* When the video detail fragment is already showing details for a video and the user opens a
* new one, the video detail fragment changes all of its old data to the new stream, so if there
* is a video player currently open it should be hidden. This method does exactly that. If
* autoplay is enabled, the underlying player is not stopped completely, since it is going to
* be reused in a few milliseconds and the flickering would be annoying.
*/
private fun hideMainPlayerOnLoadingNewStream() {
val root: Optional<View> = getRoot()
if (!isPlayerServiceAvailable() || root.isEmpty() || !player!!.videoPlayerSelected()) {
return
}
removeVideoPlayerView()
if (isAutoplayEnabled()) {
playerService!!.stopForImmediateReusing()
root.ifPresent(java.util.function.Consumer({ view: View -> view.setVisibility(View.GONE) }))
} else {
playerHolder!!.stopService()
}
}
private fun setupPlayQueueForIntent(append: Boolean): PlayQueue {
if (append) {
return SinglePlayQueue(currentInfo)
}
var queue: PlayQueue? = playQueue
// Size can be 0 because queue removes bad stream automatically when error occurs
if (queue == null || queue.isEmpty()) {
queue = SinglePlayQueue(currentInfo)
}
return queue
}
/*//////////////////////////////////////////////////////////////////////////
// Utils
////////////////////////////////////////////////////////////////////////// */
fun setAutoPlay(autoPlay: Boolean) {
autoPlayEnabled = autoPlay
}
private fun startOnExternalPlayer(context: Context,
info: StreamInfo,
selectedStream: Stream) {
NavigationHelper.playOnExternalPlayer(context, currentInfo!!.getName(),
currentInfo!!.getSubChannelName(), selectedStream)
val recordManager: HistoryRecordManager = HistoryRecordManager(requireContext())
disposables.add(recordManager.onViewed(info).onErrorComplete()
.subscribe(
io.reactivex.rxjava3.functions.Consumer<Long?>({ ignored: Long? -> }),
io.reactivex.rxjava3.functions.Consumer({ error: Throwable? -> Log.e(TAG, "Register view failure: ", error) })
))
}
private fun isExternalPlayerEnabled(): Boolean {
return PreferenceManager.getDefaultSharedPreferences(requireContext())
.getBoolean(getString(R.string.use_external_video_player_key), false)
}
// This method overrides default behaviour when setAutoPlay() is called.
// Don't auto play if the user selected an external player or disabled it in settings
private fun isAutoplayEnabled(): Boolean {
return (autoPlayEnabled
&& !isExternalPlayerEnabled()
&& (!isPlayerAvailable() || player!!.videoPlayerSelected())
&& (bottomSheetState != BottomSheetBehavior.STATE_HIDDEN
) && PlayerHelper.isAutoplayAllowedByUser(requireContext()))
}
private fun tryAddVideoPlayerView() {
if (isPlayerAvailable() && getView() != null) {
// Setup the surface view height, so that it fits the video correctly; this is done also
// here, and not only in the Handler, to avoid a choppy fullscreen rotation animation.
setHeightThumbnail()
}
// do all the null checks in the posted lambda, too, since the player, the binding and the
// view could be set or unset before the lambda gets executed on the next main thread cycle
Handler(Looper.getMainLooper()).post(Runnable({
if (!isPlayerAvailable() || getView() == null) {
return@post
}
// setup the surface view height, so that it fits the video correctly
setHeightThumbnail()
player!!.UIs().get((MainPlayerUi::class.java)).ifPresent(java.util.function.Consumer({ playerUi: MainPlayerUi? ->
// sometimes binding would be null here, even though getView() != null above u.u
if (binding != null) {
// prevent from re-adding a view multiple times
playerUi!!.removeViewFromParent()
binding!!.playerPlaceholder.addView(playerUi.getBinding().getRoot())
playerUi.setupVideoSurfaceIfNeeded()
}
}))
}))
}
private fun removeVideoPlayerView() {
makeDefaultHeightForVideoPlaceholder()
if (player != null) {
player!!.UIs().get((VideoPlayerUi::class.java)).ifPresent(java.util.function.Consumer({ obj: VideoPlayerUi? -> obj!!.removeViewFromParent() }))
}
}
private fun makeDefaultHeightForVideoPlaceholder() {
if (getView() == null) {
return
}
binding!!.playerPlaceholder.getLayoutParams().height = FrameLayout.LayoutParams.MATCH_PARENT
binding!!.playerPlaceholder.requestLayout()
}
private val preDrawListener: ViewTreeObserver.OnPreDrawListener = object : ViewTreeObserver.OnPreDrawListener {
public override fun onPreDraw(): Boolean {
val metrics: DisplayMetrics = getResources().getDisplayMetrics()
if (getView() != null) {
val height: Int = (if (DeviceUtils.isInMultiWindow((activity)!!)) requireView() else activity!!.getWindow().getDecorView()).getHeight()
setHeightThumbnail(height, metrics)
getView()!!.getViewTreeObserver().removeOnPreDrawListener(preDrawListener)
}
return false
}
}
/**
* Method which controls the size of thumbnail and the size of main player inside
* a layout with thumbnail. It decides what height the player should have in both
* screen orientations. It knows about multiWindow feature
* and about videos with aspectRatio ZOOM (the height for them will be a bit higher,
* [.MAX_PLAYER_HEIGHT])
*/
private fun setHeightThumbnail() {
val metrics: DisplayMetrics = getResources().getDisplayMetrics()
val isPortrait: Boolean = metrics.heightPixels > metrics.widthPixels
requireView().getViewTreeObserver().removeOnPreDrawListener(preDrawListener)
if (isFullscreen()) {
val height: Int = (if (DeviceUtils.isInMultiWindow((activity)!!)) requireView() else activity!!.getWindow().getDecorView()).getHeight()
// Height is zero when the view is not yet displayed like after orientation change
if (height != 0) {
setHeightThumbnail(height, metrics)
} else {
requireView().getViewTreeObserver().addOnPreDrawListener(preDrawListener)
}
} else {
val height: Int = (if (isPortrait) metrics.widthPixels / (16.0f / 9.0f) else metrics.heightPixels / 2.0f).toInt()
setHeightThumbnail(height, metrics)
}
}
private fun setHeightThumbnail(newHeight: Int, metrics: DisplayMetrics) {
binding!!.detailThumbnailImageView.setLayoutParams(
FrameLayout.LayoutParams(
RelativeLayout.LayoutParams.MATCH_PARENT, newHeight))
binding!!.detailThumbnailImageView.setMinimumHeight(newHeight)
if (isPlayerAvailable()) {
val maxHeight: Int = (metrics.heightPixels * MAX_PLAYER_HEIGHT).toInt()
player!!.UIs().get((VideoPlayerUi::class.java)).ifPresent(java.util.function.Consumer({ ui: VideoPlayerUi? ->
ui.getBinding().surfaceView.setHeights(newHeight,
if (ui!!.isFullscreen()) newHeight else maxHeight)
}))
}
}
private fun showContent() {
binding!!.detailContentRootHiding.setVisibility(View.VISIBLE)
}
protected fun setInitialData(newServiceId: Int,
newUrl: String?,
newTitle: String,
newPlayQueue: PlayQueue?) {
serviceId = newServiceId
url = newUrl
title = newTitle
playQueue = newPlayQueue
}
private fun setErrorImage(imageResource: Int) {
if (binding == null || activity == null) {
return
}
binding!!.detailThumbnailImageView.setImageDrawable(
AppCompatResources.getDrawable(requireContext(), imageResource))
binding!!.detailThumbnailImageView.animate(false, 0, AnimationType.ALPHA, 0, Runnable({ binding!!.detailThumbnailImageView.animate(true, 500) }))
}
public override fun handleError() {
super.handleError()
setErrorImage(R.drawable.not_available_monkey)
if (binding!!.relatedItemsLayout != null) { // hide related streams for tablets
binding!!.relatedItemsLayout!!.setVisibility(View.INVISIBLE)
}
// hide comments / related streams / description tabs
binding!!.viewPager.setVisibility(View.GONE)
binding!!.tabLayout.setVisibility(View.GONE)
}
private fun hideAgeRestrictedContent() {
showTextError(getString(R.string.restricted_video,
getString(R.string.show_age_restricted_content_title)))
}
private fun setupBroadcastReceiver() {
broadcastReceiver = object : BroadcastReceiver() {
public override fun onReceive(context: Context, intent: Intent) {
when (intent.getAction()) {
ACTION_SHOW_MAIN_PLAYER -> bottomSheetBehavior!!.setState(BottomSheetBehavior.STATE_EXPANDED)
ACTION_HIDE_MAIN_PLAYER -> bottomSheetBehavior!!.setState(BottomSheetBehavior.STATE_HIDDEN)
ACTION_PLAYER_STARTED -> {
// If the state is not hidden we don't need to show the mini player
if (bottomSheetBehavior!!.getState() == BottomSheetBehavior.STATE_HIDDEN) {
bottomSheetBehavior!!.setState(BottomSheetBehavior.STATE_COLLAPSED)
}
// Rebound to the service if it was closed via notification or mini player
if (!playerHolder!!.isBound()) {
playerHolder.startService(
false, this@VideoDetailFragment)
}
}
}
}
}
val intentFilter: IntentFilter = IntentFilter()
intentFilter.addAction(ACTION_SHOW_MAIN_PLAYER)
intentFilter.addAction(ACTION_HIDE_MAIN_PLAYER)
intentFilter.addAction(ACTION_PLAYER_STARTED)
activity!!.registerReceiver(broadcastReceiver, intentFilter)
}
/*//////////////////////////////////////////////////////////////////////////
// Orientation listener
////////////////////////////////////////////////////////////////////////// */
private fun restoreDefaultOrientation() {
if (isPlayerAvailable() && player!!.videoPlayerSelected()) {
toggleFullscreenIfInFullscreenMode()
}
// This will show systemUI and pause the player.
// User can tap on Play button and video will be in fullscreen mode again
// Note for tablet: trying to avoid orientation changes since it's not easy
// to physically rotate the tablet every time
if (activity != null && !DeviceUtils.isTablet(activity!!)) {
activity!!.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED)
}
}
/*//////////////////////////////////////////////////////////////////////////
// Contract
////////////////////////////////////////////////////////////////////////// */
public override fun showLoading() {
super.showLoading()
//if data is already cached, transition from VISIBLE -> INVISIBLE -> VISIBLE is not required
if (!ExtractorHelper.isCached(serviceId, (url)!!, InfoCache.Type.STREAM)) {
binding!!.detailContentRootHiding.setVisibility(View.INVISIBLE)
}
binding!!.detailThumbnailPlayButton.animate(false, 50)
binding!!.detailDurationView.animate(false, 100)
binding!!.detailPositionView.setVisibility(View.GONE)
binding!!.positionView.setVisibility(View.GONE)
binding!!.detailVideoTitleView.setText(title)
binding!!.detailVideoTitleView.setMaxLines(1)
binding!!.detailVideoTitleView.animate(true, 0)
binding!!.detailToggleSecondaryControlsView.setVisibility(View.GONE)
binding!!.detailTitleRootLayout.setClickable(false)
binding!!.detailSecondaryControlPanel.setVisibility(View.GONE)
if (binding!!.relatedItemsLayout != null) {
if (showRelatedItems) {
binding!!.relatedItemsLayout!!.setVisibility(
if (isFullscreen()) View.GONE else View.INVISIBLE)
} else {
binding!!.relatedItemsLayout!!.setVisibility(View.GONE)
}
}
PicassoHelper.cancelTag(PICASSO_VIDEO_DETAILS_TAG)
binding!!.detailThumbnailImageView.setImageBitmap(null)
binding!!.detailSubChannelThumbnailView.setImageBitmap(null)
}
public override fun handleResult(info: StreamInfo) {
super.handleResult(info)
currentInfo = info
setInitialData(info.getServiceId(), info.getOriginalUrl(), info.getName(), playQueue)
updateTabs(info)
binding!!.detailThumbnailPlayButton.animate(true, 200)
binding!!.detailVideoTitleView.setText(title)
binding!!.detailSubChannelThumbnailView.setVisibility(View.GONE)
if (!TextUtils.isEmpty(info.getSubChannelName())) {
displayBothUploaderAndSubChannel(info)
} else {
displayUploaderAsSubChannel(info)
}
if (info.getViewCount() >= 0) {
if ((info.getStreamType() == StreamType.AUDIO_LIVE_STREAM)) {
binding!!.detailViewCountView.setText(Localization.listeningCount((activity)!!,
info.getViewCount()))
} else if ((info.getStreamType() == StreamType.LIVE_STREAM)) {
binding!!.detailViewCountView.setText(Localization.localizeWatchingCount((activity)!!, info.getViewCount()))
} else {
binding!!.detailViewCountView.setText(Localization.localizeViewCount((activity)!!, info.getViewCount()))
}
binding!!.detailViewCountView.setVisibility(View.VISIBLE)
} else {
binding!!.detailViewCountView.setVisibility(View.GONE)
}
if (info.getDislikeCount() == -1L && info.getLikeCount() == -1L) {
binding!!.detailThumbsDownImgView.setVisibility(View.VISIBLE)
binding!!.detailThumbsUpImgView.setVisibility(View.VISIBLE)
binding!!.detailThumbsUpCountView.setVisibility(View.GONE)
binding!!.detailThumbsDownCountView.setVisibility(View.GONE)
binding!!.detailThumbsDisabledView.setVisibility(View.VISIBLE)
} else {
if (info.getDislikeCount() >= 0) {
binding!!.detailThumbsDownCountView.setText(Localization.shortCount((activity)!!, info.getDislikeCount()))
binding!!.detailThumbsDownCountView.setVisibility(View.VISIBLE)
binding!!.detailThumbsDownImgView.setVisibility(View.VISIBLE)
} else {
binding!!.detailThumbsDownCountView.setVisibility(View.GONE)
binding!!.detailThumbsDownImgView.setVisibility(View.GONE)
}
if (info.getLikeCount() >= 0) {
binding!!.detailThumbsUpCountView.setText(Localization.shortCount((activity)!!,
info.getLikeCount()))
binding!!.detailThumbsUpCountView.setVisibility(View.VISIBLE)
binding!!.detailThumbsUpImgView.setVisibility(View.VISIBLE)
} else {
binding!!.detailThumbsUpCountView.setVisibility(View.GONE)
binding!!.detailThumbsUpImgView.setVisibility(View.GONE)
}
binding!!.detailThumbsDisabledView.setVisibility(View.GONE)
}
if (info.getDuration() > 0) {
binding!!.detailDurationView.setText(Localization.getDurationString(info.getDuration()))
binding!!.detailDurationView.setBackgroundColor(
ContextCompat.getColor((activity)!!, R.color.duration_background_color))
binding!!.detailDurationView.animate(true, 100)
} else if (info.getStreamType() == StreamType.LIVE_STREAM) {
binding!!.detailDurationView.setText(R.string.duration_live)
binding!!.detailDurationView.setBackgroundColor(
ContextCompat.getColor((activity)!!, R.color.live_duration_background_color))
binding!!.detailDurationView.animate(true, 100)
} else {
binding!!.detailDurationView.setVisibility(View.GONE)
}
binding!!.detailTitleRootLayout.setClickable(true)
binding!!.detailToggleSecondaryControlsView.setRotation(0f)
binding!!.detailToggleSecondaryControlsView.setVisibility(View.VISIBLE)
binding!!.detailSecondaryControlPanel.setVisibility(View.GONE)
checkUpdateProgressInfo(info)
PicassoHelper.loadDetailsThumbnail(info.getThumbnails()).tag(PICASSO_VIDEO_DETAILS_TAG)
.into(binding!!.detailThumbnailImageView)
ExtractorHelper.showMetaInfoInTextView(info.getMetaInfo(), binding!!.detailMetaInfoTextView,
binding!!.detailMetaInfoSeparator, disposables)
if (!isPlayerAvailable() || player!!.isStopped()) {
updateOverlayData(info.getName(), info.getUploaderName(), info.getThumbnails())
}
if (!info.getErrors().isEmpty()) {
// Bandcamp fan pages are not yet supported and thus a ContentNotAvailableException is
// thrown. This is not an error and thus should not be shown to the user.
for (throwable: Throwable in info.getErrors()) {
if ((throwable is ContentNotSupportedException
&& ("Fan pages are not supported" == throwable.message))) {
info.getErrors().remove(throwable)
}
}
if (!info.getErrors().isEmpty()) {
showSnackBarError(ErrorInfo(info.getErrors(),
UserAction.REQUESTED_STREAM, info.getUrl(), info))
}
}
binding!!.detailControlsDownload.setVisibility(
if (StreamTypeUtil.isLiveStream(info.getStreamType())) View.GONE else View.VISIBLE)
binding!!.detailControlsBackground.setVisibility(
if (info.getAudioStreams().isEmpty() && info.getVideoStreams().isEmpty()) View.GONE else View.VISIBLE)
val noVideoStreams: Boolean = info.getVideoStreams().isEmpty() && info.getVideoOnlyStreams().isEmpty()
binding!!.detailControlsPopup.setVisibility(if (noVideoStreams) View.GONE else View.VISIBLE)
binding!!.detailThumbnailPlayButton.setImageResource(
if (noVideoStreams) R.drawable.ic_headset_shadow else R.drawable.ic_play_arrow_shadow)
}
private fun displayUploaderAsSubChannel(info: StreamInfo) {
binding!!.detailSubChannelTextView.setText(info.getUploaderName())
binding!!.detailSubChannelTextView.setVisibility(View.VISIBLE)
binding!!.detailSubChannelTextView.setSelected(true)
if (info.getUploaderSubscriberCount() > -1) {
binding!!.detailUploaderTextView.setText(
Localization.shortSubscriberCount((activity)!!, info.getUploaderSubscriberCount()))
binding!!.detailUploaderTextView.setVisibility(View.VISIBLE)
} else {
binding!!.detailUploaderTextView.setVisibility(View.GONE)
}
PicassoHelper.loadAvatar(info.getUploaderAvatars()).tag(PICASSO_VIDEO_DETAILS_TAG)
.into(binding!!.detailSubChannelThumbnailView)
binding!!.detailSubChannelThumbnailView.setVisibility(View.VISIBLE)
binding!!.detailUploaderThumbnailView.setVisibility(View.GONE)
}
private fun displayBothUploaderAndSubChannel(info: StreamInfo) {
binding!!.detailSubChannelTextView.setText(info.getSubChannelName())
binding!!.detailSubChannelTextView.setVisibility(View.VISIBLE)
binding!!.detailSubChannelTextView.setSelected(true)
val subText: StringBuilder = StringBuilder()
if (!TextUtils.isEmpty(info.getUploaderName())) {
subText.append(String.format(getString(R.string.video_detail_by), info.getUploaderName()))
}
if (info.getUploaderSubscriberCount() > -1) {
if (subText.length > 0) {
subText.append(Localization.DOT_SEPARATOR)
}
subText.append(
Localization.shortSubscriberCount((activity)!!, info.getUploaderSubscriberCount()))
}
if (subText.length > 0) {
binding!!.detailUploaderTextView.setText(subText)
binding!!.detailUploaderTextView.setVisibility(View.VISIBLE)
binding!!.detailUploaderTextView.setSelected(true)
} else {
binding!!.detailUploaderTextView.setVisibility(View.GONE)
}
PicassoHelper.loadAvatar(info.getSubChannelAvatars()).tag(PICASSO_VIDEO_DETAILS_TAG)
.into(binding!!.detailSubChannelThumbnailView)
binding!!.detailSubChannelThumbnailView.setVisibility(View.VISIBLE)
PicassoHelper.loadAvatar(info.getUploaderAvatars()).tag(PICASSO_VIDEO_DETAILS_TAG)
.into(binding!!.detailUploaderThumbnailView)
binding!!.detailUploaderThumbnailView.setVisibility(View.VISIBLE)
}
fun openDownloadDialog() {
if (currentInfo == null) {
return
}
try {
val downloadDialog: DownloadDialog = DownloadDialog((activity)!!, currentInfo!!)
downloadDialog.show(activity!!.getSupportFragmentManager(), "downloadDialog")
} catch (e: Exception) {
showSnackbar((activity)!!, ErrorInfo(e, UserAction.DOWNLOAD_OPEN_DIALOG,
"Showing download dialog", currentInfo))
}
}
/*//////////////////////////////////////////////////////////////////////////
// Stream Results
////////////////////////////////////////////////////////////////////////// */
private fun checkUpdateProgressInfo(info: StreamInfo) {
if (positionSubscriber != null) {
positionSubscriber!!.dispose()
}
if (!DependentPreferenceHelper.getResumePlaybackEnabled((activity)!!)) {
binding!!.positionView.setVisibility(View.GONE)
binding!!.detailPositionView.setVisibility(View.GONE)
return
}
val recordManager: HistoryRecordManager = HistoryRecordManager(requireContext())
positionSubscriber = recordManager.loadStreamState(info)
.subscribeOn(Schedulers.io())
.onErrorComplete()
.observeOn(AndroidSchedulers.mainThread())
.subscribe(io.reactivex.rxjava3.functions.Consumer<StreamStateEntity?>({ state: StreamStateEntity? ->
updatePlaybackProgress(
state!!.getProgressMillis(), info.getDuration() * 1000)
}), io.reactivex.rxjava3.functions.Consumer({ e: Throwable? -> }), Action({
binding!!.positionView.setVisibility(View.GONE)
binding!!.detailPositionView.setVisibility(View.GONE)
}))
}
private fun updatePlaybackProgress(progress: Long, duration: Long) {
if (!DependentPreferenceHelper.getResumePlaybackEnabled((activity)!!)) {
return
}
val progressSeconds: Int = TimeUnit.MILLISECONDS.toSeconds(progress).toInt()
val durationSeconds: Int = TimeUnit.MILLISECONDS.toSeconds(duration).toInt()
// If the old and the new progress values have a big difference then use animation.
// Otherwise don't because it affects CPU
val progressDifference: Int = abs((binding!!.positionView.getProgress()
- progressSeconds).toDouble()).toInt()
binding!!.positionView.setMax(durationSeconds)
if (progressDifference > 2) {
binding!!.positionView.setProgressAnimated(progressSeconds)
} else {
binding!!.positionView.setProgress(progressSeconds)
}
val position: String? = Localization.getDurationString(progressSeconds.toLong())
if (position !== binding!!.detailPositionView.getText()) {
binding!!.detailPositionView.setText(position)
}
if (binding!!.positionView.getVisibility() != View.VISIBLE) {
binding!!.positionView.animate(true, 100)
binding!!.detailPositionView.animate(true, 100)
}
}
/*//////////////////////////////////////////////////////////////////////////
// Player event listener
////////////////////////////////////////////////////////////////////////// */
public override fun onViewCreated() {
tryAddVideoPlayerView()
}
public override fun onQueueUpdate(queue: PlayQueue?) {
playQueue = queue
if (BaseFragment.Companion.DEBUG) {
Log.d(TAG, ("onQueueUpdate() called with: serviceId = ["
+ serviceId + "], videoUrl = [" + url + "], name = ["
+ title + "], playQueue = [" + playQueue + "]"))
}
// Register broadcast receiver to listen to playQueue changes
// and hide the overlayPlayQueueButton when the playQueue is empty / destroyed.
if (playQueue != null && playQueue.getBroadcastReceiver() != null) {
playQueue.getBroadcastReceiver().subscribe(
io.reactivex.rxjava3.functions.Consumer<PlayQueueEvent?>({ event: PlayQueueEvent? -> updateOverlayPlayQueueButtonVisibility() })
)
}
// This should be the only place where we push data to stack.
// It will allow to have live instance of PlayQueue with actual information about
// deleted/added items inside Channel/Playlist queue and makes possible to have
// a history of played items
val stackPeek: StackItem? = stack.peek()
if (stackPeek != null && !stackPeek.getPlayQueue()!!.equalStreams(queue)) {
val playQueueItem: PlayQueueItem? = queue!!.getItem()
if (playQueueItem != null) {
stack.push(StackItem(playQueueItem.getServiceId(), playQueueItem.getUrl(),
playQueueItem.getTitle(), queue))
return
} // else continue below
}
val stackWithQueue: StackItem? = findQueueInStack(queue)
if (stackWithQueue != null) {
// On every MainPlayer service's destroy() playQueue gets disposed and
// no longer able to track progress. That's why we update our cached disposed
// queue with the new one that is active and have the same history.
// Without that the cached playQueue will have an old recovery position
stackWithQueue.setPlayQueue(queue)
}
}
public override fun onPlaybackUpdate(state: Int,
repeatMode: Int,
shuffled: Boolean,
parameters: PlaybackParameters?) {
setOverlayPlayPauseImage(player != null && player!!.isPlaying())
when (state) {
Player.Companion.STATE_PLAYING -> if ((binding!!.positionView.getAlpha() != 1.0f
) && (player!!.getPlayQueue() != null
) && (player!!.getPlayQueue()!!.getItem() != null
) && (player!!.getPlayQueue()!!.getItem().getUrl() == url)) {
binding!!.positionView.animate(true, 100)
binding!!.detailPositionView.animate(true, 100)
}
}
}
public override fun onProgressUpdate(currentProgress: Int,
duration: Int,
bufferPercent: Int) {
// Progress updates every second even if media is paused. It's useless until playing
if (!player!!.isPlaying() || playQueue == null) {
return
}
if ((player!!.getPlayQueue()!!.getItem().getUrl() == url)) {
updatePlaybackProgress(currentProgress.toLong(), duration.toLong())
}
}
public override fun onMetadataUpdate(info: StreamInfo?, queue: PlayQueue?) {
val item: StackItem? = findQueueInStack(queue)
if (item != null) {
// When PlayQueue can have multiple streams (PlaylistPlayQueue or ChannelPlayQueue)
// every new played stream gives new title and url.
// StackItem contains information about first played stream. Let's update it here
item.setTitle(info!!.getName())
item.setUrl(info.getUrl())
}
// They are not equal when user watches something in popup while browsing in fragment and
// then changes screen orientation. In that case the fragment will set itself as
// a service listener and will receive initial call to onMetadataUpdate()
if (!queue!!.equalStreams(playQueue)) {
return
}
updateOverlayData(info!!.getName(), info.getUploaderName(), info.getThumbnails())
if (currentInfo != null && (info.getUrl() == currentInfo!!.getUrl())) {
return
}
currentInfo = info
setInitialData(info.getServiceId(), info.getUrl(), info.getName(), queue)
setAutoPlay(false)
// Delay execution just because it freezes the main thread, and while playing
// next/previous video you see visual glitches
// (when non-vertical video goes after vertical video)
prepareAndHandleInfoIfNeededAfterDelay(info, true, 200)
}
public override fun onPlayerError(error: PlaybackException?, isCatchableException: Boolean) {
if (!isCatchableException) {
// Properly exit from fullscreen
toggleFullscreenIfInFullscreenMode()
hideMainPlayerOnLoadingNewStream()
}
}
public override fun onServiceStopped() {
setOverlayPlayPauseImage(false)
if (currentInfo != null) {
updateOverlayData(currentInfo!!.getName(),
currentInfo!!.getUploaderName(),
currentInfo!!.getThumbnails())
}
updateOverlayPlayQueueButtonVisibility()
}
public override fun onFullscreenStateChanged(fullscreen: Boolean) {
setupBrightness()
if ((!isPlayerAndPlayerServiceAvailable()
|| player!!.UIs().get((MainPlayerUi::class.java)).isEmpty()
|| getRoot().map(Function({ obj: View -> obj.getParent() })).isEmpty())) {
return
}
if (fullscreen) {
hideSystemUiIfNeeded()
binding!!.overlayPlayPauseButton.requestFocus()
} else {
showSystemUi()
}
if (binding!!.relatedItemsLayout != null) {
binding!!.relatedItemsLayout!!.setVisibility(if (fullscreen) View.GONE else View.VISIBLE)
}
scrollToTop()
tryAddVideoPlayerView()
}
public override fun onScreenRotationButtonClicked() {
// In tablet user experience will be better if screen will not be rotated
// from landscape to portrait every time.
// Just turn on fullscreen mode in landscape orientation
// or portrait & unlocked global orientation
val isLandscape: Boolean = DeviceUtils.isLandscape(requireContext())
if ((DeviceUtils.isTablet((activity)!!)
&& (!PlayerHelper.globalScreenOrientationLocked(activity) || isLandscape))) {
player!!.UIs().get((MainPlayerUi::class.java)).ifPresent(java.util.function.Consumer({ obj: MainPlayerUi? -> obj!!.toggleFullscreen() }))
return
}
val newOrientation: Int = if (isLandscape) ActivityInfo.SCREEN_ORIENTATION_PORTRAIT else ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
activity!!.setRequestedOrientation(newOrientation)
}
/*
* Will scroll down to description view after long click on moreOptionsButton
* */
public override fun onMoreOptionsLongClicked() {
val params: CoordinatorLayout.LayoutParams = binding!!.appBarLayout.getLayoutParams() as CoordinatorLayout.LayoutParams
val behavior: AppBarLayout.Behavior? = params.getBehavior() as AppBarLayout.Behavior?
val valueAnimator: ValueAnimator = ValueAnimator
.ofInt(0, -binding!!.playerPlaceholder.getHeight())
valueAnimator.setInterpolator(DecelerateInterpolator())
valueAnimator.addUpdateListener(AnimatorUpdateListener({ animation: ValueAnimator ->
behavior!!.setTopAndBottomOffset(animation.getAnimatedValue() as Int)
binding!!.appBarLayout.requestLayout()
}))
valueAnimator.setInterpolator(DecelerateInterpolator())
valueAnimator.setDuration(500)
valueAnimator.start()
}
/*//////////////////////////////////////////////////////////////////////////
// Player related utils
////////////////////////////////////////////////////////////////////////// */
private fun showSystemUi() {
if (BaseFragment.Companion.DEBUG) {
Log.d(TAG, "showSystemUi() called")
}
if (activity == null) {
return
}
// Prevent jumping of the player on devices with cutout
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
activity!!.getWindow().getAttributes().layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT
}
activity!!.getWindow().getDecorView().setSystemUiVisibility(0)
activity!!.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN)
activity!!.getWindow().setStatusBarColor(ThemeHelper.resolveColorFromAttr(
requireContext(), android.R.attr.colorPrimary))
}
private fun hideSystemUi() {
if (BaseFragment.Companion.DEBUG) {
Log.d(TAG, "hideSystemUi() called")
}
if (activity == null) {
return
}
// Prevent jumping of the player on devices with cutout
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
activity!!.getWindow().getAttributes().layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
}
var visibility: Int = (View.SYSTEM_UI_FLAG_LAYOUT_STABLE
or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY)
// In multiWindow mode status bar is not transparent for devices with cutout
// if I include this flag. So without it is better in this case
val isInMultiWindow: Boolean = DeviceUtils.isInMultiWindow(activity!!)
if (!isInMultiWindow) {
visibility = visibility or View.SYSTEM_UI_FLAG_FULLSCREEN
}
activity!!.getWindow().getDecorView().setSystemUiVisibility(visibility)
if (isInMultiWindow || isFullscreen()) {
activity!!.getWindow().setStatusBarColor(Color.TRANSPARENT)
activity!!.getWindow().setNavigationBarColor(Color.TRANSPARENT)
}
activity!!.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN)
}
// Listener implementation
public override fun hideSystemUiIfNeeded() {
if ((isFullscreen()
&& bottomSheetBehavior!!.getState() == BottomSheetBehavior.STATE_EXPANDED)) {
hideSystemUi()
}
}
private fun isFullscreen(): Boolean {
return isPlayerAvailable() && player!!.UIs().get((VideoPlayerUi::class.java))
.map(Function({ obj: VideoPlayerUi? -> obj!!.isFullscreen() })).orElse(false)
}
private fun playerIsNotStopped(): Boolean {
return isPlayerAvailable() && !player!!.isStopped()
}
private fun restoreDefaultBrightness() {
val lp: WindowManager.LayoutParams = activity!!.getWindow().getAttributes()
if (lp.screenBrightness == -1f) {
return
}
// Restore the old brightness when fragment.onPause() called or
// when a player is in portrait
lp.screenBrightness = -1f
activity!!.getWindow().setAttributes(lp)
}
private fun setupBrightness() {
if (activity == null) {
return
}
val lp: WindowManager.LayoutParams = activity!!.getWindow().getAttributes()
if (!isFullscreen() || bottomSheetState != BottomSheetBehavior.STATE_EXPANDED) {
// Apply system brightness when the player is not in fullscreen
restoreDefaultBrightness()
} else {
// Do not restore if user has disabled brightness gesture
if ((!(PlayerHelper.getActionForRightGestureSide(activity!!)
== getString(R.string.brightness_control_key))
&& !(PlayerHelper.getActionForLeftGestureSide(activity!!)
== getString(R.string.brightness_control_key)))) {
return
}
// Restore already saved brightness level
val brightnessLevel: Float = PlayerHelper.getScreenBrightness(activity!!)
if (brightnessLevel == lp.screenBrightness) {
return
}
lp.screenBrightness = brightnessLevel
activity!!.getWindow().setAttributes(lp)
}
}
/**
* Make changes to the UI to accommodate for better usability on bigger screens such as TVs
* or in Android's desktop mode (DeX etc).
*/
private fun accommodateForTvAndDesktopMode() {
if (DeviceUtils.isTv(getContext())) {
// remove ripple effects from detail controls
val transparent: Int = ContextCompat.getColor(requireContext(),
R.color.transparent_background_color)
binding!!.detailControlsPlaylistAppend.setBackgroundColor(transparent)
binding!!.detailControlsBackground.setBackgroundColor(transparent)
binding!!.detailControlsPopup.setBackgroundColor(transparent)
binding!!.detailControlsDownload.setBackgroundColor(transparent)
binding!!.detailControlsShare.setBackgroundColor(transparent)
binding!!.detailControlsOpenInBrowser.setBackgroundColor(transparent)
binding!!.detailControlsPlayWithKodi.setBackgroundColor(transparent)
}
if (DeviceUtils.isDesktopMode((getContext())!!)) {
// Remove the "hover" overlay (since it is visible on all mouse events and interferes
// with the video content being played)
binding!!.detailThumbnailRootLayout.setForeground(null)
}
}
private fun checkLandscape() {
if (((!player!!.isPlaying() && player!!.getPlayQueue() !== playQueue)
|| player!!.getPlayQueue() == null)) {
setAutoPlay(true)
}
player!!.UIs().get((MainPlayerUi::class.java)).ifPresent(java.util.function.Consumer({ obj: MainPlayerUi? -> obj!!.checkLandscape() }))
// Let's give a user time to look at video information page if video is not playing
if (PlayerHelper.globalScreenOrientationLocked(activity) && !player!!.isPlaying()) {
player!!.play()
}
}
/*
* Means that the player fragment was swiped away via BottomSheetLayout
* and is empty but ready for any new actions. See cleanUp()
* */
private fun wasCleared(): Boolean {
return url == null
}
private fun findQueueInStack(queue: PlayQueue?): StackItem? {
var item: StackItem? = null
val iterator: Iterator<StackItem> = stack.descendingIterator()
while (iterator.hasNext()) {
val next: StackItem = iterator.next()
if (next.getPlayQueue()!!.equalStreams(queue)) {
item = next
break
}
}
return item
}
private fun replaceQueueIfUserConfirms(onAllow: Runnable) {
val activeQueue: PlayQueue? = if (isPlayerAvailable()) player!!.getPlayQueue() else null
// Player will have STATE_IDLE when a user pressed back button
if ((PlayerHelper.isClearingQueueConfirmationRequired((activity)!!)
&& playerIsNotStopped()
&& (activeQueue != null
) && !activeQueue.equalStreams(playQueue))) {
showClearingQueueConfirmation(onAllow)
} else {
onAllow.run()
}
}
private fun showClearingQueueConfirmation(onAllow: Runnable) {
AlertDialog.Builder((activity)!!)
.setTitle(R.string.clear_queue_confirmation_description)
.setNegativeButton(R.string.cancel, null)
.setPositiveButton(R.string.ok, DialogInterface.OnClickListener({ dialog: DialogInterface, which: Int ->
onAllow.run()
dialog.dismiss()
}))
.show()
}
private fun showExternalVideoPlaybackDialog() {
if (currentInfo == null) {
return
}
val builder: AlertDialog.Builder = AlertDialog.Builder((activity)!!)
builder.setTitle(R.string.select_quality_external_players)
builder.setNeutralButton(R.string.open_in_browser, DialogInterface.OnClickListener({ dialog: DialogInterface?, i: Int -> ShareUtils.openUrlInBrowser(requireActivity(), url) }))
val videoStreamsForExternalPlayers: List<VideoStream?> = ListHelper.getSortedStreamVideosList(
(activity)!!,
ListHelper.getUrlAndNonTorrentStreams(currentInfo!!.getVideoStreams()),
ListHelper.getUrlAndNonTorrentStreams(currentInfo!!.getVideoOnlyStreams()),
false,
false
)
if (videoStreamsForExternalPlayers.isEmpty()) {
builder.setMessage(R.string.no_video_streams_available_for_external_players)
builder.setPositiveButton(R.string.ok, null)
} else {
val selectedVideoStreamIndexForExternalPlayers: Int = ListHelper.getDefaultResolutionIndex((activity)!!, videoStreamsForExternalPlayers)
val resolutions: Array<CharSequence> = videoStreamsForExternalPlayers.stream()
.map<String>(Function<VideoStream?, String>({ obj: VideoStream? -> obj!!.getResolution() })).toArray<CharSequence>(IntFunction<Array<CharSequence>>({ _Dummy_.__Array__() }))
builder.setSingleChoiceItems(resolutions, selectedVideoStreamIndexForExternalPlayers,
null)
builder.setNegativeButton(R.string.cancel, null)
builder.setPositiveButton(R.string.ok, DialogInterface.OnClickListener({ dialog: DialogInterface, i: Int ->
val index: Int = (dialog as AlertDialog).getListView().getCheckedItemPosition()
// We don't have to manage the index validity because if there is no stream
// available for external players, this code will be not executed and if there is
// no stream which matches the default resolution, 0 is returned by
// ListHelper.getDefaultResolutionIndex.
// The index cannot be outside the bounds of the list as its always between 0 and
// the list size - 1, .
startOnExternalPlayer((activity)!!, currentInfo!!,
(videoStreamsForExternalPlayers.get(index))!!)
}))
}
builder.show()
}
private fun showExternalAudioPlaybackDialog() {
if (currentInfo == null) {
return
}
val audioStreams: List<AudioStream?> = ListHelper.getUrlAndNonTorrentStreams(
currentInfo!!.getAudioStreams())
val audioTracks: List<AudioStream?>? = ListHelper.getFilteredAudioStreams((activity)!!, audioStreams)
if (audioTracks!!.isEmpty()) {
Toast.makeText(activity, R.string.no_audio_streams_available_for_external_players,
Toast.LENGTH_SHORT).show()
} else if (audioTracks.size == 1) {
startOnExternalPlayer((activity)!!, currentInfo!!, (audioTracks.get(0))!!)
} else {
val selectedAudioStream: Int = ListHelper.getDefaultAudioFormat(activity, audioTracks)
val trackNames: Array<CharSequence> = audioTracks.stream()
.map<String?>(Function<AudioStream?, String?>({ audioStream: AudioStream? -> Localization.audioTrackName((activity)!!, audioStream) }))
.toArray<CharSequence>(IntFunction<Array<CharSequence>>({ _Dummy_.__Array__() }))
AlertDialog.Builder((activity)!!)
.setTitle(R.string.select_audio_track_external_players)
.setNeutralButton(R.string.open_in_browser, DialogInterface.OnClickListener({ dialog: DialogInterface?, i: Int -> ShareUtils.openUrlInBrowser(requireActivity(), url) }))
.setSingleChoiceItems(trackNames, selectedAudioStream, null)
.setNegativeButton(R.string.cancel, null)
.setPositiveButton(R.string.ok, DialogInterface.OnClickListener({ dialog: DialogInterface, i: Int ->
val index: Int = (dialog as AlertDialog).getListView()
.getCheckedItemPosition()
startOnExternalPlayer((activity)!!, currentInfo!!, (audioTracks.get(index))!!)
}))
.show()
}
}
/*
* Remove unneeded information while waiting for a next task
* */
private fun cleanUp() {
// New beginning
stack.clear()
if (currentWorker != null) {
currentWorker!!.dispose()
}
playerHolder!!.stopService()
setInitialData(0, null, "", null)
currentInfo = null
updateOverlayData(null, null, listOf<Image>())
}
/*//////////////////////////////////////////////////////////////////////////
// Bottom mini player
////////////////////////////////////////////////////////////////////////// */
/**
* That's for Android TV support. Move focus from main fragment to the player or back
* based on what is currently selected
*
* @param toMain if true than the main fragment will be focused or the player otherwise
*/
private fun moveFocusToMainFragment(toMain: Boolean) {
setupBrightness()
val mainFragment: ViewGroup = requireActivity().findViewById(R.id.fragment_holder)
// Hamburger button steels a focus even under bottomSheet
val toolbar: Toolbar = requireActivity().findViewById(R.id.toolbar)
val afterDescendants: Int = ViewGroup.FOCUS_AFTER_DESCENDANTS
val blockDescendants: Int = ViewGroup.FOCUS_BLOCK_DESCENDANTS
if (toMain) {
mainFragment.setDescendantFocusability(afterDescendants)
toolbar.setDescendantFocusability(afterDescendants)
(requireView() as ViewGroup).setDescendantFocusability(blockDescendants)
// Only focus the mainFragment if the mainFragment (e.g. search-results)
// or the toolbar (e.g. Textfield for search) don't have focus.
// This was done to fix problems with the keyboard input, see also #7490
if (!mainFragment.hasFocus() && !toolbar.hasFocus()) {
mainFragment.requestFocus()
}
} else {
mainFragment.setDescendantFocusability(blockDescendants)
toolbar.setDescendantFocusability(blockDescendants)
(requireView() as ViewGroup).setDescendantFocusability(afterDescendants)
// Only focus the player if it not already has focus
if (!binding!!.getRoot().hasFocus()) {
binding!!.detailThumbnailRootLayout.requestFocus()
}
}
}
/**
* When the mini player exists the view underneath it is not touchable.
* Bottom padding should be equal to the mini player's height in this case
*
* @param showMore whether main fragment should be expanded or not
*/
private fun manageSpaceAtTheBottom(showMore: Boolean) {
val peekHeight: Int = getResources().getDimensionPixelSize(R.dimen.mini_player_height)
val holder: ViewGroup = requireActivity().findViewById(R.id.fragment_holder)
val newBottomPadding: Int
if (showMore) {
newBottomPadding = 0
} else {
newBottomPadding = peekHeight
}
if (holder.getPaddingBottom() == newBottomPadding) {
return
}
holder.setPadding(holder.getPaddingLeft(),
holder.getPaddingTop(),
holder.getPaddingRight(),
newBottomPadding)
}
private fun setupBottomPlayer() {
val params: CoordinatorLayout.LayoutParams = binding!!.appBarLayout.getLayoutParams() as CoordinatorLayout.LayoutParams
val behavior: AppBarLayout.Behavior? = params.getBehavior() as AppBarLayout.Behavior?
val bottomSheetLayout: FrameLayout = activity!!.findViewById(R.id.fragment_player_holder)
bottomSheetBehavior = BottomSheetBehavior.from(bottomSheetLayout)
bottomSheetBehavior!!.setState(lastStableBottomSheetState)
updateBottomSheetState(lastStableBottomSheetState)
val peekHeight: Int = getResources().getDimensionPixelSize(R.dimen.mini_player_height)
if (bottomSheetState != BottomSheetBehavior.STATE_HIDDEN) {
manageSpaceAtTheBottom(false)
bottomSheetBehavior!!.setPeekHeight(peekHeight)
if (bottomSheetState == BottomSheetBehavior.STATE_COLLAPSED) {
binding!!.overlayLayout.setAlpha(MAX_OVERLAY_ALPHA)
} else if (bottomSheetState == BottomSheetBehavior.STATE_EXPANDED) {
binding!!.overlayLayout.setAlpha(0f)
setOverlayElementsClickable(false)
}
}
bottomSheetCallback = object : BottomSheetCallback() {
public override fun onStateChanged(bottomSheet: View, newState: Int) {
updateBottomSheetState(newState)
when (newState) {
BottomSheetBehavior.STATE_HIDDEN -> {
moveFocusToMainFragment(true)
manageSpaceAtTheBottom(true)
bottomSheetBehavior!!.setPeekHeight(0)
cleanUp()
}
BottomSheetBehavior.STATE_EXPANDED -> {
moveFocusToMainFragment(false)
manageSpaceAtTheBottom(false)
bottomSheetBehavior!!.setPeekHeight(peekHeight)
// Disable click because overlay buttons located on top of buttons
// from the player
setOverlayElementsClickable(false)
hideSystemUiIfNeeded()
// Conditions when the player should be expanded to fullscreen
if ((DeviceUtils.isLandscape(requireContext())
&& isPlayerAvailable()
&& player!!.isPlaying()
&& !isFullscreen()
&& !DeviceUtils.isTablet((activity)!!))) {
player!!.UIs().get((MainPlayerUi::class.java))
.ifPresent(java.util.function.Consumer({ obj: MainPlayerUi? -> obj!!.toggleFullscreen() }))
}
setOverlayLook(binding!!.appBarLayout, behavior, 1f)
}
BottomSheetBehavior.STATE_COLLAPSED -> {
moveFocusToMainFragment(true)
manageSpaceAtTheBottom(false)
bottomSheetBehavior!!.setPeekHeight(peekHeight)
// Re-enable clicks
setOverlayElementsClickable(true)
if (isPlayerAvailable()) {
player!!.UIs().get((MainPlayerUi::class.java))
.ifPresent(java.util.function.Consumer({ obj: MainPlayerUi? -> obj!!.closeItemsList() }))
}
setOverlayLook(binding!!.appBarLayout, behavior, 0f)
}
BottomSheetBehavior.STATE_DRAGGING, BottomSheetBehavior.STATE_SETTLING -> {
if (isFullscreen()) {
showSystemUi()
}
if (isPlayerAvailable()) {
player!!.UIs().get((MainPlayerUi::class.java)).ifPresent(java.util.function.Consumer({ ui: MainPlayerUi? ->
if (ui!!.isControlsVisible()) {
ui.hideControls(0, 0)
}
}))
}
}
BottomSheetBehavior.STATE_HALF_EXPANDED -> {}
}
}
public override fun onSlide(bottomSheet: View, slideOffset: Float) {
setOverlayLook(binding!!.appBarLayout, behavior, slideOffset)
}
}
bottomSheetBehavior!!.addBottomSheetCallback(bottomSheetCallback)
// User opened a new page and the player will hide itself
activity!!.getSupportFragmentManager().addOnBackStackChangedListener(FragmentManager.OnBackStackChangedListener({
if (bottomSheetBehavior!!.getState() == BottomSheetBehavior.STATE_EXPANDED) {
bottomSheetBehavior!!.setState(BottomSheetBehavior.STATE_COLLAPSED)
}
}))
}
private fun updateOverlayPlayQueueButtonVisibility() {
val isPlayQueueEmpty: Boolean = (player == null // no player => no play queue :)
) || (player!!.getPlayQueue() == null
) || player!!.getPlayQueue().isEmpty()
if (binding != null) {
// binding is null when rotating the device...
binding!!.overlayPlayQueueButton.setVisibility(
if (isPlayQueueEmpty) View.GONE else View.VISIBLE)
}
}
private fun updateOverlayData(overlayTitle: String?,
uploader: String?,
thumbnails: List<Image?>) {
binding!!.overlayTitleTextView.setText(if (TextUtils.isEmpty(overlayTitle)) "" else overlayTitle)
binding!!.overlayChannelTextView.setText(if (TextUtils.isEmpty(uploader)) "" else uploader)
binding!!.overlayThumbnail.setImageDrawable(null)
PicassoHelper.loadDetailsThumbnail(thumbnails).tag(PICASSO_VIDEO_DETAILS_TAG)
.into(binding!!.overlayThumbnail)
}
private fun setOverlayPlayPauseImage(playerIsPlaying: Boolean) {
val drawable: Int = if (playerIsPlaying) R.drawable.ic_pause else R.drawable.ic_play_arrow
binding!!.overlayPlayPauseButton.setImageResource(drawable)
}
private fun setOverlayLook(appBar: AppBarLayout,
behavior: AppBarLayout.Behavior?,
slideOffset: Float) {
// SlideOffset < 0 when mini player is about to close via swipe.
// Stop animation in this case
if (behavior == null || slideOffset < 0) {
return
}
binding!!.overlayLayout.setAlpha(min(MAX_OVERLAY_ALPHA.toDouble(), (1 - slideOffset).toDouble()).toFloat())
// These numbers are not special. They just do a cool transition
behavior.setTopAndBottomOffset((-binding!!.detailThumbnailImageView.getHeight() * 2 * (1 - slideOffset) / 3).toInt())
appBar.requestLayout()
}
private fun setOverlayElementsClickable(enable: Boolean) {
binding!!.overlayThumbnail.setClickable(enable)
binding!!.overlayThumbnail.setLongClickable(enable)
binding!!.overlayMetadataLayout.setClickable(enable)
binding!!.overlayMetadataLayout.setLongClickable(enable)
binding!!.overlayButtonsLayout.setClickable(enable)
binding!!.overlayPlayQueueButton.setClickable(enable)
binding!!.overlayPlayPauseButton.setClickable(enable)
binding!!.overlayCloseButton.setClickable(enable)
}
// helpers to check the state of player and playerService
fun isPlayerAvailable(): Boolean {
return player != null
}
fun isPlayerServiceAvailable(): Boolean {
return playerService != null
}
fun isPlayerAndPlayerServiceAvailable(): Boolean {
return player != null && playerService != null
}
fun getRoot(): Optional<View> {
return Optional.ofNullable(player)
.flatMap(Function<Player, Optional<out VideoPlayerUi?>?>({ player1: Player -> player1.UIs().get(VideoPlayerUi::class.java) }))
.map(Function<VideoPlayerUi?, View>({ playerUi: VideoPlayerUi? -> playerUi.getBinding().getRoot() }))
}
private fun updateBottomSheetState(newState: Int) {
bottomSheetState = newState
if ((newState != BottomSheetBehavior.STATE_DRAGGING
&& newState != BottomSheetBehavior.STATE_SETTLING)) {
lastStableBottomSheetState = newState
}
}
companion object {
val KEY_SWITCHING_PLAYERS: String = "switching_players"
private val MAX_OVERLAY_ALPHA: Float = 0.9f
private val MAX_PLAYER_HEIGHT: Float = 0.7f
val ACTION_SHOW_MAIN_PLAYER: String = App.Companion.PACKAGE_NAME + ".VideoDetailFragment.ACTION_SHOW_MAIN_PLAYER"
val ACTION_HIDE_MAIN_PLAYER: String = App.Companion.PACKAGE_NAME + ".VideoDetailFragment.ACTION_HIDE_MAIN_PLAYER"
val ACTION_PLAYER_STARTED: String = App.Companion.PACKAGE_NAME + ".VideoDetailFragment.ACTION_PLAYER_STARTED"
val ACTION_VIDEO_FRAGMENT_RESUMED: String = App.Companion.PACKAGE_NAME + ".VideoDetailFragment.ACTION_VIDEO_FRAGMENT_RESUMED"
val ACTION_VIDEO_FRAGMENT_STOPPED: String = App.Companion.PACKAGE_NAME + ".VideoDetailFragment.ACTION_VIDEO_FRAGMENT_STOPPED"
private val COMMENTS_TAB_TAG: String = "COMMENTS"
private val RELATED_TAB_TAG: String = "NEXT VIDEO"
private val DESCRIPTION_TAB_TAG: String = "DESCRIPTION TAB"
private val EMPTY_TAB_TAG: String = "EMPTY TAB"
private val PICASSO_VIDEO_DETAILS_TAG: String = "PICASSO_VIDEO_DETAILS_TAG"
/*//////////////////////////////////////////////////////////////////////// */
fun getInstance(serviceId: Int,
videoUrl: String?,
name: String,
queue: PlayQueue?): VideoDetailFragment {
val instance: VideoDetailFragment = VideoDetailFragment()
instance.setInitialData(serviceId, videoUrl, name, queue)
return instance
}
fun getInstanceInCollapsedState(): VideoDetailFragment {
val instance: VideoDetailFragment = VideoDetailFragment()
instance.updateBottomSheetState(BottomSheetBehavior.STATE_COLLAPSED)
return instance
}
/*//////////////////////////////////////////////////////////////////////////
// OwnStack
////////////////////////////////////////////////////////////////////////// */
/**
* Stack that contains the "navigation history".<br></br>
* The peek is the current video.
*/
private var stack: LinkedList<StackItem> = LinkedList()
}
}