diff --git a/app/src/main/java/com/github/libretube/ui/fragments/PlayerFragment.kt b/app/src/main/java/com/github/libretube/ui/fragments/PlayerFragment.kt index 2845a5dc8..76c26a25b 100644 --- a/app/src/main/java/com/github/libretube/ui/fragments/PlayerFragment.kt +++ b/app/src/main/java/com/github/libretube/ui/fragments/PlayerFragment.kt @@ -17,12 +17,14 @@ import android.os.Handler import android.os.Looper import android.os.PowerManager import android.text.format.DateUtils +import android.text.method.LinkMovementMethod import android.text.util.Linkify import android.util.Base64 import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.TextView import android.widget.Toast import androidx.annotation.RequiresApi import androidx.constraintlayout.motion.widget.MotionLayout @@ -82,17 +84,7 @@ import com.github.libretube.ui.models.PlayerViewModel import com.github.libretube.ui.sheets.BaseBottomSheet import com.github.libretube.ui.sheets.CommentsSheet import com.github.libretube.ui.sheets.PlayingQueueSheet -import com.github.libretube.util.BackgroundHelper -import com.github.libretube.util.DashHelper -import com.github.libretube.util.DataSaverMode -import com.github.libretube.util.ImageHelper -import com.github.libretube.util.NavigationHelper -import com.github.libretube.util.NowPlayingNotification -import com.github.libretube.util.PlayerHelper -import com.github.libretube.util.PlayingQueue -import com.github.libretube.util.PreferenceHelper -import com.github.libretube.util.SeekbarPreviewListener -import com.github.libretube.util.TextUtils +import com.github.libretube.util.* import com.google.android.exoplayer2.C import com.google.android.exoplayer2.DefaultLoadControl import com.google.android.exoplayer2.ExoPlayer @@ -859,7 +851,7 @@ class PlayerFragment : BaseFragment(), OnlinePlayerOptions { binding.apply { playerViewsInfo.text = context?.getString(R.string.views, streams.views.formatShort()) + - if (!isLive) TextUtils.SEPARATOR + localizedDate(streams.uploadDate) else "" + if (!isLive) TextUtils.SEPARATOR + localizedDate(streams.uploadDate) else "" textLike.text = streams.likes.formatShort() textDislike.text = streams.dislikes.formatShort() @@ -926,9 +918,9 @@ class PlayerFragment : BaseFragment(), OnlinePlayerOptions { override fun onPlaybackStateChanged(playbackState: Int) { exoPlayerView.keepScreenOn = !( - playbackState == Player.STATE_IDLE || - playbackState == Player.STATE_ENDED - ) + playbackState == Player.STATE_IDLE || + playbackState == Player.STATE_ENDED + ) // save the watch position to the database // only called when the position is unequal to 0, otherwise it would become reset @@ -1000,14 +992,7 @@ class PlayerFragment : BaseFragment(), OnlinePlayerOptions { // set video description val description = streams.description!! - // detect whether the description is html formatted - 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 - } + setupDescription(binding.playerDescription, description, exoPlayer) binding.playerChannel.setOnClickListener { val activity = view?.context as MainActivity @@ -1041,6 +1026,116 @@ class PlayerFragment : BaseFragment(), OnlinePlayerOptions { } } + private fun setupDescription(descTextView: TextView, description: String, exoPlayer: ExoPlayer) { + // detect whether the description is html formatted + if (description.contains("<") && description.contains(">")) { + descTextView.movementMethod = LinkMovementMethod.getInstance() + descTextView.text = HtmlParser.createSpannedText( + description, + LinkHandler { link -> + // check if the link is a youtube link + if (link.contains("youtube.com") || link.contains("youtu.be")) { + // 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 { + // Links can be present as plain text + descTextView.autoLinkMask = Linkify.WEB_URLS + descTextView.text = description + } + } + + private fun getVideoIdIfVideoLink(link: String): String? { + if (link.contains("youtube.com")) { + // check if the link is a channel link + val videoId = link.substringAfter("v=").substringBefore("&") + if (videoId.isNotEmpty() && videoId != link) { + return videoId + } + } else if (link.contains("youtu.be")) { + val videoId = link.substringAfter("be/").substringBefore("&") + if (videoId.isNotEmpty() && videoId != link) { + return videoId + } + } + return null + } + + private fun getTimeInMillis(time: String): Long { + var timeInSeconds = 0L + var found = false + // check if the time is in seconds + if (time.contains("s")) { + 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")) { + val longOrNull = time.substringBefore("h").substringAfter("m").toLongOrNull() + if (longOrNull != null) { + timeInSeconds += longOrNull * 60 * 60 + found = true + } + } + + if (!found) { + val longOrNull = time.toLongOrNull() + if (longOrNull != null) { + timeInSeconds += longOrNull + found = true + } + } + + return if (found) timeInSeconds * 1000 else -1 + } + /** * Update the displayed duration of the video */ diff --git a/app/src/main/java/com/github/libretube/util/HtmlParser.kt b/app/src/main/java/com/github/libretube/util/HtmlParser.kt new file mode 100644 index 000000000..2a0d0d9a9 --- /dev/null +++ b/app/src/main/java/com/github/libretube/util/HtmlParser.kt @@ -0,0 +1,104 @@ +package com.github.libretube.util + +import android.text.Editable +import android.text.Html +import android.text.Spanned +import androidx.core.text.HtmlCompat +import org.xml.sax.* +import java.util.* + +class HtmlParser private constructor(private val handler: TagHandler) : Html.TagHandler, ContentHandler { + private val tagStatus = ArrayDeque() + 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 + + // handle endElement() callback for tag + tagStatus.addLast(java.lang.Boolean.FALSE) + } + } + + @Throws(SAXException::class) 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) + } + } + + @Throws(SAXException::class) 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) + } + + @Throws(SAXException::class) override fun startDocument() { + wrapped?.startDocument() + } + + @Throws(SAXException::class) override fun endDocument() { + wrapped?.endDocument() + } + + @Throws(SAXException::class) override fun startPrefixMapping(prefix: String, uri: String) { + wrapped?.startPrefixMapping(prefix, uri) + } + + @Throws(SAXException::class) override fun endPrefixMapping(prefix: String) { + wrapped?.endPrefixMapping(prefix) + } + + @Throws(SAXException::class) override fun characters(ch: CharArray, start: Int, length: Int) { + wrapped?.characters(ch, start, length) + } + + @Throws(SAXException::class) override fun ignorableWhitespace(ch: CharArray, start: Int, length: Int) { + wrapped?.ignorableWhitespace(ch, start, length) + } + + @Throws(SAXException::class) override fun processingInstruction(target: String, data: String) { + wrapped?.processingInstruction(target, data) + } + + @Throws(SAXException::class) override fun skippedEntity(name: String) { + 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 + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/libretube/util/LinkHandler.kt b/app/src/main/java/com/github/libretube/util/LinkHandler.kt new file mode 100644 index 000000000..ec5939aea --- /dev/null +++ b/app/src/main/java/com/github/libretube/util/LinkHandler.kt @@ -0,0 +1,45 @@ +package com.github.libretube.util + +import android.text.Editable +import android.text.Spanned +import android.text.TextPaint +import android.text.style.ClickableSpan +import android.util.Log +import android.view.View +import com.github.libretube.util.HtmlParser.Companion.getValue +import org.xml.sax.Attributes + +class LinkHandler(private val clickCallback: ((String) -> Unit)?) : HtmlParser.TagHandler { + private var linkTagStartIndex = -1 + private var link: String? = null + override fun handleTag(opening: Boolean, tag: String?, output: Editable?, attributes: Attributes?): Boolean { + if (output != null) { + if ("a" == tag) { + if (opening && attributes != null) { + linkTagStartIndex = output.length + link = getValue(attributes, "href") + } else { + val refTagEndIndex = output.length + setLinkSpans(output, linkTagStartIndex, refTagEndIndex, link) + } + return true + } + } + return false + } + + private fun setLinkSpans(output: Editable, start: Int, end: Int, link: String?) { + output.setSpan(object : ClickableSpan() { + override fun onClick(widget: View) { + if (clickCallback != null && link != null) { + clickCallback.invoke(link) + } + } + + override fun updateDrawState(ds: TextPaint) { + super.updateDrawState(ds) + ds.isUnderlineText = false + } + }, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } +} \ No newline at end of file