Merge pull request #2717 from faisalcodes/master

Fixed #2670 : Timestamp click behaviour in the description.
This commit is contained in:
Bnyro 2023-01-19 17:21:54 +01:00 committed by GitHub
commit fe9bb97c96
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 251 additions and 18 deletions

View File

@ -72,6 +72,7 @@
android:name=".ui.activities.MainActivity" android:name=".ui.activities.MainActivity"
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation" android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation"
android:exported="true" android:exported="true"
android:launchMode="singleTask"
android:screenOrientation="user" android:screenOrientation="user"
android:supportsPictureInPicture="true" android:supportsPictureInPicture="true"
android:windowSoftInputMode="adjustPan"> android:windowSoftInputMode="adjustPan">

View File

@ -189,16 +189,16 @@ class MainActivity : BaseActivity() {
if (viewGroup == null || viewGroup.childCount == 0) return if (viewGroup == null || viewGroup.childCount == 0) return
viewGroup.children.forEach { viewGroup.children.forEach {
(it as? ScrollView)?.let { (it as? ScrollView)?.let { scrollView ->
it.smoothScrollTo(0, 0) scrollView.smoothScrollTo(0, 0)
return return
} }
(it as? NestedScrollView)?.let { (it as? NestedScrollView)?.let { scrollView ->
it.smoothScrollTo(0, 0) scrollView.smoothScrollTo(0, 0)
return return
} }
(it as? RecyclerView)?.let { (it as? RecyclerView)?.let { recyclerView ->
it.smoothScrollToPosition(0) recyclerView.smoothScrollToPosition(0)
return return
} }
tryScrollToTop(it as? ViewGroup) tryScrollToTop(it as? ViewGroup)
@ -373,9 +373,9 @@ class MainActivity : BaseActivity() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N &&
isInPictureInPictureMode isInPictureInPictureMode
) { ) {
moveTaskToBack(true) val nIntent = Intent(this, MainActivity::class.java)
intent?.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT) nIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
startActivity(intent) startActivity(nIntent)
} }
if (intent?.getBooleanExtra(IntentData.openAudioPlayer, false) == true) { if (intent?.getBooleanExtra(IntentData.openAudioPlayer, false) == true) {

View File

@ -17,18 +17,21 @@ import android.os.Handler
import android.os.Looper import android.os.Looper
import android.os.PowerManager import android.os.PowerManager
import android.text.format.DateUtils import android.text.format.DateUtils
import android.text.method.LinkMovementMethod
import android.text.util.Linkify import android.text.util.Linkify
import android.util.Base64 import android.util.Base64
import android.util.Log import android.util.Log
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.constraintlayout.motion.widget.MotionLayout import androidx.constraintlayout.motion.widget.MotionLayout
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.core.os.ConfigurationCompat import androidx.core.os.ConfigurationCompat
import androidx.core.os.bundleOf import androidx.core.os.bundleOf
import androidx.core.text.HtmlCompat
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
@ -75,7 +78,6 @@ import com.github.libretube.ui.dialogs.AddToPlaylistDialog
import com.github.libretube.ui.dialogs.DownloadDialog import com.github.libretube.ui.dialogs.DownloadDialog
import com.github.libretube.ui.dialogs.ShareDialog import com.github.libretube.ui.dialogs.ShareDialog
import com.github.libretube.ui.extensions.setAspectRatio import com.github.libretube.ui.extensions.setAspectRatio
import com.github.libretube.ui.extensions.setFormattedHtml
import com.github.libretube.ui.extensions.setupSubscriptionButton import com.github.libretube.ui.extensions.setupSubscriptionButton
import com.github.libretube.ui.interfaces.OnlinePlayerOptions import com.github.libretube.ui.interfaces.OnlinePlayerOptions
import com.github.libretube.ui.models.CommentsViewModel import com.github.libretube.ui.models.CommentsViewModel
@ -86,7 +88,9 @@ import com.github.libretube.ui.sheets.PlayingQueueSheet
import com.github.libretube.util.BackgroundHelper import com.github.libretube.util.BackgroundHelper
import com.github.libretube.util.DashHelper import com.github.libretube.util.DashHelper
import com.github.libretube.util.DataSaverMode import com.github.libretube.util.DataSaverMode
import com.github.libretube.util.HtmlParser
import com.github.libretube.util.ImageHelper import com.github.libretube.util.ImageHelper
import com.github.libretube.util.LinkHandler
import com.github.libretube.util.NavigationHelper import com.github.libretube.util.NavigationHelper
import com.github.libretube.util.NowPlayingNotification import com.github.libretube.util.NowPlayingNotification
import com.github.libretube.util.PlayerHelper import com.github.libretube.util.PlayerHelper
@ -997,14 +1001,7 @@ class PlayerFragment : BaseFragment(), OnlinePlayerOptions {
// set video description // set video description
val description = streams.description!! val description = streams.description!!
// detect whether the description is html formatted setupDescription(binding.playerDescription, description)
if (description.contains("<") && description.contains(">")) {
binding.playerDescription.setFormattedHtml(description)
} else {
// Links can be present as plain text
binding.playerDescription.autoLinkMask = Linkify.WEB_URLS
binding.playerDescription.text = description
}
binding.playerChannel.setOnClickListener { binding.playerChannel.setOnClickListener {
val activity = view?.context as MainActivity val activity = view?.context as MainActivity
@ -1038,6 +1035,53 @@ class PlayerFragment : BaseFragment(), OnlinePlayerOptions {
} }
} }
/**
* Set up the description text with video links and timestamps
*/
private fun setupDescription(
descTextView: TextView,
description: String
) {
// detect whether the description is html formatted
if (description.contains("<") && description.contains(">")) {
descTextView.movementMethod = LinkMovementMethod.getInstance()
descTextView.text = HtmlCompat.fromHtml(
description,
HtmlCompat.FROM_HTML_MODE_LEGACY,
null,
HtmlParser(LinkHandler { link -> handleLink(link) })
)
} else {
// Links can be present as plain text
descTextView.autoLinkMask = Linkify.WEB_URLS
descTextView.text = description
}
}
/**
* Handle a link clicked in the description
*/
private fun handleLink(link: String) {
val uri = Uri.parse(link)
// get video id if the link is a valid youtube video link
val videoId = TextUtils.getVideoIdFromUri(link)
if (videoId.isNullOrEmpty()) {
// not a youtube video link, thus handle normally
val intent = Intent(Intent.ACTION_VIEW, uri)
startActivity(intent)
}
// 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
TextUtils.getTimeInSeconds(uri)?.let {
exoPlayer.seekTo(it * 1000)
}
} else {
// youtube video link without time or not the current video, thus open new player
playNextVideo(videoId)
}
}
/** /**
* Update the displayed duration of the video * Update the displayed duration of the video
*/ */

View File

@ -0,0 +1,85 @@
package com.github.libretube.util
import android.text.Editable
import android.text.Html
import org.xml.sax.Attributes
import org.xml.sax.ContentHandler
import org.xml.sax.Locator
import org.xml.sax.XMLReader
class HtmlParser(
private val handler: LinkHandler
) : Html.TagHandler, ContentHandler {
private val tagStatus = ArrayDeque<Boolean>()
private var wrapped: ContentHandler? = null
private var text: Editable? = null
override fun handleTag(opening: Boolean, tag: String, output: Editable, xmlReader: XMLReader) {
if (wrapped == null) {
// record result object
text = output
// record current content handler
wrapped = xmlReader.contentHandler
// replace content handler with our own that forwards to calls to original when needed
xmlReader.contentHandler = this
// add false to the stack to make sure we always have a tag to pop
tagStatus.addLast(false)
}
}
override fun startElement(
uri: String,
localName: String,
qName: String,
attributes: Attributes
) {
val isHandled = handler.handleTag(true, localName, text, attributes)
tagStatus.addLast(isHandled)
if (!isHandled) {
wrapped?.startElement(uri, localName, qName, attributes)
}
}
override fun endElement(uri: String, localName: String, qName: String) {
if (!tagStatus.removeLast()) {
wrapped?.endElement(uri, localName, qName)
}
handler.handleTag(false, localName, text, null)
}
override fun setDocumentLocator(locator: Locator) {
wrapped?.setDocumentLocator(locator)
}
override fun startDocument() {
wrapped?.startDocument()
}
override fun endDocument() {
wrapped?.endDocument()
}
override fun startPrefixMapping(prefix: String, uri: String) {
wrapped?.startPrefixMapping(prefix, uri)
}
override fun endPrefixMapping(prefix: String) {
wrapped?.endPrefixMapping(prefix)
}
override fun characters(ch: CharArray, start: Int, length: Int) {
wrapped?.characters(ch, start, length)
}
override fun ignorableWhitespace(ch: CharArray, start: Int, length: Int) {
wrapped?.ignorableWhitespace(ch, start, length)
}
override fun processingInstruction(target: String, data: String) {
wrapped?.processingInstruction(target, data)
}
override fun skippedEntity(name: String) {
wrapped?.skippedEntity(name)
}
}

View File

@ -0,0 +1,54 @@
package com.github.libretube.util
import android.text.Editable
import android.text.Spanned
import android.text.TextPaint
import android.text.style.ClickableSpan
import android.view.View
import org.xml.sax.Attributes
class LinkHandler(
private val clickCallback: ((String) -> Unit)?
) {
private var linkTagStartIndex = -1
private var link: String? = null
fun handleTag(
opening: Boolean,
tag: String?,
output: Editable?,
attributes: Attributes?
): Boolean {
// if the tag is not an anchor link, ignore for the default handler
if (output == null || tag != "a") {
return false
}
if (opening && attributes != null) {
linkTagStartIndex = output.length
link = attributes.getValue("href")
} else if (!opening && linkTagStartIndex >= 0 && link != null) {
setLinkSpans(output, linkTagStartIndex, output.length, link!!)
linkTagStartIndex = -1
link = null
}
return true
}
private fun setLinkSpans(output: Editable, start: Int, end: Int, link: String) {
output.setSpan(
object : ClickableSpan() {
override fun onClick(widget: View) {
clickCallback?.invoke(link)
}
override fun updateDrawState(ds: TextPaint) {
super.updateDrawState(ds)
ds.isUnderlineText = false
}
},
start,
end,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
}

View File

@ -1,5 +1,6 @@
package com.github.libretube.util package com.github.libretube.util
import android.net.Uri
import java.net.URL import java.net.URL
import java.time.LocalDate import java.time.LocalDate
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
@ -52,4 +53,52 @@ object TextUtils {
val formatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale) val formatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
return dateObj.format(formatter) return dateObj.format(formatter)
} }
/**
* Get time in seconds from a youtube video link
*/
fun getTimeInSeconds(uri: Uri): Long? {
var time = uri.getQueryParameter("t") ?: return -1L
var timeInSeconds: Long? = null
// Find all spans containing hours, minutes or seconds
listOf(Pair("h", 60 * 60), Pair("m", 60), Pair("s", 1)).forEach { (separator, timeFactor) ->
if (time.contains(separator)) {
time.substringBefore(separator).toLongOrNull()?.let {
timeInSeconds = (timeInSeconds ?: 0L) + it * timeFactor
time = time.substringAfter(separator)
}
}
}
// Time may not contain h, m or s. In that case, it is just a number of seconds
if (timeInSeconds == null) {
time.toLongOrNull()?.let {
timeInSeconds = it
}
}
return timeInSeconds
}
/**
* Get video id if the link is a valid youtube video link
*/
fun getVideoIdFromUri(link: String): String? {
val uri = Uri.parse(link)
if (link.contains("youtube.com")) {
// the link may be in an unsupported format, so we should try/catch it
return try {
uri.getQueryParameter("v")
} catch (e: Exception) {
null
}
} else if (link.contains("youtu.be")) {
return uri.lastPathSegment
}
return null
}
} }