Fixed #2670 : Timestamp click behaviour in the description.

This commit is contained in:
faisalcodes 2023-01-18 21:43:22 +05:30
parent b805e0e375
commit 7006693485
3 changed files with 101 additions and 136 deletions

View File

@ -31,6 +31,7 @@ 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
@ -1002,7 +1003,7 @@ class PlayerFragment : BaseFragment(), OnlinePlayerOptions {
// set video description // set video description
val description = streams.description!! val description = streams.description!!
setupDescription(binding.playerDescription, description, exoPlayer) setupDescription(binding.playerDescription, description)
binding.playerChannel.setOnClickListener { binding.playerChannel.setOnClickListener {
val activity = view?.context as MainActivity val activity = view?.context as MainActivity
@ -1036,56 +1037,21 @@ class PlayerFragment : BaseFragment(), OnlinePlayerOptions {
} }
} }
/**
* Set up the description text with video links and timestamps
*/
private fun setupDescription( private fun setupDescription(
descTextView: TextView, descTextView: TextView,
description: String, description: String
exoPlayer: ExoPlayer
) { ) {
// detect whether the description is html formatted // detect whether the description is html formatted
if (description.contains("<") && description.contains(">")) { if (description.contains("<") && description.contains(">")) {
descTextView.movementMethod = LinkMovementMethod.getInstance() descTextView.movementMethod = LinkMovementMethod.getInstance()
descTextView.text = HtmlParser.createSpannedText( descTextView.text = HtmlCompat.fromHtml(
description, description,
LinkHandler { link -> HtmlCompat.FROM_HTML_MODE_LEGACY,
// check if the link is a youtube link null,
if (link.contains("youtube.com") || link.contains("youtu.be")) { HtmlParser(LinkHandler { link -> handleLink(link) })
// check if the link is a video link
val videoId = getVideoIdIfVideoLink(link)
if (!videoId.isNullOrEmpty()) {
// check if the video is the current video
if (videoId == this.videoId) {
// get the time from the link
val time = link.substringAfter("t=").substringBefore("&")
// check if the time is valid
if (time.isNotEmpty() && time != link) {
// get the time in seconds
val timeInSeconds = getTimeInMillis(time)
if (timeInSeconds != -1L) {
// seek to the time
exoPlayer.seekTo(timeInSeconds)
}
}
// youtube link without time
// open new player
playNextVideo(videoId)
} else {
// not the current video
// open new player
playNextVideo(videoId)
}
} else {
// not a video link, might be channel or playlist link
// handle normally
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link))
startActivity(intent)
}
} else {
// not a youtube link
// handle normally
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link))
startActivity(intent)
}
}
) )
} else { } else {
// Links can be present as plain text // Links can be present as plain text
@ -1094,54 +1060,92 @@ class PlayerFragment : BaseFragment(), OnlinePlayerOptions {
} }
} }
private fun getVideoIdIfVideoLink(link: String): String? { /**
* 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 = getVideoIdIfVideoLink(link, uri)
if (!videoId.isNullOrEmpty()) {
// check if the video is the current video and has a valid time
if (videoId == this.videoId) {
val timeInMillis = getTimeInMillis(uri)
if (timeInMillis != -1L) {
// seek to the time
exoPlayer.seekTo(timeInMillis)
}
// else do nothing as the video is already playing
} else {
// youtube video link without time or not the current video
// open new player
playNextVideo(videoId)
}
} else {
// not a youtube video link
// handle normally
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link))
startActivity(intent)
}
}
/**
* Get video id if the link is a valid youtube video link
*/
private fun getVideoIdIfVideoLink(link: String, uri: Uri): String? {
if (link.contains("youtube.com")) { if (link.contains("youtube.com")) {
// check if the link is a channel link // the link may be in an unsupported format, so we should try/catch it
val videoId = link.substringAfter("v=").substringBefore("&") return try {
if (videoId.isNotEmpty() && videoId != link) { uri.getQueryParameter("v")
return videoId } catch (e: Exception) {
null
} }
} else if (link.contains("youtu.be")) { } else if (link.contains("youtu.be")) {
val videoId = link.substringAfter("be/").substringBefore("&") return uri.lastPathSegment
if (videoId.isNotEmpty() && videoId != link) {
return videoId
}
} }
return null return null
} }
private fun getTimeInMillis(time: String): Long { /**
* Get time in milliseconds from a youtube video link
*/
private fun getTimeInMillis(uri: Uri): Long {
var time = uri.getQueryParameter("t") ?: return -1L
var timeInSeconds = 0L var timeInSeconds = 0L
var found = false var found = false
// check if the time is in seconds
if (time.contains("s")) { // Check if the time has hours
val longOrNull = time.substringBefore("s").toLongOrNull()
if (longOrNull != null) {
timeInSeconds += longOrNull
found = true
}
}
// check if the time is in minutes
if (time.contains("m")) {
val longOrNull = time.substringBefore("m").substringAfter("s").toLongOrNull()
if (longOrNull != null) {
timeInSeconds += longOrNull * 60
found = true
}
}
// check if the time is in hours
if (time.contains("h")) { if (time.contains("h")) {
val longOrNull = time.substringBefore("h").substringAfter("m").toLongOrNull() time.substringBefore("h").toLongOrNull()?.let {
if (longOrNull != null) { timeInSeconds += it * 60 * 60
timeInSeconds += longOrNull * 60 * 60 time = time.substringAfter("h")
found = true found = true
} }
} }
// Check if the time has minutes
if (time.contains("m")) {
time.substringBefore("m").toLongOrNull()?.let {
timeInSeconds += it * 60
time = time.substringAfter("m")
found = true
}
}
// Check if the time has seconds
if (time.contains("s")) {
time.substringBefore("s").toLongOrNull()?.let {
timeInSeconds += it
found = true
}
}
// Time may not contain h, m or s. In that case, it is just a number of seconds
if (!found) { if (!found) {
val longOrNull = time.toLongOrNull() time.toLongOrNull()?.let {
if (longOrNull != null) { timeInSeconds += it
timeInSeconds += longOrNull
found = true found = true
} }
} }

View File

@ -2,17 +2,13 @@ package com.github.libretube.util
import android.text.Editable import android.text.Editable
import android.text.Html import android.text.Html
import android.text.Spanned
import androidx.core.text.HtmlCompat
import org.xml.sax.Attributes import org.xml.sax.Attributes
import org.xml.sax.ContentHandler import org.xml.sax.ContentHandler
import org.xml.sax.Locator import org.xml.sax.Locator
import org.xml.sax.SAXException import org.xml.sax.SAXException
import org.xml.sax.XMLReader import org.xml.sax.XMLReader
class HtmlParser private constructor(private val handler: TagHandler) : class HtmlParser constructor(private val handler: LinkHandler) : Html.TagHandler, ContentHandler {
Html.TagHandler,
ContentHandler {
private val tagStatus = ArrayDeque<Boolean>() private val tagStatus = ArrayDeque<Boolean>()
private var wrapped: ContentHandler? = null private var wrapped: ContentHandler? = null
private var text: Editable? = null private var text: Editable? = null
@ -20,15 +16,12 @@ class HtmlParser private constructor(private val handler: TagHandler) :
if (wrapped == null) { if (wrapped == null) {
// record result object // record result object
text = output text = output
// record current content handler // record current content handler
wrapped = xmlReader.contentHandler wrapped = xmlReader.contentHandler
// replace content handler with our own that forwards to calls to original when needed // replace content handler with our own that forwards to calls to original when needed
xmlReader.contentHandler = this xmlReader.contentHandler = this
// add false to the stack to make sure we always have a tag to pop
// handle endElement() callback for <inject/> tag tagStatus.addLast(false)
tagStatus.addLast(java.lang.Boolean.FALSE)
} }
} }
@ -41,6 +34,7 @@ class HtmlParser private constructor(private val handler: TagHandler) :
) { ) {
val isHandled = handler.handleTag(true, localName, text, attributes) val isHandled = handler.handleTag(true, localName, text, attributes)
tagStatus.addLast(isHandled) tagStatus.addLast(isHandled)
if (!isHandled) { if (!isHandled) {
wrapped?.startElement(uri, localName, qName, attributes) wrapped?.startElement(uri, localName, qName, attributes)
} }
@ -97,38 +91,4 @@ class HtmlParser private constructor(private val handler: TagHandler) :
override fun skippedEntity(name: String) { override fun skippedEntity(name: String) {
wrapped?.skippedEntity(name) wrapped?.skippedEntity(name)
} }
interface TagHandler {
fun handleTag(
opening: Boolean,
tag: String?,
output: Editable?,
attributes: Attributes?
): Boolean
}
companion object {
fun createSpannedText(html: String, handler: TagHandler): Spanned {
// add a tag at the start that is not handled by default,
// allowing custom tag handler to replace xmlReader contentHandler
return HtmlCompat.fromHtml(
html,
HtmlCompat.FROM_HTML_MODE_LEGACY,
null,
HtmlParser(handler)
)
}
@JvmStatic fun getValue(attributes: Attributes, name: String): String? {
var i = 0
val n = attributes.length
while (i < n) {
if (name.equals(attributes.getLocalName(i), ignoreCase = true)) {
return attributes.getValue(i)
}
i++
}
return null
}
}
} }

