mirror of
https://github.com/libre-tube/LibreTube.git
synced 2025-04-29 00:10:32 +05:30
Fixed #2670 : Timestamp click behaviour in the description.
This commit is contained in:
parent
b805e0e375
commit
7006693485
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -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?) {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user