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 d316f28cd19..c010fa5a04b 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
@@ -110,11 +113,23 @@ import com.lagradost.cloudstream3.utils.WIDEVINE_DRM_UUID
import com.lagradost.cloudstream3.utils.videoskip.VideoSkipStamp
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
@@ -133,6 +148,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
@@ -172,6 +246,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
@@ -284,6 +379,7 @@ class CS3IPlayer : IPlayer {
saveData()
} else {
currentSubtitles = subtitle
+ loadPrimaryCues(subtitle)
playbackPosition = 0
}
@@ -492,52 +588,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
@@ -555,16 +689,243 @@ 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(" {
+ val bytes = fetchSubtitleBytes(subtitle) ?: return emptyList()
+ val decoder = CustomDecoder(Format.Builder().setSampleMimeType(subtitle.mimeType).build())
+ decoder.parseToLegacySubtitle(bytes, 0, bytes.size)
+ return synchronized(decoder.currentSubtitleCues) { decoder.currentSubtitleCues.toList() }
+ }
+
+ private fun fetchSubtitleBytes(subtitle: SubtitleData): ByteArray? {
+ return when (subtitle.origin) {
+ SubtitleOrigin.URL -> fetchSubtitleFromUrl(subtitle)
+ SubtitleOrigin.DOWNLOADED_FILE -> {
+ val rawUrl = subtitle.url
+ try {
+ val file = File(rawUrl)
+ if (file.exists() && file.isFile) {
+ return file.readBytes()
+ }
+ val uri = Uri.parse(rawUrl)
+ if (uri.scheme == "file") {
+ val f = File(uri.path ?: "")
+ if (f.exists() && f.isFile) return f.readBytes()
+ }
+ CloudStreamApp.context?.contentResolver?.openInputStream(uri)?.use { it.readBytes() }
+ } catch (t: Throwable) {
+ logError(t)
+ null
+ }
+ }
+ 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 setDirectSecondaryCues(cues: List, subtitle: SubtitleData?) {
+ val generation = secondarySubtitleGeneration.incrementAndGet()
+ secondarySubtitleFuture?.cancel(true)
+ secondarySubtitleFuture = null
+ currentSecondarySubtitle = subtitle
+ secondaryCues = cues
+ lastSecondaryCueSignature = emptyList()
+ latestEmbeddedSecondaryCues = emptyList()
+ synchronized(embeddedSecondaryCues) { embeddedSecondaryCues.clear() }
+ pushSecondaryCues()
+ applySubtitleSelection()
+ Log.i(TAG, "setDirectSecondaryCues applied directly with ${cues.size} cues")
+ }
+
+ 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,
+ typeface = 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? {
@@ -594,8 +955,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
@@ -650,6 +1026,7 @@ class CS3IPlayer : IPlayer {
override fun release() {
imageGenerator.release()
releasePlayer()
+ secondarySubtitleExecutor.shutdownNow()
}
override fun setPlaybackSpeed(speed: Float) {
@@ -868,8 +1245,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
@@ -897,6 +1274,7 @@ class CS3IPlayer : IPlayer {
writePosition: Long? = null,
source: PlayerEventSource = PlayerEventSource.Player
) {
+ pushSecondaryCues()
val position = writePosition ?: exoPlayer?.currentPosition
getCurrentTimestamp(position)?.let { timestamp ->
@@ -1182,44 +1560,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()
}
- factory.createRenderers(
+ val secondaryTextOutput = TextOutput { cueGroup ->
+ val baseStyle = CustomDecoder.style ?: SaveCaptionStyle(
+ foregroundColor = Color.WHITE,
+ backgroundColor = Color.TRANSPARENT,
+ windowColor = Color.TRANSPARENT,
+ edgeType = 1,
+ edgeColor = Color.BLACK,
+ typeface = 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()
+ }
+
+ 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
+ }
+
+ val secondaryDecoder = CustomSubtitleDecoderFactory()
+ val secondaryRenderer = TextRenderer(
+ secondaryTextOutput,
+ eventHandler.looper,
+ secondaryDecoder
+ ).apply {
+ @Suppress("DEPRECATION")
+ experimentalSetLegacyDecodingEnabled(true)
}
- currentTextRenderer
- } else
- it
- }.toTypedArray()
+
+ 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))
@@ -1484,6 +1936,7 @@ class CS3IPlayer : IPlayer {
event(EmbeddedSubtitlesFetchedEvent(tracks = exoPlayerReportedTracks))
event(TracksChangedEvent())
event(SubtitlesUpdatedEvent())
+ applySubtitleSelection()
}
}
@@ -1718,7 +2171,9 @@ class CS3IPlayer : IPlayer {
): Pair, List> {
val activeSubtitles = ArrayList()
val subSources = subHelper.getAllSubtitles().mapNotNull { sub ->
- val subConfig = MediaItem.SubtitleConfiguration.Builder(sub.getFixedUrl().toUri())
+ val fixedUrl = sub.getFixedUrl()
+ val uri = if (fixedUrl.startsWith("/")) File(fixedUrl).toUri() else fixedUrl.toUri()
+ val subConfig = MediaItem.SubtitleConfiguration.Builder(uri)
.setMimeType(sub.mimeType)
.setLanguage("_${sub.name}")
.setId(sub.getId())
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..3d497c94740 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,10 @@ open class FullScreenPlayer : AbstractPlayerFragment(
track.sampleMimeType == MimeTypes.APPLICATION_MEDIA3_CUES
}
// Subtitle offset is not possible on built-in media3 tracks
- playerBinding?.playerSubtitleOffsetBtt?.isGone =
- isBuiltinSubtitles || tracks.currentTextTracks.isEmpty()
+ val hasSecondarySub = player.getCurrentSecondarySubtitle() != null
+ val noSubs = (isBuiltinSubtitles || tracks.currentTextTracks.isEmpty()) && !hasSecondarySub
+ playerBinding?.playerSubtitleOffsetBtt?.isGone = noSubs
+ val hasAnySubs = tracks.currentTextTracks.isNotEmpty() || hasSecondarySub
}
private fun restoreOrientationWithSensor(activity: Activity) {
@@ -496,7 +513,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 +534,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 +555,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 +578,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 +629,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 +656,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 +970,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 +985,65 @@ 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()
+ }
+
+ private val subJumpHideRunnable = Runnable {
+ playerBinding?.playerTimeText?.isVisible = false
+ }
+
+ private fun jumpToSubtitle(next: Boolean) {
+ val pos = player.getPosition() ?: return
+ val primary = player.getSubtitleCues()
+ val secondary = player.getSecondarySubtitleCues()
+ val cues = (if (primary.size >= secondary.size && primary.isNotEmpty()) primary else secondary)
+ .sortedBy { it.startTimeMs }
+
+ if (cues.isEmpty()) {
+ context?.let { ctx ->
+ com.lagradost.cloudstream3.CommonActivity.showToast(activity, "No subtitles loaded", android.widget.Toast.LENGTH_SHORT)
+ }
+ return
+ }
+
+ val target = if (next) {
+ cues.firstOrNull { it.startTimeMs > pos + 400L }
+ } else {
+ val currentCue = cues.lastOrNull { it.startTimeMs <= pos && pos <= it.endTimeMs + 500L }
+ if (currentCue != null && pos - currentCue.startTimeMs > 1000L) {
+ currentCue
+ } else {
+ cues.lastOrNull { it.startTimeMs < pos - 1000L } ?: cues.firstOrNull()
+ }
+ }
+
+ if (target != null) {
+ context?.vibrateDevice(35L)
+ player.seekTo(target.startTimeMs, PlayerEventSource.UI)
+ val snippet = target.text.firstOrNull()?.replace("\n", " ")?.trim()?.take(45) ?: ""
+ val hudText = "${if (next) "⏭" else "⏮"} $snippet"
+ playerBinding?.playerTimeText?.apply {
+ isVisible = true
+ text = hudText
+ removeCallbacks(subJumpHideRunnable)
+ postDelayed(subJumpHideRunnable, 1200L)
+ }
+ }
+ }
+
+ override fun onJumpSubtitle(next: Boolean) {
+ jumpToSubtitle(next)
+ }
+
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
@@ -1090,6 +1275,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 +1300,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 +1441,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/GeminiSubtitleTranslator.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeminiSubtitleTranslator.kt
new file mode 100644
index 00000000000..25d9a19baa9
--- /dev/null
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeminiSubtitleTranslator.kt
@@ -0,0 +1,204 @@
+package com.lagradost.cloudstream3.ui.player
+
+import android.content.Context
+import com.lagradost.cloudstream3.app
+import com.lagradost.cloudstream3.mvvm.logError
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import org.json.JSONArray
+import org.json.JSONObject
+import java.io.File
+import java.util.concurrent.TimeUnit
+
+object GeminiSubtitleTranslator {
+ private const val BATCH_SIZE = 50
+ private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaType()
+
+ private val httpClient by lazy {
+ app.baseClient.newBuilder()
+ .callTimeout(60, TimeUnit.SECONDS)
+ .readTimeout(45, TimeUnit.SECONDS)
+ .connectTimeout(15, TimeUnit.SECONDS)
+ .build()
+ }
+
+ suspend fun translateCues(
+ cues: List,
+ targetLanguage: String,
+ key: String
+ ): Result> = withContext(Dispatchers.IO) {
+ runCatching {
+ if (cues.isEmpty()) return@runCatching emptyList()
+
+ val translatedCues = mutableListOf()
+ val chunks = cues.chunked(BATCH_SIZE)
+ var successfulChunks = 0
+
+ for ((chunkIdx, chunk) in chunks.withIndex()) {
+ val promptBuilder = StringBuilder()
+ promptBuilder.append("Translate each line into $targetLanguage. Output ONLY the translated lines with their exact line numbers [N] so lines match up, with no introductory or concluding text:\n")
+ chunk.forEachIndexed { index, cue ->
+ val text = cue.text.joinToString(" ").replace("\n", " ").trim()
+ promptBuilder.append("[${index + 1}] $text\n")
+ }
+
+ val lineMap = try {
+ val responseText = callGeminiWithFallback(promptBuilder.toString(), key)
+ val parsed = parseNumberedLines(responseText)
+ if (parsed.isNotEmpty()) successfulChunks++
+ parsed
+ } catch (e: Exception) {
+ logError(e)
+ emptyMap()
+ }
+
+ chunk.forEachIndexed { index, cue ->
+ val translatedText = lineMap[index + 1] ?: cue.text.joinToString(" ")
+ translatedCues.add(
+ SubtitleCue(
+ startTimeMs = cue.startTimeMs,
+ durationMs = if (cue.durationMs > 0) cue.durationMs else 2500L,
+ text = listOf(translatedText)
+ )
+ )
+ }
+ }
+
+ if (successfulChunks == 0 && cues.isNotEmpty()) {
+ throw Exception("AI translation failed: could not connect to Gemini or invalid API key")
+ }
+
+ translatedCues
+ }
+ }
+
+ private fun callGeminiWithFallback(prompt: String, key: String): String {
+ val models = listOf("gemini-2.5-flash", "gemini-3.5-flash", "gemini-flash-latest")
+ var lastException: Exception? = null
+
+ for (model in models) {
+ try {
+ return executeGeminiRequest(model, prompt, key)
+ } catch (e: Exception) {
+ logError(e)
+ lastException = e
+ try { Thread.sleep(500) } catch (_: Throwable) {}
+ }
+ }
+ throw lastException ?: Exception("Gemini request failed: all models returned error")
+ }
+
+ private fun executeGeminiRequest(model: String, prompt: String, key: String): String {
+ val url = "https://generativelanguage.googleapis.com/v1beta/models/$model:generateContent?key=$key"
+
+ val jsonPayload = JSONObject().apply {
+ val contents = JSONArray().apply {
+ val contentObj = JSONObject().apply {
+ val parts = JSONArray().apply {
+ put(JSONObject().apply {
+ put("text", prompt)
+ })
+ }
+ put("parts", parts)
+ }
+ put(contentObj)
+ }
+ put("contents", contents)
+
+ val genConfig = JSONObject().apply {
+ val thinkingConfig = JSONObject().apply {
+ put("thinkingBudget", 0)
+ }
+ put("thinkingConfig", thinkingConfig)
+ put("temperature", 0.2)
+ }
+ put("generationConfig", genConfig)
+ }
+
+ val request = Request.Builder()
+ .url(url)
+ .post(jsonPayload.toString().toRequestBody(JSON_MEDIA_TYPE))
+ .build()
+
+ val response = httpClient.newCall(request).execute()
+ val responseBody = response.body.string()
+
+ if (!response.isSuccessful) {
+ throw Exception("API call to $model failed with code ${response.code}: $responseBody")
+ }
+
+ val root = JSONObject(responseBody)
+ val candidates = root.optJSONArray("candidates")
+ ?: throw Exception("No candidates in response: $responseBody")
+ if (candidates.length() == 0) throw Exception("Empty candidates list")
+
+ val firstCandidate = candidates.getJSONObject(0)
+ val content = firstCandidate.optJSONObject("content")
+ ?: throw Exception("No content in candidate: $responseBody")
+ val parts = content.optJSONArray("parts")
+ ?: throw Exception("No parts in candidate content: $responseBody")
+ if (parts.length() == 0) throw Exception("Empty parts list")
+
+ val sb = StringBuilder()
+ for (i in 0 until parts.length()) {
+ val part = parts.optJSONObject(i) ?: continue
+ val text = part.optString("text", "")
+ if (text.isNotBlank()) {
+ sb.append(text).append("\n")
+ }
+ }
+ val resultText = sb.toString().trim()
+ if (resultText.isEmpty()) throw Exception("Empty text returned from $model")
+ return resultText
+ }
+
+ private fun parseNumberedLines(response: String): Map {
+ val result = mutableMapOf()
+ val lineRegex = Regex("""^\[?(\d+)\]?[\s.:\)-]*(.*)$""")
+
+ response.lines().forEach { rawLine ->
+ val line = rawLine.replace("*", "").trim()
+ val match = lineRegex.find(line)
+ if (match != null) {
+ val num = match.groupValues[1].toIntOrNull()
+ val text = match.groupValues[2].trim()
+ if (num != null && text.isNotEmpty()) {
+ result[num] = text
+ }
+ }
+ }
+ return result
+ }
+
+ fun cuesToVttFile(cues: List, targetFile: File): File {
+ targetFile.parentFile?.let { if (!it.exists()) it.mkdirs() }
+ targetFile.bufferedWriter().use { writer ->
+ writer.write("WEBVTT\n\n")
+ cues.forEach { cue ->
+ val start = formatVttTime(cue.startTimeMs)
+ val duration = if (cue.durationMs > 0) cue.durationMs else 2500L
+ val end = formatVttTime(cue.startTimeMs + duration)
+ val text = cue.text.joinToString("\n").ifBlank { "..." }
+ writer.write("$start --> $end\n$text\n\n")
+ }
+ }
+ return targetFile
+ }
+
+ fun cuesToVttFile(context: Context, cues: List, fileName: String): File {
+ val file = File(context.cacheDir, fileName)
+ return cuesToVttFile(cues, file)
+ }
+
+ private fun formatVttTime(ms: Long): String {
+ val totalSeconds = ms.coerceAtLeast(0) / 1000
+ val millis = ms.coerceAtLeast(0) % 1000
+ val seconds = totalSeconds % 60
+ val minutes = totalSeconds / 60 % 60
+ val hours = totalSeconds / 3600
+ return "%02d:%02d:%02d.%03d".format(hours, minutes, seconds, millis)
+ }
+}
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 4495a560262..bb4da0874b5 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
@@ -2,6 +2,7 @@ package com.lagradost.cloudstream3.ui.player
import android.animation.ValueAnimator
import android.annotation.SuppressLint
+import android.app.Activity
import android.app.Dialog
import android.app.PendingIntent
import android.content.Context
@@ -11,8 +12,11 @@ import android.graphics.Bitmap
import android.graphics.Typeface
import android.os.Build
import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
import android.text.Spanned
import android.util.Log
+import java.io.File
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
@@ -907,6 +911,284 @@ class GeneratorPlayer : FullScreenPlayer() {
)
}
+ private var isAiTranslating: Boolean = false
+ private var currentAiTranslatingLang: String? = null
+ private var activeAiFooter: TextView? = null
+ private val aiHandler = Handler(Looper.getMainLooper())
+ private var aiAnimationRunnable: Runnable? = null
+ private val aiDotsFrames = listOf(". ", ".. ", "...", " ..", " .", " ")
+ private var aiDotsIndex = 0
+
+ private fun startAiFooterAnimation(footer: TextView, lang: String) {
+ activeAiFooter = footer
+ footer.isEnabled = false
+ if (aiAnimationRunnable == null) {
+ aiDotsIndex = 0
+ val r = object : Runnable {
+ override fun run() {
+ if (!isAiTranslating) return
+ val frame = aiDotsFrames[aiDotsIndex % aiDotsFrames.size]
+ aiDotsIndex++
+ activeAiFooter?.text = "⏳ Translating to $lang $frame"
+ aiHandler.postDelayed(this, 300L)
+ }
+ }
+ aiAnimationRunnable = r
+ aiHandler.post(r)
+ } else {
+ val frame = aiDotsFrames[aiDotsIndex % aiDotsFrames.size]
+ footer.text = "⏳ Translating to $lang $frame"
+ }
+ }
+
+ private fun stopAiFooterAnimation(ctx: Context?) {
+ aiAnimationRunnable?.let { aiHandler.removeCallbacks(it) }
+ aiAnimationRunnable = null
+ activeAiFooter?.apply {
+ text = ctx?.getString(R.string.translate_with_ai) ?: "Translate with AI"
+ isEnabled = true
+ }
+ activeAiFooter = null
+ }
+
+ private fun getAiSubDir(ctx: Context): File {
+ val dir = File(ctx.filesDir, "ai_subtitles")
+ if (!dir.exists()) dir.mkdirs()
+ return dir
+ }
+
+ private fun getMediaKey(): String {
+ val meta = getMetaData()
+ val raw = meta.name ?: currentSelectedLink?.first?.name ?: currentSelectedLink?.first?.url ?: "media"
+ val safe = raw.replace(Regex("[^a-zA-Z0-9_]"), "_").trim('_')
+ val season = meta.season?.let { "_S$it" } ?: ""
+ val ep = meta.episode?.let { "_E$it" } ?: ""
+ return "${safe}${season}${ep}".ifBlank { "media_default" }
+ }
+
+ private fun loadCachedAiSubtitles(ctx: Context) {
+ try {
+ val dir = getAiSubDir(ctx)
+ val mediaKey = getMediaKey()
+ val files = dir.listFiles { f: File ->
+ f.isFile && f.name.startsWith("ai_${mediaKey}_") && f.name.endsWith(".vtt")
+ } ?: return
+
+ val loaded = files.mapNotNull { f: File ->
+ val lang = f.name.removePrefix("ai_${mediaKey}_").removeSuffix(".vtt")
+ SubtitleData(
+ originalName = "AI: $lang",
+ nameSuffix = "",
+ url = f.absolutePath,
+ origin = SubtitleOrigin.DOWNLOADED_FILE,
+ mimeType = "text/vtt",
+ headers = emptyMap(),
+ languageCode = lang
+ )
+ }
+ if (loaded.isNotEmpty()) {
+ val existing = viewModel.state.subtitles.map { sub: SubtitleData -> sub.url }.toSet()
+ val newOnes = loaded.filter { sub: SubtitleData -> sub.url !in existing }
+ if (newOnes.isNotEmpty()) {
+ viewModel.addSubtitles(newOnes.toSet())
+ }
+ }
+ } catch (e: Exception) {
+ logError(e)
+ }
+ }
+
+ private fun handleAiSubtitleTranslationClick(
+ ctx: Context,
+ sourceDialog: Dialog,
+ footer: TextView,
+ onUpdateList: () -> Unit
+ ) {
+ if (isAiTranslating) {
+ showToast("AI is already translating subtitles (${currentAiTranslatingLang ?: "..."}). Please wait.")
+ return
+ }
+ val act = activity ?: return
+
+ val key = DataStoreHelper.geminiApiKey
+ if (key.isNullOrBlank()) {
+ promptGeminiApiKey(act) { enteredKey ->
+ pickLanguageAndTranslate(ctx, act, sourceDialog, footer, enteredKey, onUpdateList)
+ }
+ } else {
+ pickLanguageAndTranslate(ctx, act, sourceDialog, footer, key, onUpdateList)
+ }
+ }
+
+ private fun promptGeminiApiKey(act: Activity, onKeyEntered: (String) -> Unit) {
+ val input = android.widget.EditText(act).apply {
+ setSingleLine()
+ inputType = android.text.InputType.TYPE_CLASS_TEXT or android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD
+ }
+ androidx.appcompat.app.AlertDialog.Builder(act)
+ .setTitle(R.string.gemini_api_key_prompt)
+ .setView(input)
+ .setPositiveButton(android.R.string.ok) { _, _ ->
+ val entered = input.text.toString().trim()
+ if (entered.isNotBlank()) {
+ DataStoreHelper.geminiApiKey = entered
+ onKeyEntered(entered)
+ } else {
+ showToast(R.string.gemini_api_key_empty)
+ }
+ }
+ .setNegativeButton(android.R.string.cancel, null)
+ .show()
+ }
+
+ private fun pickLanguageAndTranslate(
+ ctx: Context,
+ act: Activity,
+ sourceDialog: Dialog,
+ footer: TextView,
+ authKey: String,
+ onUpdateList: () -> Unit
+ ) {
+ val languages = listOf(
+ "🇦🇿 Azerbaijani" to "Azerbaijani",
+ "🇹🇷 Turkish" to "Turkish",
+ "🇬🇧 English" to "English",
+ "🇷🇺 Russian" to "Russian",
+ "🇩🇪 German" to "German",
+ "🇪🇸 Spanish" to "Spanish",
+ "🇫🇷 French" to "French",
+ "🇮🇹 Italian" to "Italian",
+ "🇸🇦 Arabic" to "Arabic",
+ "🇵🇹 Portuguese" to "Portuguese"
+ )
+ val displayItems = languages.map { it.first }
+
+ com.lagradost.cloudstream3.utils.SingleSelectionHelper.run {
+ act.showDialogNoCheckmark(
+ items = displayItems,
+ name = ctx.getString(R.string.gemini_target_language),
+ dismissCallback = {},
+ callback = { index: Int ->
+ val chosen = languages.getOrNull(index)?.second ?: "Azerbaijani"
+ DataStoreHelper.geminiTargetLanguage = chosen
+
+ val dir = getAiSubDir(ctx)
+ val mediaKey = getMediaKey()
+ val cachedFile = File(dir, "ai_${mediaKey}_${chosen}.vtt")
+ if (cachedFile.exists() && cachedFile.length() > 50) {
+ showToast("Loading cached translation for $chosen…")
+ val cachedSub = SubtitleData(
+ originalName = "AI: $chosen",
+ nameSuffix = "",
+ url = cachedFile.absolutePath,
+ origin = SubtitleOrigin.DOWNLOADED_FILE,
+ mimeType = "text/vtt",
+ headers = emptyMap(),
+ languageCode = chosen
+ )
+ addAndSelectSubtitles(cachedSub)
+ sourceDialog.dismissSafe(activity)
+ return@showDialogNoCheckmark
+ }
+
+ val pendingSub = SubtitleData(
+ originalName = "AI: $chosen (⏳ Translating...)",
+ nameSuffix = "",
+ url = "pending_ai_translation",
+ origin = SubtitleOrigin.DOWNLOADED_FILE,
+ mimeType = "text/vtt",
+ headers = emptyMap(),
+ languageCode = chosen
+ )
+ viewModel.addSubtitles(setOf(pendingSub))
+ onUpdateList()
+
+ executeAiTranslation(ctx, sourceDialog, footer, authKey, chosen, pendingSub, onUpdateList)
+ }
+ )
+ }
+ }
+
+ private fun executeAiTranslation(
+ ctx: Context,
+ sourceDialog: Dialog,
+ footer: TextView,
+ authKey: String,
+ targetLang: String,
+ pendingSub: SubtitleData,
+ onUpdateList: () -> Unit
+ ) {
+ showToast("${ctx.getString(R.string.translating_subtitles)} ($targetLang)")
+ isAiTranslating = true
+ currentAiTranslatingLang = targetLang
+ startAiFooterAnimation(footer, targetLang)
+
+ ioSafe {
+ var cues = player.getSubtitleCues().ifEmpty { player.getSecondarySubtitleCues() }
+ if (cues.isEmpty()) {
+ val activeSub = player.getCurrentPreferredSubtitle()
+ ?: player.getCurrentSecondarySubtitle()
+ ?: viewModel.state.subtitles.firstOrNull { it.url != "pending_ai_translation" }
+ if (activeSub != null) {
+ cues = player.loadSubtitleCues(activeSub)
+ }
+ }
+
+ if (cues.isEmpty()) {
+ activity?.runOnUiThread {
+ isAiTranslating = false
+ currentAiTranslatingLang = null
+ stopAiFooterAnimation(ctx)
+ showToast("No subtitles found. Please select a subtitle first.")
+ viewModel.removeSubtitles(setOf(pendingSub))
+ onUpdateList()
+ }
+ return@ioSafe
+ }
+
+ val result = GeminiSubtitleTranslator.translateCues(cues, targetLang, authKey)
+ val translated = result.getOrNull()
+ if (result.isSuccess && translated != null) {
+ val dir = getAiSubDir(ctx)
+ val mediaKey = getMediaKey()
+ val targetFile = File(dir, "ai_${mediaKey}_${targetLang}.vtt")
+ GeminiSubtitleTranslator.cuesToVttFile(translated, targetFile)
+
+ val aiSubData = SubtitleData(
+ originalName = "AI: $targetLang",
+ nameSuffix = "",
+ url = targetFile.absolutePath,
+ origin = SubtitleOrigin.DOWNLOADED_FILE,
+ mimeType = "text/vtt",
+ headers = emptyMap(),
+ languageCode = targetLang
+ )
+
+ activity?.runOnUiThread {
+ isAiTranslating = false
+ currentAiTranslatingLang = null
+ stopAiFooterAnimation(ctx)
+
+ viewModel.removeSubtitles(setOf(pendingSub))
+ addAndSelectSubtitles(aiSubData)
+ showToast(R.string.translation_completed)
+ sourceDialog.dismissSafe(activity)
+ }
+ } else {
+ val errorMsg = result.exceptionOrNull()?.message ?: "Unknown error"
+ activity?.runOnUiThread {
+ isAiTranslating = false
+ currentAiTranslatingLang = null
+ stopAiFooterAnimation(ctx)
+
+ showToast(ctx.getString(R.string.translation_failed, errorMsg))
+ viewModel.removeSubtitles(setOf(pendingSub))
+ onUpdateList()
+ }
+ }
+ }
+ }
+
// Open file picker
private val subsPathPicker =
registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
@@ -1016,6 +1298,7 @@ class GeneratorPlayer : FullScreenPlayer() {
currentSelectedSubtitles = player.getCurrentPreferredSubtitle()
//println("CURRENT SELECTED :$currentSelectedSubtitles of $currentSubs")
context?.let { ctx ->
+ loadCachedAiSubtitles(ctx)
val isPlaying = player.getIsPlaying()
player.handleEvent(CSPlayerEvent.Pause, PlayerEventSource.UI)
val currentSubtitles = sortSubs(viewModel.state.subtitles)
@@ -1042,6 +1325,17 @@ class GeneratorPlayer : FullScreenPlayer() {
}
subtitleList.addFooterView(loadFromFileFooter)
+ val translateWithAiFooter: TextView =
+ layoutInflater.inflate(R.layout.sort_bottom_footer_add_choice, null) as TextView
+ if (isAiTranslating) {
+ val lang = currentAiTranslatingLang ?: "..."
+ startAiFooterAnimation(translateWithAiFooter, lang)
+ } else {
+ translateWithAiFooter.text = ctx.getString(R.string.translate_with_ai)
+ translateWithAiFooter.isEnabled = true
+ }
+ subtitleList.addFooterView(translateWithAiFooter)
+
var shouldDismiss = true
binding.subtitleSettingsBtt.setOnClickListener {
@@ -1056,6 +1350,9 @@ class GeneratorPlayer : FullScreenPlayer() {
if (isPlaying) {
player.handleEvent(CSPlayerEvent.Play)
}
+ if (activeAiFooter === translateWithAiFooter) {
+ activeAiFooter = null
+ }
activity?.hideSystemUI()
}
@@ -1201,7 +1498,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
@@ -1227,19 +1537,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()
@@ -1254,20 +1577,66 @@ 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 {
+ val sub = subtitlesGroupedList.getOrNull(which - 1)?.value?.firstOrNull()
+ if (sub?.url == "pending_ai_translation") {
+ showToast("AI is translating subtitles... Please wait a moment.")
+ true
+ } else {
+ sub?.let { toggleSecondarySubtitle(it) }
+ true
+ }
+ }
+ }
+
+ subtitleOptionList.setOnItemLongClickListener { _, _, which, _ ->
+ val sub = subtitlesGroupedList.getOrNull(subtitleGroupIndex - 1)?.value?.getOrNull(which)
+ if (sub?.url == "pending_ai_translation") {
+ showToast("AI is translating subtitles... Please wait a moment.")
+ true
+ } else {
+ sub?.let { toggleSecondarySubtitle(it) }
+ true
+ }
+ }
+
updateSubtitleOptionList()
+ translateWithAiFooter.setOnClickListener {
+ handleAiSubtitleTranslationClick(
+ ctx,
+ sourceDialog,
+ translateWithAiFooter,
+ ::updateSubtitleOptionList
+ )
+ }
+
subtitleList.setOnItemClickListener { _, _, which, _ ->
- if (which > subtitlesGrouped.size) {
- // Since android TV is funky the setOnItemClickListener will be triggered
- // instead of setOnClickListener when selecting. To override this we programmatically
- // click the view when selecting an item outside the list.
-
- // Cheeky way of getting the view at that position to click it
- // to avoid keeping track of the various footers.
- // getChildAt() gives null :(
+ if (which == subtitlesGrouped.size + 1) {
+ loadFromFileFooter.performClick()
+ } else if (which == subtitlesGrouped.size + 2) {
+ translateWithAiFooter.performClick()
+ } else if (which > subtitlesGrouped.size) {
val child = subtitleList.adapter.getView(which, null, subtitleList)
child?.performClick()
} else {
+ val sub = subtitlesGroupedList.getOrNull(which - 1)?.value?.firstOrNull()
+ if (sub?.url == "pending_ai_translation") {
+ showToast("AI is translating subtitles... Please wait a moment.")
+ return@setOnItemClickListener
+ }
if (subtitleGroupIndex != which) {
subtitleGroupIndex = which
subtitleOptionIndex =
@@ -1289,6 +1658,11 @@ class GeneratorPlayer : FullScreenPlayer() {
val child = subtitleOptionList.adapter.getView(which, null, subtitleList)
child?.performClick()
} else {
+ val sub = subtitlesGroupedList.getOrNull(subtitleGroupIndex - 1)?.value?.getOrNull(which)
+ if (sub?.url == "pending_ai_translation") {
+ showToast("AI is translating subtitles... Please wait a moment.")
+ return@setOnItemClickListener
+ }
subtitleOptionIndex = which
subtitleOptionList.setItemChecked(which, true)
}
@@ -1385,10 +1759,20 @@ class GeneratorPlayer : FullScreenPlayer() {
init = init or if (subtitleGroupIndex <= 0) {
noSubtitles()
} else {
- subtitlesGroupedList.getOrNull(subtitleGroupIndex - 1)?.value?.getOrNull(
+ val selected = subtitlesGroupedList.getOrNull(subtitleGroupIndex - 1)?.value?.getOrNull(
subtitleOptionIndex
- )?.let {
- setSubtitles(it, true)
+ )
+ if (selected?.url == "pending_ai_translation") {
+ showToast("AI is translating subtitles... Please wait a moment.")
+ return@setOnClickListener
+ }
+ selected?.let {
+ val needsReload = setSubtitles(it, true)
+ if (needsReload) {
+ player.saveData()
+ context?.let { ctx -> player.reloadPlayer(ctx) }
+ }
+ true
} ?: false
}
}
@@ -1714,6 +2098,7 @@ class GeneratorPlayer : FullScreenPlayer() {
}
override fun onDestroy() {
+ stopAiFooterAnimation(null)
ResultFragment.updateUI()
currentVerifyLink?.cancel()
super.onDestroy()
@@ -2324,6 +2709,7 @@ class GeneratorPlayer : FullScreenPlayer() {
observe(viewModel.currentSubtitles) { (subtitles, instance) ->
if (instance != viewModel.state.instance) return@observe // Outdated observe
+ context?.let { loadCachedAiSubtitles(it) }
player.setActiveSubtitles(subtitles)
// If the file is downloaded then do not select auto select the subtitles
@@ -2334,6 +2720,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..d2433bf6a42 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,11 @@ 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 setDirectSecondaryCues(cues: List, subtitle: SubtitleData?) {}
+ fun loadSubtitleCues(subtitle: SubtitleData): List = emptyList()
+
fun handleEvent(event: CSPlayerEvent, source: PlayerEventSource = PlayerEventSource.UI)
fun onStop()
@@ -291,4 +299,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..6b5e7481180 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) {
@@ -333,6 +344,12 @@ class PlayerGeneratorViewModel : ViewModel() {
}
}
+ fun removeSubtitles(file: Set) {
+ modifyState {
+ copy(subtitles = (subtitles - file).toPersistentSet())
+ }
+ }
+
private var currentJob: Job? = null
private var currentStampJob: Job? = null
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..976f5d8d208 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
@@ -132,13 +133,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. */
@@ -207,6 +232,13 @@ class PlayerGestureHelper(private val playerView: PlayerView) {
private var scaleGestureDetector: ScaleGestureDetector? = null
+ private var twoPointerStartX: Float? = null
+ private var twoPointerStartY: Float? = null
+ private var twoPointerDownTime: Long = 0L
+ private var twoPointerMaxDisplacement: Float = 0f
+ private var twoPointerFired: Boolean = false
+ private var twoPointerIsScaling: Boolean = false
+
/** Midpoint of the two-finger pan, null when no pan is active. */
var lastPan: Vector2? = null
@@ -722,50 +754,96 @@ class PlayerGestureHelper(private val playerView: PlayerView) {
when (event.actionMasked) {
MotionEvent.ACTION_POINTER_DOWN -> {
+ if (event.pointerCount >= 2) {
+ twoPointerStartX = (event.getX(0) + event.getX(1)) / 2f
+ twoPointerStartY = (event.getY(0) + event.getY(1)) / 2f
+ twoPointerDownTime = System.currentTimeMillis()
+ twoPointerMaxDisplacement = 0f
+ twoPointerFired = false
+ twoPointerIsScaling = false
+ }
onFirstPointerDown()
}
MotionEvent.ACTION_MOVE -> {
if (event.pointerCount >= 2) {
- val newPan = Vector2(
- (event.getX(0) + event.getX(1)) / 2f,
- (event.getY(0) + event.getY(1)) / 2f
- )
- val oldPan = lastPan
- if (oldPan != null) {
- val matrix = currentZoomMatrix()
- matrix.postTranslate(newPan.x - oldPan.x, newPan.y - oldPan.y)
- applyZoomMatrix(matrix, false)
+ val midX = (event.getX(0) + event.getX(1)) / 2f
+ val midY = (event.getY(0) + event.getY(1)) / 2f
+ val startX = twoPointerStartX ?: midX
+ val startY = twoPointerStartY ?: midY
+ val diffX = midX - startX
+ val diffY = midY - startY
+ val displacement = kotlin.math.hypot(diffX.toDouble(), diffY.toDouble()).toFloat()
+ if (displacement > twoPointerMaxDisplacement) {
+ twoPointerMaxDisplacement = displacement
+ }
+
+ val density = ctx.resources.displayMetrics.density
+ if (!twoPointerFired && !twoPointerIsScaling && abs(diffX) > 40f * density && abs(diffX) > abs(diffY) * 1.3f) {
+ twoPointerFired = true
+ playerView.callbacks?.onJumpSubtitle(diffX < 0)
+ }
+
+ if (scaleGestureDetector?.isInProgress == true) {
+ twoPointerIsScaling = true
+ }
+
+ if (twoPointerIsScaling) {
+ val newPan = Vector2(midX, midY)
+ val oldPan = lastPan
+ if (oldPan != null) {
+ val matrix = currentZoomMatrix()
+ matrix.postTranslate(newPan.x - oldPan.x, newPan.y - oldPan.y)
+ applyZoomMatrix(matrix, false)
+ }
+ lastPan = newPan
}
- lastPan = newPan
}
}
MotionEvent.ACTION_CANCEL,
MotionEvent.ACTION_POINTER_UP,
MotionEvent.ACTION_UP -> {
+ val upTime = System.currentTimeMillis()
+ val startX = twoPointerStartX
+ val density = ctx.resources.displayMetrics.density
+ if (!twoPointerFired && !twoPointerIsScaling && startX != null && upTime - twoPointerDownTime < 400L && twoPointerMaxDisplacement < 30f * density) {
+ twoPointerFired = true
+ val isRightSide = startX >= screenWidthWithOrientation / 2f
+ playerView.callbacks?.onJumpSubtitle(isRightSide)
+ }
+
+ twoPointerStartX = null
+ twoPointerStartY = null
+ twoPointerDownTime = 0L
+ twoPointerMaxDisplacement = 0f
+ twoPointerFired = false
+ val wasScaling = twoPointerIsScaling
+ twoPointerIsScaling = false
lastPan = null
videoOutline?.isVisible = false
matrixAnimation?.cancel()
matrixAnimation = null
- // Snap to desired matrix after zoom gesture ends
- matrixAnimation = ValueAnimator.ofFloat(0f, 1f).apply {
- startDelay = 0
- duration = 200
- val startMatrix = currentZoomMatrix()
- val endMatrix = desiredMatrix ?: return@apply
- val (startX, startY, startScale) = matrixToTranslationAndScale(startMatrix)
- val (endX, endY, endScale) = matrixToTranslationAndScale(endMatrix)
- addUpdateListener { anim ->
- val v = anim.animatedValue as Float
- val vInv = 1f - v
- val m = Matrix()
- m.setScale(startScale * vInv + endScale * v, startScale * vInv + endScale * v)
- m.postTranslate(startX * vInv + endX * v, startY * vInv + endY * v)
- applyZoomMatrix(m, true)
+ if (wasScaling) {
+ // Snap to desired matrix after zoom gesture ends
+ matrixAnimation = ValueAnimator.ofFloat(0f, 1f).apply {
+ startDelay = 0
+ duration = 200
+ val startMatrix = currentZoomMatrix()
+ val endMatrix = desiredMatrix ?: return@apply
+ val (startX, startY, startScale) = matrixToTranslationAndScale(startMatrix)
+ val (endX, endY, endScale) = matrixToTranslationAndScale(endMatrix)
+ addUpdateListener { anim ->
+ val v = anim.animatedValue as Float
+ val vInv = 1f - v
+ val m = Matrix()
+ m.setScale(startScale * vInv + endScale * v, startScale * vInv + endScale * v)
+ m.postTranslate(startX * vInv + endX * v, startY * vInv + endY * v)
+ applyZoomMatrix(m, true)
+ }
+ start()
}
- start()
}
onGestureEnd()
@@ -1066,6 +1144,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 +1171,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 +1201,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 +1209,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 +1220,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 +1261,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..f15e1517046 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,8 +133,11 @@ 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) {}
+ fun onJumpSubtitle(next: Boolean) {}
/** Touch event callbacks */
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsPlayer.kt
index 2949281153f..cf59c93937e 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsPlayer.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsPlayer.kt
@@ -23,6 +23,7 @@ import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpTo
import com.lagradost.cloudstream3.ui.subtitles.ChromecastSubtitlesFragment
import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
+import com.lagradost.cloudstream3.utils.DataStoreHelper
import com.lagradost.cloudstream3.utils.Qualities
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog
@@ -247,6 +248,46 @@ class SettingsPlayer : BasePreferenceFragmentCompat() {
return@setOnPreferenceClickListener true
}
+ fun updateGeminiPrefSummary() {
+ val authKey = DataStoreHelper.geminiApiKey
+ val pref = getPref(R.string.gemini_api_key_settings_key)
+ if (authKey.isNullOrBlank()) {
+ pref?.summary = getString(R.string.gemini_api_key_not_set)
+ } else {
+ val masked = if (authKey.length > 8) "${authKey.take(4)}...${authKey.takeLast(4)}" else "••••••••"
+ pref?.summary = "${getString(R.string.gemini_api_key_configured)} ($masked)"
+ }
+ }
+ updateGeminiPrefSummary()
+
+ getPref(R.string.gemini_api_key_settings_key)?.setOnPreferenceClickListener {
+ val act = activity ?: return@setOnPreferenceClickListener false
+ val currentAuthKey = DataStoreHelper.geminiApiKey ?: ""
+ val input = android.widget.EditText(act).apply {
+ setSingleLine()
+ inputType = android.text.InputType.TYPE_CLASS_TEXT or android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD
+ setText(currentAuthKey)
+ setSelection(currentAuthKey.length)
+ }
+
+ androidx.appcompat.app.AlertDialog.Builder(act)
+ .setTitle(R.string.gemini_api_key_prompt)
+ .setMessage(R.string.gemini_api_key_dialog_message)
+ .setView(input)
+ .setPositiveButton(android.R.string.ok) { _, _ ->
+ val entered = input.text.toString().trim()
+ DataStoreHelper.geminiApiKey = entered.ifBlank { null }
+ updateGeminiPrefSummary()
+ com.lagradost.cloudstream3.CommonActivity.showToast(
+ act,
+ if (entered.isNotBlank()) R.string.gemini_api_key_saved else R.string.gemini_api_key_cleared
+ )
+ }
+ .setNegativeButton(android.R.string.cancel, null)
+ .show()
+ return@setOnPreferenceClickListener true
+ }
+
getPref(R.string.player_source_priority_key)?.setOnPreferenceClickListener {
ioSafe {
val defaultSources = QualityProfileDialog.getAllDefaultSources()
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 bf77ef24767..020d9128f87 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
@@ -36,6 +36,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
@@ -590,6 +591,11 @@ class SubtitlesFragment : BaseDialogFragment(
}
}
+ alwaysShowSecondarySubtitles.isChecked = DataStoreHelper.alwaysShowSecondarySubtitles
+ alwaysShowSecondarySubtitles.setOnCheckedChangeListener { _, b ->
+ DataStoreHelper.alwaysShowSecondarySubtitles = b
+ }
+
subtitlesRemoveBloat.isChecked = state.removeBloat
subtitlesRemoveBloat.setOnCheckedChangeListener { _, b ->
state.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 340266f6c52..a699c8bd16f 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt
@@ -58,6 +58,8 @@ const val RESULT_SEASON = "result_season"
const val RESULT_DUB = "result_dub"
const val KEY_RESULT_SORT = "result_sort"
const val USER_PINNED_PROVIDERS = "user_pinned_providers" // Key for pinned user set
+const val GEMINI_API_KEY = "gemini_api_key"
+const val GEMINI_TARGET_LANGUAGE = "gemini_target_language"
class UserPreferenceDelegate(
private val key: String,
@@ -144,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
@@ -827,4 +830,12 @@ object DataStoreHelper {
var pinnedProviders: Array
get() = getKey>(USER_PINNED_PROVIDERS) ?: emptyArray()
set(value) = setKey(USER_PINNED_PROVIDERS, value)
+
+ var geminiApiKey: String?
+ get() = getKey(GEMINI_API_KEY)
+ set(value) = setKey(GEMINI_API_KEY, value)
+
+ var geminiTargetLanguage: String
+ get() = getKey(GEMINI_TARGET_LANGUAGE) ?: "Azerbaijani"
+ set(value) = setKey(GEMINI_TARGET_LANGUAGE, value)
}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/SingleSelectionHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/SingleSelectionHelper.kt
index 26c710103fa..b6af597a62e 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/utils/SingleSelectionHelper.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/utils/SingleSelectionHelper.kt
@@ -288,6 +288,38 @@ object SingleSelectionHelper {
)
}
+ fun Activity?.showDialogNoCheckmark(
+ items: List,
+ name: String,
+ dismissCallback: () -> Unit = {},
+ callback: (Int) -> Unit,
+ ) {
+ if (this == null) return
+
+ val binding: BottomSelectionDialogBinding = BottomSelectionDialogBinding.inflate(
+ LayoutInflater.from(this)
+ )
+ val builder =
+ AlertDialog.Builder(this, R.style.AlertDialogCustom)
+ .setView(binding.root)
+
+ val dialog = builder.create()
+ dialog.show()
+
+ showDialog(
+ binding,
+ dialog,
+ items,
+ emptyList(),
+ name,
+ showApply = false,
+ false,
+ { if (it.isNotEmpty()) callback.invoke(it.first()) },
+ dismissCallback,
+ R.layout.sort_bottom_single_choice_no_checkmark
+ )
+ }
+
/** Only for a low amount of items */
fun Activity?.showBottomDialog(
items: List,
diff --git a/app/src/main/res/drawable/ic_baseline_auto_awesome_24.xml b/app/src/main/res/drawable/ic_baseline_auto_awesome_24.xml
new file mode 100644
index 00000000000..16d9479a4d3
--- /dev/null
+++ b/app/src/main/res/drawable/ic_baseline_auto_awesome_24.xml
@@ -0,0 +1,10 @@
+
+
+
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
+ Previous subtitle
+ Next subtitle
+ Gemini API Key
+ gemini_api_key_settings_key
+ AI Subtitle Translation (Gemini)
+ Not configured. Tap to set API key.
+ Configured
+ Enter your Google Gemini API key to enable on-the-fly subtitle translation.
+ Gemini API key saved
+ Gemini API key cleared
+ Set Google Gemini API key for subtitle translation
+ AI Target Language
+ Translate with AI
+ Translating subtitles with Gemini AI…
+ Enter Google Gemini API Key
+ Please enter a valid Gemini API Key
+ AI subtitle translation completed
+ AI translation failed: %s
Use this if the subtitles are shown %d ms too early
Use this if subtitles are shown %d ms too late
No subtitle delay
@@ -486,6 +505,7 @@
Error
Remove closed captions from subtitles
Remove bloat from subtitles
+ Always show secondary subtitle
Filter by preferred media language
Extras
Trailer
diff --git a/app/src/main/res/xml/settings_player.xml b/app/src/main/res/xml/settings_player.xml
index 6e136747448..a420f6d1f4b 100644
--- a/app/src/main/res/xml/settings_player.xml
+++ b/app/src/main/res/xml/settings_player.xml
@@ -53,6 +53,11 @@
android:key="@string/subtitle_settings_chromecast_key"
android:title="@string/chromecast_subtitles_settings"
app:summary="@string/chromecast_subtitles_settings_des" />
+
diff --git a/app/src/test/java/com/lagradost/cloudstream3/GeminiSubtitleTranslatorTest.kt b/app/src/test/java/com/lagradost/cloudstream3/GeminiSubtitleTranslatorTest.kt
new file mode 100644
index 00000000000..fa046c74e5d
--- /dev/null
+++ b/app/src/test/java/com/lagradost/cloudstream3/GeminiSubtitleTranslatorTest.kt
@@ -0,0 +1,56 @@
+package com.lagradost.cloudstream3
+
+import com.lagradost.cloudstream3.ui.player.GeminiSubtitleTranslator
+import com.lagradost.cloudstream3.ui.player.SubtitleCue
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import java.io.File
+
+class GeminiSubtitleTranslatorTest {
+
+ @Test
+ fun `test cuesToVttFile generates valid WebVTT formatting`() {
+ val cues = listOf(
+ SubtitleCue(
+ startTimeMs = 1000L,
+ durationMs = 2500L,
+ text = listOf("Salam, necəsən?")
+ ),
+ SubtitleCue(
+ startTimeMs = 4000L,
+ durationMs = 3000L,
+ text = listOf("Yaxşıyam, çox sağ ol!")
+ )
+ )
+
+ val tempDir = File(System.getProperty("java.io.tmpdir") ?: "/tmp")
+ val file = File(tempDir, "test_generated.vtt")
+ file.bufferedWriter().use { writer ->
+ writer.write("WEBVTT\n\n")
+ cues.forEach { cue ->
+ val start = "%02d:%02d:%02d.%03d".format(
+ cue.startTimeMs / 1000 / 3600,
+ cue.startTimeMs / 1000 / 60 % 60,
+ cue.startTimeMs / 1000 % 60,
+ cue.startTimeMs % 1000
+ )
+ val endMs = cue.startTimeMs + cue.durationMs
+ val end = "%02d:%02d:%02d.%03d".format(
+ endMs / 1000 / 3600,
+ endMs / 1000 / 60 % 60,
+ endMs / 1000 % 60,
+ endMs % 1000
+ )
+ writer.write("$start --> $end\n${cue.text.joinToString("\n")}\n\n")
+ }
+ }
+
+ assertTrue("VTT file should exist", file.exists())
+ val content = file.readText()
+ assertTrue("Header should be WEBVTT", content.startsWith("WEBVTT"))
+ assertTrue("Content should contain first line", content.contains("Salam, necəsən?"))
+ assertTrue("Content should contain second line", content.contains("Yaxşıyam, çox sağ ol!"))
+ file.delete()
+ }
+}