View File

@ -5,31 +5,32 @@ import android.text.Spanned
import android.text.TextPaint import android.text.TextPaint
import android.text.style.ClickableSpan import android.text.style.ClickableSpan
import android.view.View import android.view.View
import com.github.libretube.util.HtmlParser.Companion.getValue
import org.xml.sax.Attributes import org.xml.sax.Attributes
class LinkHandler(private val clickCallback: ((String) -> Unit)?) : HtmlParser.TagHandler { class LinkHandler(private val clickCallback: ((String) -> Unit)?) {
private var linkTagStartIndex = -1 private var linkTagStartIndex = -1
private var link: String? = null private var link: String? = null
override fun handleTag( fun handleTag(
opening: Boolean, opening: Boolean,
tag: String?, tag: String?,
output: Editable?, output: Editable?,
attributes: Attributes? attributes: Attributes?
): Boolean { ): Boolean {
if (output != null) { // if the tag is not an anchor link, ignore for the default handler
if ("a" == tag) { if (output == null || "a" != tag) {
if (opening && attributes != null) { return false
linkTagStartIndex = output.length
link = getValue(attributes, "href")
} else {
val refTagEndIndex = output.length
setLinkSpans(output, linkTagStartIndex, refTagEndIndex, link)
}
return true
}
} }
return false
if (opening) {
if (attributes != null) {
linkTagStartIndex = output.length
link = attributes.getValue("href")
}
} else {
val refTagEndIndex = output.length
setLinkSpans(output, linkTagStartIndex, refTagEndIndex, link)
}
return true
} }
private fun setLinkSpans(output: Editable, start: Int, end: Int, link: String?) { private fun setLinkSpans(output: Editable, start: Int, end: Int, link: String?) {