diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index ee4c978f2be..8ef3aeeeb25 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -8,6 +8,7 @@
+
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt
index e5a460b9a02..3fd1ddea0be 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt
@@ -2,6 +2,7 @@ package com.lagradost.cloudstream3.ui.player
import android.os.Bundle
import android.view.View
+import android.widget.FrameLayout
import android.widget.ImageView
import androidx.annotation.OptIn
import androidx.annotation.StringRes
@@ -49,6 +50,7 @@ abstract class AbstractPlayerFragment(
}
val subView: SubtitleView? get() = playerHostView?.subView
+ val subtitleHolder: FrameLayout? get() = playerHostView?.subtitleHolder
val playerPausePlay: ImageView? get() = playerHostView?.playerPausePlay
/** The underlying [androidx.media3.ui.PlayerView] widget (named to avoid conflict with our [PlayerView]). */
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt
index 9c5685c682b..6803694ac51 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt
@@ -65,9 +65,12 @@ import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.source.MergingMediaSource
import androidx.media3.exoplayer.source.SingleSampleMediaSource
+import androidx.media3.exoplayer.source.TrackGroupArray
import androidx.media3.exoplayer.text.TextOutput
import androidx.media3.exoplayer.text.TextRenderer
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
+import androidx.media3.exoplayer.trackselection.ExoTrackSelection
+import androidx.media3.exoplayer.trackselection.MappingTrackSelector
import androidx.media3.exoplayer.trackselection.TrackSelector
import androidx.media3.extractor.mp4.FragmentedMp4Extractor
import androidx.media3.ui.SubtitleView
@@ -111,11 +114,23 @@ import com.lagradost.cloudstream3.utils.videoskip.VideoSkipStamp
import com.lagradost.cloudstream4.AppSettings
import kotlinx.coroutines.delay
import okhttp3.Interceptor
+import okhttp3.Request
import org.chromium.net.CronetEngine
import java.io.File
import java.security.SecureRandom
import java.util.UUID
+import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
+import java.util.concurrent.Future
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicLong
+import androidx.media3.common.text.Cue
+import com.lagradost.cloudstream3.ui.player.CustomDecoder.Companion.fixSubtitleAlignment
+import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment.Companion.applyStyle
+import com.lagradost.cloudstream3.CommonActivity.showToast
+import android.graphics.Color
+import android.view.View
+import com.lagradost.cloudstream3.CloudStreamApp
import javax.net.ssl.HttpsURLConnection
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLSession
@@ -134,6 +149,65 @@ const val toleranceBeforeUs = 300_000L
*/
const val toleranceAfterUs = 300_000L
+@OptIn(UnstableApi::class)
+class DualDefaultTrackSelector(context: Context) : DefaultTrackSelector(context) {
+ var secondaryTrackId: String? = null
+ var secondaryRendererIndex: Int = -1
+
+ override fun selectAllTracks(
+ mappedTrackInfo: MappingTrackSelector.MappedTrackInfo,
+ rendererFormatSupports: Array>,
+ rendererMixedMimeTypeAdaptationSupport: IntArray,
+ params: Parameters
+ ): Array {
+ val definitions = super.selectAllTracks(
+ mappedTrackInfo,
+ rendererFormatSupports,
+ rendererMixedMimeTypeAdaptationSupport,
+ params
+ )
+ val sIdx = secondaryRendererIndex
+ val secId = secondaryTrackId
+ if (sIdx in definitions.indices && secId != null) {
+ val unmapped = mappedTrackInfo.unmappedTrackGroups
+ var targetGroup: TrackGroup? = null
+ var targetTrackIndex: Int = -1
+ for (r in 0 until mappedTrackInfo.rendererCount) {
+ val groups = mappedTrackInfo.getTrackGroups(r)
+ for (g in 0 until groups.length) {
+ val group = groups.get(g)
+ for (t in 0 until group.length) {
+ if (group.getFormat(t).id?.replace(Regex("""^\d+:"""), "") == secId) {
+ targetGroup = group
+ targetTrackIndex = t
+ break
+ }
+ }
+ if (targetGroup != null) break
+ }
+ if (targetGroup != null) break
+ }
+ if (targetGroup == null) {
+ for (g in 0 until unmapped.length) {
+ val group = unmapped.get(g)
+ for (t in 0 until group.length) {
+ if (group.getFormat(t).id?.replace(Regex("""^\d+:"""), "") == secId) {
+ targetGroup = group
+ targetTrackIndex = t
+ break
+ }
+ }
+ if (targetGroup != null) break
+ }
+ }
+ if (targetGroup != null && targetTrackIndex >= 0) {
+ definitions[sIdx] = ExoTrackSelection.Definition(targetGroup, targetTrackIndex)
+ }
+ }
+ return definitions
+ }
+}
+
@OptIn(UnstableApi::class)
class CS3IPlayer : IPlayer {
private var playerListener: Player.Listener? = null
@@ -173,6 +247,27 @@ class CS3IPlayer : IPlayer {
private val subtitleHelper = PlayerSubtitleHelper()
+ private var secondarySubtitleExecutor: ExecutorService = newSecondarySubtitleExecutor()
+ private fun newSecondarySubtitleExecutor(): ExecutorService = Executors.newSingleThreadExecutor {
+ Thread(it, "secondary-subtitle-decoder").apply { isDaemon = true }
+ }
+ private val secondarySubtitleGeneration = AtomicLong(0L)
+ @Volatile private var secondarySubtitleFuture: Future<*>? = null
+ @Volatile private var currentSecondarySubtitle: SubtitleData? = null
+ @Volatile private var secondaryCues: List = emptyList()
+ private var lastSecondaryCueSignature: List = emptyList()
+
+ private val primarySubtitleGeneration = AtomicLong(0L)
+ @Volatile private var primarySubtitleFuture: Future<*>? = null
+ @Volatile private var primaryCues: List = emptyList()
+
+ private var primaryTextRendererIndex: Int = -1
+ private var secondaryTextRendererIndex: Int = -1
+ private var dualTrackSelector: DualDefaultTrackSelector? = null
+ private var latestEmbeddedSecondaryCues: List = emptyList()
+ private val embeddedPrimaryCues = mutableListOf()
+ private val embeddedSecondaryCues = mutableListOf()
+
/** If we want to play the audio only in the background when the app is not open */
private var isAudioOnlyBackground = false
@@ -285,6 +380,7 @@ class CS3IPlayer : IPlayer {
saveData()
} else {
currentSubtitles = subtitle
+ loadPrimaryCues(subtitle)
playbackPosition = 0
}
@@ -493,52 +589,90 @@ class CS3IPlayer : IPlayer {
)
}
- /**
- * @return True if the player should be reloaded
- * */
- override fun setPreferredSubtitles(subtitle: SubtitleData?): Boolean {
- Log.i(TAG, "setPreferredSubtitles init $subtitle")
- currentSubtitles = subtitle
- val trackSelector = exoPlayer?.trackSelector as? DefaultTrackSelector ?: return false
- // Disable subtitles if null
- if (subtitle == null) {
- trackSelector.setParameters(
- trackSelector.buildUponParameters()
- .setTrackTypeDisabled(TRACK_TYPE_TEXT, true)
- .clearOverridesOfType(TRACK_TYPE_TEXT)
- )
- return false
- }
- // Handle subtitle based on status
- when (subtitleHelper.subtitleStatus(subtitle)) {
- SubtitleStatus.REQUIRES_RELOAD -> {
- Log.i(TAG, "setPreferredSubtitles REQUIRES_RELOAD")
- return true
+ private fun findTrackInGroups(trackGroups: TrackGroupArray, id: String?): Pair? {
+ if (id == null) return null
+ for (g in 0 until trackGroups.length) {
+ val group = trackGroups.get(g)
+ for (t in 0 until group.length) {
+ val format = group.getFormat(t)
+ if (format.id?.stripTrackId() == id) {
+ return Pair(g, t)
+ }
}
+ }
+ return null
+ }
- SubtitleStatus.NOT_FOUND -> {
- Log.i(TAG, "setPreferredSubtitles NOT_FOUND")
- return true
+ private fun applySubtitleSelection() {
+ val trackSelector = exoPlayer?.trackSelector as? DefaultTrackSelector ?: return
+ val mappedTrackInfo = trackSelector.currentMappedTrackInfo ?: return
+ val builder = trackSelector.buildUponParameters()
+
+ if (primaryTextRendererIndex in 0 until mappedTrackInfo.rendererCount) {
+ val trackGroups = mappedTrackInfo.getTrackGroups(primaryTextRendererIndex)
+ val sub = currentSubtitles
+ if (sub == null) {
+ builder.setRendererDisabled(primaryTextRendererIndex, true)
+ builder.clearSelectionOverrides(primaryTextRendererIndex)
+ } else {
+ val trackPair = findTrackInGroups(trackGroups, sub.getId())
+ if (trackPair != null) {
+ val (gIdx, tIdx) = trackPair
+ builder.setRendererDisabled(primaryTextRendererIndex, false)
+ builder.setSelectionOverride(
+ primaryTextRendererIndex,
+ trackGroups,
+ DefaultTrackSelector.SelectionOverride(gIdx, tIdx)
+ )
+ } else {
+ builder.setRendererDisabled(primaryTextRendererIndex, false)
+ }
}
+ }
- SubtitleStatus.IS_ACTIVE -> {
- Log.i(TAG, "setPreferredSubtitles IS_ACTIVE")
- exoPlayer?.currentTracks?.groups
- ?.filter { it.type == TRACK_TYPE_TEXT }
- ?.getTrack(subtitle.getId())
- ?.let { (trackGroup, trackIndex) ->
- trackSelector.setParameters(
- trackSelector.buildUponParameters()
- .setTrackTypeDisabled(TRACK_TYPE_TEXT, false)
- .setOverrideForType(TrackSelectionOverride(trackGroup, trackIndex))
- )
- }
- return false
+ if (secondaryTextRendererIndex in 0 until mappedTrackInfo.rendererCount) {
+ val trackGroups = mappedTrackInfo.getTrackGroups(secondaryTextRendererIndex)
+ val sub = currentSecondarySubtitle
+ if (sub == null) {
+ builder.setRendererDisabled(secondaryTextRendererIndex, true)
+ builder.clearSelectionOverrides(secondaryTextRendererIndex)
+ } else {
+ val trackPair = findTrackInGroups(trackGroups, sub.getId())
+ if (trackPair != null) {
+ val (gIdx, tIdx) = trackPair
+ builder.setRendererDisabled(secondaryTextRendererIndex, false)
+ builder.setSelectionOverride(
+ secondaryTextRendererIndex,
+ trackGroups,
+ DefaultTrackSelector.SelectionOverride(gIdx, tIdx)
+ )
+ } else {
+ builder.setRendererDisabled(secondaryTextRendererIndex, false)
+ }
}
}
+
+ dualTrackSelector?.let { sel ->
+ sel.secondaryTrackId = currentSecondarySubtitle?.getId()
+ sel.secondaryRendererIndex = secondaryTextRendererIndex
+ }
+
+ trackSelector.setParameters(builder)
+ }
+
+ /**
+ * @return True if the player should be reloaded
+ * */
+ override fun setPreferredSubtitles(subtitle: SubtitleData?): Boolean {
+ Log.i(TAG, "setPreferredSubtitles init $subtitle")
+ currentSubtitles = subtitle
+ loadPrimaryCues(subtitle)
+ applySubtitleSelection()
+ return false
}
private var currentSubtitleOffset: Long = 0
+ private var currentSecondarySubtitleOffset: Long = 0
override fun setSubtitleOffset(offset: Long) {
currentSubtitleOffset = offset
@@ -556,16 +690,206 @@ class CS3IPlayer : IPlayer {
return currentSubtitleOffset
}
+ override fun setSecondarySubtitleOffset(offset: Long) {
+ currentSecondarySubtitleOffset = offset
+ lastSecondaryCueSignature = emptyList()
+ pushSecondaryCues()
+ }
+
+ override fun getSecondarySubtitleOffset(): Long {
+ return currentSecondarySubtitleOffset
+ }
+
override fun getSubtitleCues(): List {
- return currentSubtitleDecoder?.getSubtitleCues() ?: emptyList()
+ val active = getCurrentPreferredSubtitle()
+ if (primaryCues.isNotEmpty()) return primaryCues
+ val decoderCues = currentSubtitleDecoder?.getSubtitleCues()
+ if (!decoderCues.isNullOrEmpty()) return decoderCues
+ if (embeddedPrimaryCues.isNotEmpty()) return synchronized(embeddedPrimaryCues) { embeddedPrimaryCues.toList() }
+ if (active != null && active.origin != SubtitleOrigin.EMBEDDED_IN_VIDEO) {
+ try {
+ primarySubtitleFuture?.get(1500, TimeUnit.MILLISECONDS)
+ if (primaryCues.isNotEmpty()) return primaryCues
+ } catch (_: Throwable) {}
+ }
+ return primaryCues
+ }
+
+ private fun fetchSubtitleFromUrl(subtitle: SubtitleData): ByteArray? {
+ val fixedUrl = subtitle.getFixedUrl()
+ val reqHeaders = subtitle.headers.toMutableMap()
+ if (reqHeaders.keys.none { it.equals("User-Agent", ignoreCase = true) }) {
+ reqHeaders["User-Agent"] = USER_AGENT
+ }
+ if (reqHeaders.keys.none { it.equals("Referer", ignoreCase = true) }) {
+ try {
+ val uri = Uri.parse(fixedUrl)
+ if (uri.scheme != null && uri.host != null) {
+ reqHeaders["Referer"] = "${uri.scheme}://${uri.host}/"
+ }
+ } catch (_: Throwable) {}
+ }
+ return app.baseClient.newCall(
+ Request.Builder().url(fixedUrl).apply {
+ reqHeaders.forEach { (key, value) -> addHeader(key, value) }
+ }.build()
+ ).execute().use { resp ->
+ val body = resp.body.bytes()
+ if (body.size > 200) {
+ val preview = String(body.take(200).toByteArray())
+ if (preview.contains(" fetchSubtitleFromUrl(subtitle)
+ SubtitleOrigin.DOWNLOADED_FILE -> CloudStreamApp.context?.contentResolver
+ ?.openInputStream(Uri.parse(subtitle.url))?.use { it.readBytes() }
+ SubtitleOrigin.EMBEDDED_IN_VIDEO -> null
+ }
+ }
+
+ private fun loadPrimaryCues(subtitle: SubtitleData?) {
+ val generation = primarySubtitleGeneration.incrementAndGet()
+ primarySubtitleFuture?.cancel(true)
+ primarySubtitleFuture = null
+ if (subtitle == null || subtitle.origin == SubtitleOrigin.EMBEDDED_IN_VIDEO) {
+ primaryCues = emptyList()
+ return
+ }
+ if (secondarySubtitleExecutor.isShutdown) secondarySubtitleExecutor = newSecondarySubtitleExecutor()
+ primarySubtitleFuture = secondarySubtitleExecutor.submit {
+ try {
+ val bytes = fetchSubtitleBytes(subtitle) ?: return@submit
+ if (primarySubtitleGeneration.get() != generation) return@submit
+ val decoder = CustomDecoder(Format.Builder().setSampleMimeType(subtitle.mimeType).build())
+ decoder.parseToLegacySubtitle(bytes, 0, bytes.size)
+ val cues = synchronized(decoder.currentSubtitleCues) { decoder.currentSubtitleCues.toList() }
+ Log.i(TAG, "Primary subtitle parsed cues count: ${cues.size}")
+ if (primarySubtitleGeneration.get() != generation) return@submit
+ primaryCues = cues
+ } catch (t: Throwable) { if (t !is InterruptedException) logError(t) }
+ }
+ }
+
+ override fun setSecondarySubtitles(subtitle: SubtitleData?) {
+ val generation = secondarySubtitleGeneration.incrementAndGet()
+ secondarySubtitleFuture?.cancel(true)
+ secondarySubtitleFuture = null
+ currentSecondarySubtitle = subtitle
+ secondaryCues = emptyList()
+ lastSecondaryCueSignature = emptyList()
+ latestEmbeddedSecondaryCues = emptyList()
+ synchronized(embeddedSecondaryCues) { embeddedSecondaryCues.clear() }
+ pushSecondaryCues()
+ applySubtitleSelection()
+ if (subtitle == null || subtitle.origin == SubtitleOrigin.EMBEDDED_IN_VIDEO) return
+ if (secondarySubtitleExecutor.isShutdown) secondarySubtitleExecutor = newSecondarySubtitleExecutor()
+ secondarySubtitleFuture = secondarySubtitleExecutor.submit {
+ try {
+ val bytes = fetchSubtitleBytes(subtitle) ?: return@submit
+ if (secondarySubtitleGeneration.get() != generation) return@submit
+ val decoder = CustomDecoder(Format.Builder().setSampleMimeType(subtitle.mimeType).build())
+ decoder.parseToLegacySubtitle(bytes, 0, bytes.size)
+ val cues = synchronized(decoder.currentSubtitleCues) { decoder.currentSubtitleCues.toList() }
+ Log.i(TAG, "Secondary subtitle parsed cues count: ${cues.size}")
+ if (secondarySubtitleGeneration.get() != generation) return@submit
+ secondaryCues = cues
+ runOnMainThread { if (secondarySubtitleGeneration.get() == generation) pushSecondaryCues() }
+ } catch (t: Throwable) { if (t !is InterruptedException) logError(t) }
+ }
+ }
+
+ override fun getCurrentSecondarySubtitle(): SubtitleData? = currentSecondarySubtitle
+
+ override fun getSecondarySubtitleCues(): List {
+ if (secondaryCues.isNotEmpty()) return secondaryCues
+ if (embeddedSecondaryCues.isNotEmpty()) return synchronized(embeddedSecondaryCues) { embeddedSecondaryCues.toList() }
+ return secondaryCues
+ }
+
+ private fun pushSecondaryCues() {
+ val view = subtitleHelper.secondarySubtitleView ?: return
+ val position = exoPlayer?.currentPosition ?: return
+ val baseStyle = CustomDecoder.style ?: SaveCaptionStyle(
+ foregroundColor = Color.WHITE,
+ backgroundColor = Color.TRANSPARENT,
+ windowColor = Color.TRANSPARENT,
+ edgeType = 1,
+ edgeColor = Color.BLACK,
+ font = null,
+ typefaceFilePath = null,
+ elevation = 20,
+ fixedTextSize = null,
+ edgeSize = null,
+ removeCaptions = false,
+ removeBloat = true,
+ upperCase = false,
+ bold = false,
+ italic = false,
+ backgroundRadius = null,
+ alignment = null
+ )
+ val transparentTopStyle = baseStyle.copy(
+ backgroundColor = Color.TRANSPARENT,
+ windowColor = Color.TRANSPARENT,
+ backgroundRadius = null
+ )
+ if (currentSecondarySubtitle?.origin == SubtitleOrigin.EMBEDDED_IN_VIDEO) {
+ val matchingCue = synchronized(embeddedSecondaryCues) {
+ embeddedSecondaryCues.lastOrNull {
+ position in it.startTimeMs..(it.startTimeMs + it.durationMs)
+ } ?: embeddedSecondaryCues.lastOrNull {
+ kotlin.math.abs(it.startTimeMs - position) < 3000L
+ }
+ }
+ if (matchingCue != null) {
+ view.setCues(matchingCue.text.map { line ->
+ Cue.Builder()
+ .setText(line)
+ .setTextSize(25f, Cue.TEXT_SIZE_TYPE_ABSOLUTE)
+ .setLine(0f, Cue.LINE_TYPE_FRACTION)
+ .setLineAnchor(Cue.ANCHOR_TYPE_START)
+ .fixSubtitleAlignment()
+ .applyStyle(transparentTopStyle)
+ .build()
+ })
+ } else if (latestEmbeddedSecondaryCues.isNotEmpty()) {
+ view.setCues(latestEmbeddedSecondaryCues)
+ }
+ return
+ }
+ val active = secondaryCues.filter { it.startTimeMs <= position + currentSecondarySubtitleOffset && position + currentSecondarySubtitleOffset < it.endTimeMs }
+ val activeSignature = active.map { "${it.startTimeMs}:${it.endTimeMs}:${it.text.joinToString(" ")}" }
+ if (activeSignature == lastSecondaryCueSignature) return
+ lastSecondaryCueSignature = activeSignature
+ view.setCues(active.map { cue ->
+ Cue.Builder()
+ .setText(cue.text.joinToString("\n"))
+ .setTextSize(25f, Cue.TEXT_SIZE_TYPE_ABSOLUTE)
+ .setLine(0f, Cue.LINE_TYPE_FRACTION)
+ .setLineAnchor(Cue.ANCHOR_TYPE_START)
+ .fixSubtitleAlignment()
+ .applyStyle(transparentTopStyle)
+ .build()
+ })
}
override fun getCurrentPreferredSubtitle(): SubtitleData? {
- return subtitleHelper.getAllSubtitles().firstOrNull { sub ->
+ val active = subtitleHelper.getAllSubtitles().firstOrNull { sub ->
playerSelectedSubtitleTracks.any { (id, isSelected) ->
isSelected && sub.getId() == id
}
+ } ?: currentSubtitles
+ if (active != null && primaryCues.isEmpty() && active.origin != SubtitleOrigin.EMBEDDED_IN_VIDEO) {
+ loadPrimaryCues(active)
}
+ return active
}
override fun getAspectRatio(): Rational? {
@@ -595,8 +919,23 @@ class CS3IPlayer : IPlayer {
if (saveTime)
updatedTime()
+ secondarySubtitleGeneration.incrementAndGet()
+ secondarySubtitleFuture?.cancel(true)
+ secondarySubtitleFuture = null
+ secondaryCues = emptyList()
+ lastSecondaryCueSignature = emptyList()
+ if (!saveTime) {
+ primarySubtitleGeneration.incrementAndGet()
+ primarySubtitleFuture?.cancel(true)
+ primarySubtitleFuture = null
+ primaryCues = emptyList()
+ latestEmbeddedSecondaryCues = emptyList()
+ synchronized(embeddedPrimaryCues) { embeddedPrimaryCues.clear() }
+ synchronized(embeddedSecondaryCues) { embeddedSecondaryCues.clear() }
+ }
currentTextRenderer = null
currentSubtitleDecoder = null
+ pushSecondaryCues()
exoPlayer?.apply {
playWhenReady = false
@@ -651,6 +990,7 @@ class CS3IPlayer : IPlayer {
override fun release() {
imageGenerator.release()
releasePlayer()
+ secondarySubtitleExecutor.shutdownNow()
}
override fun setPlaybackSpeed(speed: Float) {
@@ -869,8 +1209,8 @@ class CS3IPlayer : IPlayer {
return getMediaItemBuilder(mimeType).setUri(url).build()
}
- private fun getTrackSelector(context: Context, maxVideoHeight: Int?): TrackSelector {
- val trackSelector = DefaultTrackSelector(context)
+ private fun getTrackSelector(context: Context, maxVideoHeight: Int?): DualDefaultTrackSelector {
+ val trackSelector = DualDefaultTrackSelector(context)
trackSelector.parameters = trackSelector.buildUponParameters()
// This will not force higher quality videos to fail
// but will make the m3u8 pick the correct preferred
@@ -898,6 +1238,7 @@ class CS3IPlayer : IPlayer {
writePosition: Long? = null,
source: PlayerEventSource = PlayerEventSource.Player
) {
+ pushSecondaryCues()
val position = writePosition ?: exoPlayer?.currentPosition
getCurrentTimestamp(position)?.let { timestamp ->
@@ -1183,44 +1524,118 @@ class CS3IPlayer : IPlayer {
val combinedCues = styledBitmapCues + styledTextCues
+ val pos = exoPlayer?.currentPosition ?: 0L
+ val textLines = textCues.mapNotNull { it.text?.toString() }
+ if (textLines.isNotEmpty()) {
+ synchronized(embeddedPrimaryCues) {
+ if (embeddedPrimaryCues.none { kotlin.math.abs(it.startTimeMs - pos) < 800L && it.text == textLines }) {
+ embeddedPrimaryCues.add(SubtitleCue(pos, 3000L, textLines))
+ }
+ }
+ }
+
subtitleHelper.subtitleView?.setCues(combinedCues)
+ pushSecondaryCues()
+ }
+
+ val secondaryTextOutput = TextOutput { cueGroup ->
+ val baseStyle = CustomDecoder.style ?: SaveCaptionStyle(
+ foregroundColor = Color.WHITE,
+ backgroundColor = Color.TRANSPARENT,
+ windowColor = Color.TRANSPARENT,
+ edgeType = 1,
+ edgeColor = Color.BLACK,
+ font = null,
+ typefaceFilePath = null,
+ elevation = 20,
+ fixedTextSize = null,
+ edgeSize = null,
+ removeCaptions = false,
+ removeBloat = true,
+ upperCase = false,
+ bold = false,
+ italic = false,
+ backgroundRadius = null,
+ alignment = null
+ )
+ val transparentTopStyle = baseStyle.copy(
+ backgroundColor = Color.TRANSPARENT,
+ windowColor = Color.TRANSPARENT,
+ backgroundRadius = null
+ )
+ val styledCues = cueGroup.cues.map { cue ->
+ cue.buildUpon()
+ .setLine(0f, Cue.LINE_TYPE_FRACTION)
+ .setLineAnchor(Cue.ANCHOR_TYPE_START)
+ .fixSubtitleAlignment()
+ .applyStyle(transparentTopStyle)
+ .build()
+ }
+ latestEmbeddedSecondaryCues = styledCues
+ val pos = exoPlayer?.currentPosition ?: 0L
+ val textLines = cueGroup.cues.mapNotNull { it.text?.toString() }
+ if (textLines.isNotEmpty()) {
+ synchronized(embeddedSecondaryCues) {
+ if (embeddedSecondaryCues.none { kotlin.math.abs(it.startTimeMs - pos) < 800L && it.text == textLines }) {
+ embeddedSecondaryCues.add(SubtitleCue(pos, 3000L, textLines))
+ }
+ }
+ }
+ pushSecondaryCues()
}
- factory.createRenderers(
+ val renderersList = mutableListOf()
+ var pIdx = -1
+ var sIdx = -1
+ for (r in factory.createRenderers(
eventHandler,
videoRendererEventListener,
audioRendererEventListener,
customTextOutput,
metadataRendererOutput
- ).map {
- if (it is TextRenderer) {
+ )) {
+ if (r is TextRenderer) {
CustomDecoder.subtitleOffset = subtitleOffset
- val decoder = CustomSubtitleDecoderFactory()
-
- // @OptIn(ExperimentalApi::class)
- val currentTextRenderer = TextRenderer(
+ val primaryDecoder = CustomSubtitleDecoderFactory()
+ val primaryRenderer = TextRenderer(
customTextOutput,
eventHandler.looper,
- decoder
+ primaryDecoder
).apply {
- // Required to make the decoder work with old subtitles
- // Upgrade CustomSubtitleDecoderFactory when media3 supports it
@Suppress("DEPRECATION")
experimentalSetLegacyDecodingEnabled(true)
}.also { renderer ->
currentTextRenderer = renderer
- currentSubtitleDecoder = decoder
+ currentSubtitleDecoder = primaryDecoder
}
- currentTextRenderer
- } else
- it
- }.toTypedArray()
+
+ val secondaryDecoder = CustomSubtitleDecoderFactory()
+ val secondaryRenderer = TextRenderer(
+ secondaryTextOutput,
+ eventHandler.looper,
+ secondaryDecoder
+ ).apply {
+ @Suppress("DEPRECATION")
+ experimentalSetLegacyDecodingEnabled(true)
+ }
+
+ pIdx = renderersList.size
+ renderersList.add(primaryRenderer)
+ sIdx = renderersList.size
+ renderersList.add(secondaryRenderer)
+ } else {
+ renderersList.add(r)
+ }
+ }
+ primaryTextRendererIndex = pIdx
+ secondaryTextRendererIndex = sIdx
+ renderersList.toTypedArray()
}
.setTrackSelector(
- trackSelector ?: getTrackSelector(
+ (trackSelector as? DualDefaultTrackSelector ?: getTrackSelector(
context,
maxVideoHeight
- )
+ )).also { dualTrackSelector = it }
)
// Allows any seeking to be +- 0.3s to allow for faster seeking
.setSeekParameters(SeekParameters(toleranceBeforeUs, toleranceAfterUs))
@@ -1492,6 +1907,7 @@ class CS3IPlayer : IPlayer {
event(EmbeddedSubtitlesFetchedEvent(tracks = exoPlayerReportedTracks))
event(TracksChangedEvent())
event(SubtitlesUpdatedEvent())
+ applySubtitleSelection()
}
}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubtitleDecoderFactory.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubtitleDecoderFactory.kt
index 61d6f556450..0dc7abaeb62 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubtitleDecoderFactory.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubtitleDecoderFactory.kt
@@ -356,7 +356,7 @@ class CustomDecoder(private val fallbackFormat: Format?) : SubtitleParser {
}
override fun reset() {
- currentSubtitleCues.clear()
+ // Do not clear currentSubtitleCues here so they remain available for subtitle sync and comparison
super.reset()
}
}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/DualSubtitleAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/DualSubtitleAdapter.kt
new file mode 100644
index 00000000000..9b9428dc3d1
--- /dev/null
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/DualSubtitleAdapter.kt
@@ -0,0 +1,189 @@
+package com.lagradost.cloudstream3.ui.player
+
+import android.animation.ObjectAnimator
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import android.view.animation.DecelerateInterpolator
+import androidx.core.view.isInvisible
+import com.lagradost.cloudstream3.databinding.DialogDualSubtitlesItemBinding
+import com.lagradost.cloudstream3.ui.BaseDiffCallback
+import com.lagradost.cloudstream3.ui.NoStateAdapter
+import com.lagradost.cloudstream3.ui.ViewHolderState
+import java.util.Locale
+import kotlin.math.max
+import kotlin.math.min
+import kotlin.math.roundToInt
+
+data class DualSubtitleCue(
+ val startTimeMs: Long,
+ val endTimeMs: Long,
+ val primaryText: String?,
+ val secondaryText: String?,
+)
+
+object DualSubtitleAligner {
+ fun align(
+ primaryCues: List,
+ primaryOffset: Long,
+ secondaryCues: List,
+ secondaryOffset: Long
+ ): List {
+ val pList = primaryCues.map {
+ SubtitleCue(it.startTimeMs - primaryOffset, it.durationMs, it.text)
+ }.sortedBy { it.startTimeMs }
+
+ val sList = secondaryCues.map {
+ SubtitleCue(it.startTimeMs - secondaryOffset, it.durationMs, it.text)
+ }.sortedBy { it.startTimeMs }
+
+ if (pList.isEmpty() && sList.isEmpty()) return emptyList()
+ if (pList.isEmpty()) {
+ return sList.map { DualSubtitleCue(it.startTimeMs, it.endTimeMs, null, it.text.joinToString("\n")) }
+ }
+ if (sList.isEmpty()) {
+ return pList.map { DualSubtitleCue(it.startTimeMs, it.endTimeMs, it.text.joinToString("\n"), null) }
+ }
+
+ val result = mutableListOf()
+ var pIdx = 0
+ var sIdx = 0
+
+ while (pIdx < pList.size && sIdx < sList.size) {
+ val p = pList[pIdx]
+ val s = sList[sIdx]
+
+ val overlaps = (p.startTimeMs < s.endTimeMs && s.startTimeMs < p.endTimeMs) ||
+ kotlin.math.abs(p.startTimeMs - s.startTimeMs) <= 1200L
+
+ if (overlaps) {
+ result.add(
+ DualSubtitleCue(
+ startTimeMs = min(p.startTimeMs, s.startTimeMs),
+ endTimeMs = max(p.endTimeMs, s.endTimeMs),
+ primaryText = p.text.joinToString("\n"),
+ secondaryText = s.text.joinToString("\n")
+ )
+ )
+ pIdx++
+ sIdx++
+ } else if (p.startTimeMs < s.startTimeMs) {
+ result.add(
+ DualSubtitleCue(
+ startTimeMs = p.startTimeMs,
+ endTimeMs = p.endTimeMs,
+ primaryText = p.text.joinToString("\n"),
+ secondaryText = null
+ )
+ )
+ pIdx++
+ } else {
+ result.add(
+ DualSubtitleCue(
+ startTimeMs = s.startTimeMs,
+ endTimeMs = s.endTimeMs,
+ primaryText = null,
+ secondaryText = s.text.joinToString("\n")
+ )
+ )
+ sIdx++
+ }
+ }
+
+ while (pIdx < pList.size) {
+ val p = pList[pIdx++]
+ result.add(DualSubtitleCue(p.startTimeMs, p.endTimeMs, p.text.joinToString("\n"), null))
+ }
+ while (sIdx < sList.size) {
+ val s = sList[sIdx++]
+ result.add(DualSubtitleCue(s.startTimeMs, s.endTimeMs, null, s.text.joinToString("\n")))
+ }
+
+ return result
+ }
+}
+
+class DualSubtitleAdapter(
+ private var currentTimeMs: Long,
+ val clickCallback: (DualSubtitleCue) -> Unit
+) : NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b ->
+ a.startTimeMs == b.startTimeMs && a.endTimeMs == b.endTimeMs
+})) {
+
+ companion object {
+ fun formatTime(timeMs: Long): String {
+ val totalSeconds = (timeMs / 1000).coerceAtLeast(0)
+ val seconds = totalSeconds % 60
+ val minutes = totalSeconds / 60 % 60
+ val hours = totalSeconds / 3600
+ return if (hours > 0) {
+ String.format(Locale.US, "%d:%02d:%02d", hours, minutes, seconds)
+ } else {
+ String.format(Locale.US, "%02d:%02d", minutes, seconds)
+ }
+ }
+ }
+
+ override fun onCreateContent(parent: ViewGroup): ViewHolderState {
+ val inflater = LayoutInflater.from(parent.context)
+ val binding = DialogDualSubtitlesItemBinding.inflate(inflater, parent, false)
+ return ViewHolderState(binding)
+ }
+
+ override fun onBindContent(holder: ViewHolderState, item: DualSubtitleCue, position: Int) {
+ val binding = holder.view as? DialogDualSubtitlesItemBinding ?: return
+
+ binding.root.setOnClickListener {
+ clickCallback.invoke(item)
+ }
+
+ binding.primarySubText.text = item.primaryText ?: "—"
+ binding.secondarySubText.text = item.secondaryText ?: "—"
+ binding.timestampBadge.text = formatTime(item.startTimeMs)
+
+ val timeMs = currentTimeMs
+ val startTime = item.startTimeMs
+ val endTime = item.endTimeMs
+
+ val isActive = timeMs in startTime..= startTime) 1.0f else 0.5f
+ binding.root.alpha = newAlpha
+
+ binding.dualSubProgress.isInvisible = !isActive
+ if (isActive && endTime > startTime) {
+ val progressValue = ((timeMs - startTime) * 1000f / (endTime - startTime)).roundToInt()
+ ObjectAnimator.ofInt(
+ binding.dualSubProgress,
+ "progress",
+ binding.dualSubProgress.progress,
+ progressValue
+ ).apply {
+ duration = 200
+ interpolator = DecelerateInterpolator()
+ }.start()
+ } else {
+ binding.dualSubProgress.progress = 0
+ }
+ }
+
+ fun getLatestActiveItem(position: Long): Int {
+ return immutableCurrentList.withIndex().lastOrNull {
+ position >= it.value.startTimeMs
+ }?.index ?: 0
+ }
+
+ fun updateTime(timeMs: Long) {
+ val previousTime = currentTimeMs
+ currentTimeMs = timeMs
+
+ val earlyTime = minOf(previousTime, timeMs)
+ val lateTime = maxOf(previousTime, timeMs)
+
+ val affectedItems = immutableCurrentList.withIndex().filter { cue ->
+ cue.value.startTimeMs in (earlyTime - 5000)..(lateTime + 5000)
+ }
+
+ affectedItems.forEach { item ->
+ this.notifyItemChanged(item.index)
+ }
+ }
+}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt
index d90b6043f28..fe13dd06c25 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt
@@ -13,6 +13,7 @@ import android.graphics.Color
import android.os.Build
import android.os.Bundle
import android.text.Editable
+import android.util.Log
import android.view.KeyEvent
import android.view.LayoutInflater
import android.view.MotionEvent
@@ -68,6 +69,7 @@ import com.lagradost.cloudstream3.utils.txt
import kotlin.math.roundToInt
private const val SUBTITLE_DELAY_BUNDLE_KEY = "subtitle_delay"
+private const val SECONDARY_SUBTITLE_DELAY_BUNDLE_KEY = "secondary_subtitle_delay"
// All the UI Logic for the player
@OptIn(UnstableApi::class)
@@ -113,6 +115,19 @@ open class FullScreenPlayer : AbstractPlayerFragment(
0L
}
+ protected var secondarySubtitleDelay
+ set(value) = try {
+ player.setSecondarySubtitleOffset(-value)
+ } catch (e: Exception) {
+ logError(e)
+ }
+ get() = try {
+ -player.getSecondarySubtitleOffset()
+ } catch (e: Exception) {
+ logError(e)
+ 0L
+ }
+
private var isShowingEpisodeOverlay: Boolean = false
private var previousPlayStatus: Boolean = false
@@ -352,8 +367,9 @@ open class FullScreenPlayer : AbstractPlayerFragment(
track.sampleMimeType == MimeTypes.APPLICATION_MEDIA3_CUES
}
// Subtitle offset is not possible on built-in media3 tracks
+ val hasSecondarySub = player.getCurrentSecondarySubtitle() != null
playerBinding?.playerSubtitleOffsetBtt?.isGone =
- isBuiltinSubtitles || tracks.currentTextTracks.isEmpty()
+ (isBuiltinSubtitles || tracks.currentTextTracks.isEmpty()) && !hasSecondarySub
}
private fun restoreOrientationWithSensor(activity: Activity) {
@@ -496,7 +512,7 @@ open class FullScreenPlayer : AbstractPlayerFragment(
player.seekTime(85000) // skip 85s
}
- private fun showSubtitleOffsetDialog() {
+ private fun showSubtitleOffsetDialog(isSecondary: Boolean = false) {
val ctx = context ?: return
// Pause player because the subtitles cannot be continuously updated to follow playback.
player.handleEvent(
@@ -517,8 +533,11 @@ open class FullScreenPlayer : AbstractPlayerFragment(
ctx.resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT
fixSystemBarsPadding(binding.root, fixIme = isPortrait)
- var currentOffset = subtitleDelay
+ var currentOffset = if (isSecondary) secondarySubtitleDelay else subtitleDelay
binding.apply {
+ if (isSecondary) {
+ subtitleOffsetTitleText?.setText(R.string.secondary_subtitle_offset_title)
+ }
var subtitleAdapter: SubtitleOffsetItemAdapter? = null
subtitleOffsetInput.doOnTextChanged { text, _, _, _ ->
@@ -535,6 +554,10 @@ open class FullScreenPlayer : AbstractPlayerFragment(
subtitleOffsetRecyclerview.scrollToPosition(subtitlePos)
}
+ if (isSecondary) {
+ player.setSecondarySubtitleOffset(-currentOffset)
+ }
+
val str = when {
time > 0L -> {
txt(R.string.subtitle_offset_extra_hint_later_format, time)
@@ -554,7 +577,7 @@ open class FullScreenPlayer : AbstractPlayerFragment(
subtitleOffsetInput.text =
Editable.Factory.getInstance()?.newEditable(currentOffset.toString())
- val subtitles = player.getSubtitleCues().toMutableList()
+ val subtitles = (if (isSecondary) player.getSecondarySubtitleCues() else player.getSubtitleCues()).toMutableList()
subtitleOffsetRecyclerview.isVisible = subtitles.isNotEmpty()
noSubtitlesLoadedNotice.isVisible = subtitles.isEmpty()
@@ -605,13 +628,23 @@ open class FullScreenPlayer : AbstractPlayerFragment(
}
applyBtt.setOnClickListener {
selectSubtitlesDialog = null
- subtitleDelay = currentOffset
+ if (isSecondary) {
+ secondarySubtitleDelay = currentOffset
+ player.setSecondarySubtitleOffset(-currentOffset)
+ } else {
+ subtitleDelay = currentOffset
+ }
dialog.dismissSafe(activity)
player.seekTime(1L)
}
resetBtt.setOnClickListener {
selectSubtitlesDialog = null
- subtitleDelay = 0
+ if (isSecondary) {
+ secondarySubtitleDelay = 0
+ player.setSecondarySubtitleOffset(0)
+ } else {
+ subtitleDelay = 0
+ }
dialog.dismissSafe(activity)
player.seekTime(1L)
}
@@ -622,6 +655,91 @@ open class FullScreenPlayer : AbstractPlayerFragment(
}
}
+ private var dualSubtitlesDialog: Dialog? = null
+ private var dualSubWasPlaying = false
+
+ private fun showDualSubtitlesDialog() {
+ val ctx = context ?: return
+ dualSubWasPlaying = player.getIsPlaying()
+ player.handleEvent(CSPlayerEvent.Pause, PlayerEventSource.UI)
+
+ val primarySub = player.getCurrentPreferredSubtitle()
+ val secondarySub = player.getCurrentSecondarySubtitle()
+
+ val pOffset = player.getSubtitleOffset()
+ val sOffset = player.getSecondarySubtitleOffset()
+
+ val pCues = player.getSubtitleCues()
+ val sCues = player.getSecondarySubtitleCues()
+
+ val alignedCues = DualSubtitleAligner.align(pCues, pOffset, sCues, sOffset)
+
+ val binding = com.lagradost.cloudstream3.databinding.DialogDualSubtitlesBinding.inflate(
+ LayoutInflater.from(ctx), null, false
+ )
+ val dialog = Dialog(ctx, R.style.DialogFullscreenPlayer).apply {
+ setContentView(binding.root)
+ }
+ this.dualSubtitlesDialog = dialog
+ dialog.show()
+
+ val isPortrait =
+ ctx.resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT
+ fixSystemBarsPadding(binding.root, fixIme = isPortrait)
+
+ binding.apply {
+ val pName = primarySub?.name?.ifBlank { primarySub.originalName }
+ ?: if (pCues.isNotEmpty()) "Active" else "None"
+ val sName = secondarySub?.name?.ifBlank { secondarySub.originalName }
+ ?: if (sCues.isNotEmpty()) "Active" else "None"
+
+ primarySubHeader.text = "Primary: $pName"
+ secondarySubHeader.text = "Secondary: $sName"
+
+ noDualSubtitlesNotice.isVisible = alignedCues.isEmpty()
+ dualSubtitlesRecyclerview.isVisible = alignedCues.isNotEmpty()
+
+ val currentPosition = player.getPosition() ?: 0L
+ val adapter = DualSubtitleAdapter(currentPosition) { cue ->
+ ctx.vibrateDevice(30L)
+ player.seekTo(cue.startTimeMs, PlayerEventSource.UI)
+ player.handleEvent(CSPlayerEvent.Play, PlayerEventSource.UI)
+ dialog.dismissSafe(activity)
+ }
+ adapter.submitList(alignedCues)
+ dualSubtitlesRecyclerview.adapter = adapter
+
+ val activeIndex = adapter.getLatestActiveItem(currentPosition)
+ if (activeIndex in alignedCues.indices) {
+ dualSubtitlesRecyclerview.scrollToPosition(activeIndex)
+ }
+
+ if (pCues.isEmpty() && primarySub != null && primarySub.origin != SubtitleOrigin.EMBEDDED_IN_VIDEO) {
+ root.postDelayed({
+ val delayedPCues = player.getSubtitleCues()
+ if (delayedPCues.isNotEmpty()) {
+ val newAligned = DualSubtitleAligner.align(delayedPCues, pOffset, player.getSecondarySubtitleCues(), sOffset)
+ adapter.submitList(newAligned)
+ noDualSubtitlesNotice.isVisible = newAligned.isEmpty()
+ dualSubtitlesRecyclerview.isVisible = newAligned.isNotEmpty()
+ }
+ }, 500)
+ }
+
+ dualSubCloseBtt.setOnClickListener {
+ dialog.dismissSafe(activity)
+ }
+
+ dialog.setOnDismissListener {
+ dualSubtitlesDialog = null
+ if (dualSubWasPlaying) {
+ player.handleEvent(CSPlayerEvent.Play, PlayerEventSource.UI)
+ }
+ activity?.hideSystemUI()
+ }
+ }
+ }
+
@SuppressLint("SetTextI18n")
fun updateSpeedDialogBinding(binding: SpeedDialogBinding) {
val speed = player.getPlaybackSpeed()
@@ -851,6 +969,13 @@ open class FullScreenPlayer : AbstractPlayerFragment(
override fun playerStatusChanged() {
super.playerStatusChanged()
scheduleMetadataVisibility()
+ val secView = subtitleHolder?.findViewById(R.id.secondary_subtitle_view)
+ ?: playerBinding?.root?.findViewById(R.id.secondary_subtitle_view)
+ ?: binding?.root?.findViewById(R.id.secondary_subtitle_view)
+ val isPaused = currentPlayerStatus == CSPlayerLoading.IsPaused
+ if (player.getCurrentSecondarySubtitle() != null) {
+ secView?.visibility = if (isPaused || DataStoreHelper.alwaysShowSecondarySubtitles) View.VISIBLE else View.GONE
+ }
}
// When the hold-speedup gesture fires, hide controls so the video is unobstructed.
@@ -859,6 +984,18 @@ open class FullScreenPlayer : AbstractPlayerFragment(
if (show && isShowing) onClickChange()
}
+ override fun onHoldSecondarySubtitle(show: Boolean) {
+ val secView = subtitleHolder?.findViewById(R.id.secondary_subtitle_view)
+ ?: playerBinding?.root?.findViewById(R.id.secondary_subtitle_view)
+ ?: binding?.root?.findViewById(R.id.secondary_subtitle_view)
+ Log.i("FullScreenPlayer", "onHoldSecondarySubtitle: show=$show, secView=$secView")
+ secView?.visibility = if (show || DataStoreHelper.alwaysShowSecondarySubtitles) View.VISIBLE else View.GONE
+ }
+
+ override fun onOpenDualSubtitleDialog() {
+ showDualSubtitlesDialog()
+ }
+
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
@@ -1090,6 +1227,7 @@ open class FullScreenPlayer : AbstractPlayerFragment(
override fun onSaveInstanceState(outState: Bundle) {
// As this is video specific it is better to not do any setKey/getKey
outState.putLong(SUBTITLE_DELAY_BUNDLE_KEY, subtitleDelay)
+ outState.putLong(SECONDARY_SUBTITLE_DELAY_BUNDLE_KEY, secondarySubtitleDelay)
super.onSaveInstanceState(outState)
}
@@ -1114,6 +1252,9 @@ open class FullScreenPlayer : AbstractPlayerFragment(
savedInstanceState?.getLong(SUBTITLE_DELAY_BUNDLE_KEY)?.let {
subtitleDelay = it
}
+ savedInstanceState?.getLong(SECONDARY_SUBTITLE_DELAY_BUNDLE_KEY)?.let {
+ secondarySubtitleDelay = it
+ }
// handle tv controls directly based on player state
setupKeyEventListener()
@@ -1252,7 +1393,35 @@ open class FullScreenPlayer : AbstractPlayerFragment(
}
playerSubtitleOffsetBtt.setOnClickListener {
- showSubtitleOffsetDialog()
+ if (player.getCurrentSecondarySubtitle() != null) {
+ val activity = activity ?: return@setOnClickListener
+ val options = listOf(
+ getString(R.string.subtitle_offset_title),
+ getString(R.string.secondary_subtitle_offset_title)
+ )
+ com.lagradost.cloudstream3.utils.SingleSelectionHelper.run {
+ activity.showDialog(
+ items = options,
+ selectedIndex = 0,
+ name = getString(R.string.subtitle_offset),
+ showApply = false,
+ dismissCallback = {},
+ callback = { index: Int ->
+ showSubtitleOffsetDialog(isSecondary = index == 1)
+ }
+ )
+ }
+ } else {
+ showSubtitleOffsetDialog(isSecondary = false)
+ }
+ }
+ playerSubtitleOffsetBtt.setOnLongClickListener {
+ if (player.getCurrentSecondarySubtitle() != null) {
+ showSubtitleOffsetDialog(isSecondary = true)
+ true
+ } else {
+ false
+ }
}
playerGoBack.setOnClickListener {
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt
index e4616c93bcf..fc445ccd40c 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt
@@ -1202,7 +1202,20 @@ class GeneratorPlayer : FullScreenPlayer() {
}.toMap()
val subtitlesGroupedList = subtitlesGrouped.entries.toList()
- val subtitles = subtitlesGrouped.map { it.key.html() }
+ fun getSubGroupLabel(key: String, list: List): Spanned {
+ val currentSec = viewModel.state.secondarySubtitle
+ val isSecondary = currentSec != null && list.any { it.getId() == currentSec.getId() }
+ return if (isSecondary) {
+ "✓ [2nd] ${key}".html()
+ } else {
+ key.html()
+ }
+ }
+
+ fun getSubtitleLabels(): List =
+ subtitlesGrouped.map { getSubGroupLabel(it.key, it.value) }
+
+ val subtitles = getSubtitleLabels()
val subtitleGroupIndexStart =
subtitlesGrouped.keys.indexOf(currentSelectedSubtitles?.originalName) + 1
@@ -1228,19 +1241,32 @@ class GeneratorPlayer : FullScreenPlayer() {
subtitleOptionList.choiceMode = AbsListView.CHOICE_MODE_SINGLE
fun updateSubtitleOptionList() {
+ subsArrayAdapter.clear()
+ subsArrayAdapter.add(ctx.getString(R.string.no_subtitles).html())
+ subsArrayAdapter.addAll(getSubtitleLabels())
+ subtitleList.setItemChecked(subtitleGroupIndex, true)
+
subsOptionsArrayAdapter.clear()
+ val currentSec = viewModel.state.secondarySubtitle
val subtitleOptions =
subtitlesGroupedList
.getOrNull(subtitleGroupIndex - 1)?.value?.map { subtitle ->
val nameSuffix = subtitle.nameSuffix.html()
- nameSuffix.ifBlank {
+ val baseLabel = nameSuffix.ifBlank {
when (subtitle.origin) {
SubtitleOrigin.URL -> txt(R.string.subtitles_from_online)
SubtitleOrigin.DOWNLOADED_FILE -> txt(R.string.downloaded)
SubtitleOrigin.EMBEDDED_IN_VIDEO -> txt(R.string.subtitles_from_embedded)
}.asString(ctx).toSpanned()
}
+ if (currentSec?.getId() == subtitle.getId()) {
+ "✓ [2nd] ".html().let { prefix ->
+ android.text.TextUtils.concat(prefix, baseLabel) as Spanned
+ }
+ } else {
+ baseLabel
+ }
}
?: emptyList()
@@ -1255,6 +1281,33 @@ class GeneratorPlayer : FullScreenPlayer() {
subtitleOptionList.setItemChecked(subtitleOptionIndex, true)
}
+ fun toggleSecondarySubtitle(subtitle: SubtitleData?) {
+ ctx.vibrateDevice(55L)
+ val current = viewModel.state.secondarySubtitle
+ val next = if (subtitle != null && current?.getId() == subtitle.getId()) null else subtitle
+ viewModel.setSecondarySubtitle(next)
+ subtitleList.post { updateSubtitleOptionList() }
+ }
+
+ subtitleList.setOnItemLongClickListener { _, _, which, _ ->
+ if (which == 0) {
+ toggleSecondarySubtitle(null)
+ true
+ } else {
+ subtitlesGroupedList.getOrNull(which - 1)?.value?.firstOrNull()?.let {
+ toggleSecondarySubtitle(it)
+ }
+ true
+ }
+ }
+
+ subtitleOptionList.setOnItemLongClickListener { _, _, which, _ ->
+ subtitlesGroupedList.getOrNull(subtitleGroupIndex - 1)?.value?.getOrNull(which)?.let {
+ toggleSecondarySubtitle(it)
+ }
+ true
+ }
+
updateSubtitleOptionList()
subtitleList.setOnItemClickListener { _, _, which, _ ->
@@ -2335,6 +2388,10 @@ class GeneratorPlayer : FullScreenPlayer() {
autoSelectSubtitles()
}
}
+ observe(viewModel.currentSecondarySubtitle) { (subtitle, instance) ->
+ if (instance != viewModel.state.instance) return@observe
+ player.setSecondarySubtitles(subtitle)
+ }
observe(viewModel.loadingLinks) { (loading, instance) ->
if (instance != viewModel.state.instance) return@observe // Outdated observe
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt
index 0342372667f..d94520db4b0 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt
@@ -222,6 +222,9 @@ interface IPlayer {
fun getSubtitleOffset(): Long // in ms
fun setSubtitleOffset(offset: Long) // in ms
+ fun getSecondarySubtitleOffset(): Long = 0L
+ fun setSecondarySubtitleOffset(offset: Long) {}
+
@AnyThread
fun initCallbacks(
@MainThread eventHandler: ((PlayerEvent) -> Unit),
@@ -257,6 +260,9 @@ interface IPlayer {
fun setPreferredSubtitles(subtitle: SubtitleData?): Boolean // returns true if the player requires a reload, null for nothing
fun getCurrentPreferredSubtitle(): SubtitleData?
+ fun setSecondarySubtitles(subtitle: SubtitleData?) {}
+ fun getCurrentSecondarySubtitle(): SubtitleData? = null
+
fun handleEvent(event: CSPlayerEvent, source: PlayerEventSource = PlayerEventSource.UI)
fun onStop()
@@ -291,4 +297,5 @@ interface IPlayer {
/** Get the current subtitle cues, for use with syncing */
fun getSubtitleCues(): List
+ fun getSecondarySubtitleCues(): List = emptyList()
}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGeneratorViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGeneratorViewModel.kt
index cb8cf8bfff5..431fc9397a7 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGeneratorViewModel.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGeneratorViewModel.kt
@@ -54,6 +54,7 @@ data class DisplayLink(
// @Immutable
data class VideoState(
val subtitles: PersistentSet = persistentSetOf(),
+ val secondarySubtitle: SubtitleData? = null,
val links: PersistentSet = persistentSetOf(),
val erroredLinks: PersistentSet = persistentSetOf(),
val stamps: PersistentList = persistentListOf(),
@@ -196,6 +197,9 @@ class PlayerGeneratorViewModel : ViewModel() {
private val _currentSubtitles = MutableLiveData>>(null)
val currentSubtitles: LiveData>> = _currentSubtitles
+ private val _currentSecondarySubtitle = MutableLiveData>(null)
+ val currentSecondarySubtitle: LiveData> = _currentSecondarySubtitle
+
private val _loadingLinks = MutableLiveData>>()
val loadingLinks: LiveData>> = _loadingLinks
@@ -216,6 +220,7 @@ class PlayerGeneratorViewModel : ViewModel() {
/** New instance, always push state */
if (state.instance != oldState.instance) {
_currentSubtitles.postValue(VideoLive(state.subtitles, state.instance))
+ _currentSecondarySubtitle.postValue(VideoLive(state.secondarySubtitle, state.instance))
_currentStamps.postValue(VideoLive(state.stamps, state.instance))
_currentLinks.postValue(VideoLive(state.links, state.instance))
_loadingLinks.postValue(VideoLive(state.loading, state.instance))
@@ -234,6 +239,8 @@ class PlayerGeneratorViewModel : ViewModel() {
_currentStamps.postValue(VideoLive(state.stamps, state.instance))
if (state.subtitles !== oldState.subtitles)
_currentSubtitles.postValue(VideoLive(state.subtitles, state.instance))
+ if (state.secondarySubtitle !== oldState.secondarySubtitle)
+ _currentSecondarySubtitle.postValue(VideoLive(state.secondarySubtitle, state.instance))
/** Normal equality here as it is not a collection */
if (state.loading != oldState.loading)
@@ -254,6 +261,10 @@ class PlayerGeneratorViewModel : ViewModel() {
_currentSubtitleYear.postValue(year)
}
+ fun setSecondarySubtitle(subtitle: SubtitleData?) {
+ modifyState { copy(secondarySubtitle = subtitle) }
+ }
+
fun loadLinksPrev() {
Log.i(TAG, "loadLinksPrev")
if (generator?.hasPrev(episodeIndex) == true) {
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGestureHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGestureHelper.kt
index 1c7086d1238..c47e8aa9798 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGestureHelper.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGestureHelper.kt
@@ -11,6 +11,7 @@ import android.media.audiofx.LoudnessEnhancer
import android.os.Build
import android.os.Handler
import android.os.Looper
+import android.util.Log
import android.provider.Settings
import android.view.KeyEvent
import android.view.LayoutInflater
@@ -31,7 +32,9 @@ import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.preference.PreferenceManager
import com.lagradost.cloudstream3.CommonActivity.keyEventListener
+import com.lagradost.cloudstream3.CommonActivity.screenHeight
import com.lagradost.cloudstream3.CommonActivity.screenHeightWithOrientation
+import com.lagradost.cloudstream3.CommonActivity.screenWidth
import com.lagradost.cloudstream3.CommonActivity.screenWidthWithOrientation
import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.R
@@ -132,13 +135,37 @@ class PlayerGestureHelper(private val playerView: PlayerView) {
/** Hold / speed-up */
val holdHandler = Handler(Looper.getMainLooper())
var hasTriggeredSpeedUp = false
- val holdRunnable = Runnable {
+ val holdRunnable: Runnable = Runnable {
+ holdHandler.removeCallbacks(subRevealRunnable)
playerView.player.setPlaybackSpeed(2.0f)
showOrHideSpeedUp(true)
playerView.callbacks?.onHoldSpeedUp(true)
hasTriggeredSpeedUp = true
}
+ var hasTriggeredSubReveal = false
+ private var subRevealWasPlaying = false
+ val subRevealRunnable: Runnable = Runnable {
+ holdHandler.removeCallbacks(holdRunnable)
+ hasTriggeredSubReveal = true
+ subRevealWasPlaying = playerView.player.getIsPlaying()
+ if (subRevealWasPlaying) {
+ playerView.player.handleEvent(CSPlayerEvent.Pause, PlayerEventSource.UI)
+ }
+ Log.i(TAG, "subRevealRunnable triggered -> pausing and showing secondary subtitle")
+ playerView.callbacks?.onHoldSecondarySubtitle(true)
+ }
+
+ var hasTriggeredDualSub = false
+ val dualSubRunnable: Runnable = Runnable {
+ holdHandler.removeCallbacks(holdRunnable)
+ holdHandler.removeCallbacks(subRevealRunnable)
+ hasTriggeredDualSub = true
+ context.vibrateDevice(50L)
+ Log.i(TAG, "dualSubRunnable triggered -> opening dual subtitle comparison dialog")
+ playerView.callbacks?.onOpenDualSubtitleDialog()
+ }
+
enum class TouchAction { Brightness, Volume, Time }
/** Mirrors the host's lock state; suppresses gesture interactions when true. */
@@ -1047,15 +1074,22 @@ class PlayerGestureHelper(private val playerView: PlayerView) {
}
private fun isValidTouch(rawX: Float, rawY: Float): Boolean {
+ val holder = playerView.playerHolder ?: return true
+ val viewW = holder.width.takeIf { it > 0 } ?: screenWidth
+ val viewH = holder.height.takeIf { it > 0 } ?: screenHeight
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
- val holder = playerView.playerHolder ?: return true
- val insets = holder.rootWindowInsets.getInsetsIgnoringVisibility(WindowInsets.Type.systemBars())
- val validHeight = rawY > insets.top && rawY < screenHeightWithOrientation - insets.bottom
- val validWidth = rawX > insets.left && rawX < screenWidthWithOrientation - insets.right
- return validHeight && validWidth
+ val rootInsets = holder.rootWindowInsets
+ if (rootInsets != null) {
+ val visibleInsets = rootInsets.getInsets(WindowInsets.Type.systemBars())
+ val validHeight = rawY >= visibleInsets.top && rawY <= viewH - visibleInsets.bottom
+ val validWidth = rawX >= visibleInsets.left && rawX <= viewW - visibleInsets.right
+ return validHeight && validWidth
+ }
+ return true
}
- return rawY > context.getStatusBarHeight() && rawX < screenWidthWithOrientation
+ return rawY >= context.getStatusBarHeight() && rawX <= viewW
}
private fun handleGesture(view: View, event: MotionEvent): Boolean {
@@ -1066,6 +1100,8 @@ class PlayerGestureHelper(private val playerView: PlayerView) {
if ((event.pointerCount >= 2 || lastPan != null) && isFullScreen && !isLocked
&& !hasTriggeredSpeedUp && currentTouchAction == null) {
holdHandler.removeCallbacks(holdRunnable) // Remove 2x speed.
+ holdHandler.removeCallbacks(subRevealRunnable)
+ holdHandler.removeCallbacks(dualSubRunnable)
isCurrentTouchValid = false // Prevent other touches
return handleZoomPanGesture(
event = event,
@@ -1091,7 +1127,19 @@ class PlayerGestureHelper(private val playerView: PlayerView) {
if (isCurrentTouchValid) {
playerView.callbacks?.onTouchDown()
hasTriggeredSpeedUp = false
- if (speedupEnabled && playerView.player.getIsPlaying() && !isLocked) {
+ hasTriggeredSubReveal = false
+ hasTriggeredDualSub = false
+ holdHandler.removeCallbacks(holdRunnable)
+ holdHandler.removeCallbacks(subRevealRunnable)
+ holdHandler.removeCallbacks(dualSubRunnable)
+ val isTopRightCorner = event.x >= view.width * 0.75f && event.y <= view.height * 0.25f
+ val isRight30Percent = event.x >= view.width * 0.7f && !isTopRightCorner
+ Log.i(TAG, "ACTION_DOWN: isTopRightCorner=$isTopRightCorner, isRight30Percent=$isRight30Percent (x=${event.x}, y=${event.y}, w=${view.width}, h=${view.height}), secSub=${playerView.player.getCurrentSecondarySubtitle()}")
+ if (isTopRightCorner && !isLocked) {
+ holdHandler.postDelayed(dualSubRunnable, 400)
+ } else if (isRight30Percent && !isLocked && playerView.player.getCurrentSecondarySubtitle() != null) {
+ holdHandler.postDelayed(subRevealRunnable, 200)
+ } else if (speedupEnabled && playerView.player.getIsPlaying() && !isLocked) {
holdHandler.postDelayed(holdRunnable, 500)
}
isVolumeLocked = currentRequestedVolume < 1.0f
@@ -1109,7 +1157,7 @@ class PlayerGestureHelper(private val playerView: PlayerView) {
}
MotionEvent.ACTION_MOVE -> {
- if (hasTriggeredSpeedUp) return true
+ if (hasTriggeredSpeedUp || hasTriggeredSubReveal || hasTriggeredDualSub) return true
if (!isCurrentTouchValid) return true
if (currentTouchAction == null && startTouch != null) {
@@ -1117,6 +1165,8 @@ class PlayerGestureHelper(private val playerView: PlayerView) {
if (swipeVerticalEnabled) {
if (abs(diffFromStart.y * 100 / screenHeightWithOrientation) > MINIMUM_VERTICAL_SWIPE) {
holdHandler.removeCallbacks(holdRunnable)
+ holdHandler.removeCallbacks(subRevealRunnable)
+ holdHandler.removeCallbacks(dualSubRunnable)
uiShowingBeforeGesture = playerView.callbacks?.isUIShowing() ?: false
playerView.callbacks?.onHidePlayerUI()
currentTouchAction = if ((startTouch.x) >= view.width / 2f)
@@ -1126,6 +1176,8 @@ class PlayerGestureHelper(private val playerView: PlayerView) {
if (swipeHorizontalEnabled && !isLocked) {
if (abs(diffFromStart.x * 100 / screenHeightWithOrientation) > MINIMUM_HORIZONTAL_SWIPE) {
holdHandler.removeCallbacks(holdRunnable)
+ holdHandler.removeCallbacks(subRevealRunnable)
+ holdHandler.removeCallbacks(dualSubRunnable)
currentTouchAction = TouchAction.Time
}
}
@@ -1165,6 +1217,29 @@ class PlayerGestureHelper(private val playerView: PlayerView) {
MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP -> {
holdHandler.removeCallbacks(holdRunnable)
+ holdHandler.removeCallbacks(subRevealRunnable)
+ holdHandler.removeCallbacks(dualSubRunnable)
+ if (hasTriggeredDualSub) {
+ hasTriggeredDualSub = false
+ isCurrentTouchValid = false
+ currentTouchStart = null
+ currentLastTouchAction = null
+ currentTouchAction = null
+ currentTouchStartPlayerTime = null
+ currentTouchLast = null
+ currentTouchStartTime = null
+ uiShowingBeforeGesture = false
+ return true
+ }
+ if (hasTriggeredSubReveal) {
+ hasTriggeredSubReveal = false
+ val wasPlaying = subRevealWasPlaying
+ subRevealWasPlaying = false
+ playerView.callbacks?.onHoldSecondarySubtitle(false)
+ if (wasPlaying) {
+ playerView.player.handleEvent(CSPlayerEvent.Play, PlayerEventSource.UI)
+ }
+ }
if (hasTriggeredSpeedUp) {
playerView.player.setPlaybackSpeed(DataStoreHelper.playBackSpeed)
showOrHideSpeedUp(false)
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerSubtitleHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerSubtitleHelper.kt
index f62bad58d91..0e33a9706d9 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerSubtitleHelper.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerSubtitleHelper.kt
@@ -97,6 +97,7 @@ class PlayerSubtitleHelper {
}
var subtitleView: SubtitleView? = null
+ var secondarySubtitleView: SubtitleView? = null
companion object {
fun String.toSubtitleMimeType(): String {
@@ -135,10 +136,14 @@ class PlayerSubtitleHelper {
Log.i(TAG, "SET STYLE = $style")
subtitleView?.translationY = -style.elevation.toPx.toFloat()
setSubtitleViewStyle(subtitleView, style, true)
+ setSubtitleViewStyle(secondarySubtitleView, style.copy(backgroundColor = android.graphics.Color.TRANSPARENT, windowColor = android.graphics.Color.TRANSPARENT), false)
}
fun initSubtitles(subView: SubtitleView?, subHolder: FrameLayout?, style: SaveCaptionStyle?) {
subtitleView = subView
+ secondarySubtitleView = subHolder?.findViewById(com.lagradost.cloudstream3.R.id.secondary_subtitle_view)
+ secondarySubtitleView?.isClickable = false
+ secondarySubtitleView?.isLongClickable = false
subView?.let { sView ->
(sView.parent as ViewGroup?)?.removeView(sView)
subHolder?.addView(sView)
@@ -148,3 +153,18 @@ class PlayerSubtitleHelper {
}
}
}
+
+fun android.content.Context.vibrateDevice(durationMillis: Long = 55L) {
+ try {
+ val vibrator = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
+ getSystemService(android.os.VibratorManager::class.java)?.defaultVibrator
+ } else {
+ @Suppress("DEPRECATION") getSystemService(android.content.Context.VIBRATOR_SERVICE) as? android.os.Vibrator
+ }
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
+ vibrator?.vibrate(android.os.VibrationEffect.createOneShot(durationMillis, android.os.VibrationEffect.DEFAULT_AMPLITUDE))
+ } else {
+ @Suppress("DEPRECATION") vibrator?.vibrate(durationMillis)
+ }
+ } catch (_: Throwable) {}
+}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerView.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerView.kt
index 0e6f1a3677d..c60658d3771 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerView.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerView.kt
@@ -133,6 +133,8 @@ class PlayerView @JvmOverloads constructor(
fun onSingleTap() {}
/** Called when the hold-for-speedup gesture starts (show=true) or ends (show=false). */
fun onHoldSpeedUp(show: Boolean) {}
+ fun onHoldSecondarySubtitle(show: Boolean) {}
+ fun onOpenDualSubtitleDialog() {}
/** Called during brightness swipe with the current extra-brightness alpha (0–1). */
fun onBrightnessExtra(alpha: Float) {}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/SubtitlesFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/SubtitlesFragment.kt
index dd363fc10be..851383a7551 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/SubtitlesFragment.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/SubtitlesFragment.kt
@@ -39,6 +39,7 @@ import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.databinding.SubtitleSettingsBinding
import com.lagradost.cloudstream3.ui.BaseDialogFragment
import com.lagradost.cloudstream3.ui.BaseFragment
+import com.lagradost.cloudstream3.utils.DataStoreHelper
import com.lagradost.cloudstream3.ui.player.CustomDecoder
import com.lagradost.cloudstream3.ui.player.CustomDecoder.Companion.setSubtitleAlignment
import com.lagradost.cloudstream3.ui.player.OutlineSpan
@@ -652,6 +653,11 @@ class SubtitlesFragment : BaseDialogFragment(
}
}
+ alwaysShowSecondarySubtitles.isChecked = DataStoreHelper.alwaysShowSecondarySubtitles
+ alwaysShowSecondarySubtitles.setOnCheckedChangeListener { _, b ->
+ DataStoreHelper.alwaysShowSecondarySubtitles = b
+ }
+
subtitlesRemoveBloat.isChecked = state.removeBloat
subtitlesRemoveBloat.setOnCheckedChangeListener { _, b ->
state = state.copy(removeBloat = b)
diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt
index eabac20d322..6f937c03921 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt
@@ -146,6 +146,7 @@ object DataStoreHelper {
var playBackSpeed: Float by UserPreferenceDelegate("playback_speed", 1.0f)
var resizeMode: Int by UserPreferenceDelegate("resize_mode", 0)
+ var alwaysShowSecondarySubtitles: Boolean by UserPreferenceDelegate("always_show_secondary_subtitles", false)
var librarySortingMode: Int by UserPreferenceDelegate(
"library_sorting_mode",
ListSorting.AlphabeticalA.ordinal
diff --git a/app/src/main/res/layout/dialog_dual_subtitles.xml b/app/src/main/res/layout/dialog_dual_subtitles.xml
new file mode 100644
index 00000000000..638acf5ac79
--- /dev/null
+++ b/app/src/main/res/layout/dialog_dual_subtitles.xml
@@ -0,0 +1,110 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/dialog_dual_subtitles_item.xml b/app/src/main/res/layout/dialog_dual_subtitles_item.xml
new file mode 100644
index 00000000000..2eb3a7cea91
--- /dev/null
+++ b/app/src/main/res/layout/dialog_dual_subtitles_item.xml
@@ -0,0 +1,78 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/player_custom_layout.xml b/app/src/main/res/layout/player_custom_layout.xml
index 5ccc3ff09ac..77c41acf602 100644
--- a/app/src/main/res/layout/player_custom_layout.xml
+++ b/app/src/main/res/layout/player_custom_layout.xml
@@ -1090,6 +1090,13 @@
android:layout_width="match_parent"
android:layout_height="match_parent">
+
+
+
+
+
+
Sync subs
1000 ms
Subtitle delay
+ Secondary subtitle delay
Use this if the subtitles are shown %d ms too early
Use this if subtitles are shown %d ms too late
No subtitle delay
@@ -490,6 +491,7 @@
Error
Remove closed captions from subtitles
Remove ads from subtitles
+ Always show secondary subtitle
Filter by preferred media language
Extras
Trailer