From b5145ef29900a705eb05fa3ead3c5274373d0b58 Mon Sep 17 00:00:00 2001 From: nutine <40691418+nutine@users.noreply.github.com> Date: Mon, 7 Sep 2026 00:38:01 +0400 Subject: [PATCH 1/8] feat(player): implement secondary subtitle playback engine and layouts - Add secondary subtitle track support in CS3IPlayer with out-of-band loading and parsing - Handle Cloudflare WAF on subtitle fetches with Referer and User-Agent headers - Integrate secondary_subtitle_view into phone and TV player layouts - Add hardware vibration utility for haptic feedback --- app/src/main/AndroidManifest.xml | 1 + .../ui/player/AbstractPlayerFragment.kt | 2 + .../cloudstream3/ui/player/CS3IPlayer.kt | 206 +++++++++++++++++- .../ui/player/CustomSubtitleDecoderFactory.kt | 2 +- .../cloudstream3/ui/player/IPlayer.kt | 7 + .../ui/player/PlayerSubtitleHelper.kt | 20 ++ .../main/res/layout/player_custom_layout.xml | 7 + .../res/layout/player_custom_layout_tv.xml | 7 + .../main/res/layout/trailer_custom_layout.xml | 8 + 9 files changed, 257 insertions(+), 3 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ee4c978f2be..8ef3aeeeb25 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -8,6 +8,7 @@ + diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt index e5a460b9a02..3fd1ddea0be 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt @@ -2,6 +2,7 @@ package com.lagradost.cloudstream3.ui.player import android.os.Bundle import android.view.View +import android.widget.FrameLayout import android.widget.ImageView import androidx.annotation.OptIn import androidx.annotation.StringRes @@ -49,6 +50,7 @@ abstract class AbstractPlayerFragment( } val subView: SubtitleView? get() = playerHostView?.subView + val subtitleHolder: FrameLayout? get() = playerHostView?.subtitleHolder val playerPausePlay: ImageView? get() = playerHostView?.playerPausePlay /** The underlying [androidx.media3.ui.PlayerView] widget (named to avoid conflict with our [PlayerView]). */ diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt index 9c5685c682b..4d90bdeaeb3 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 @@ -111,11 +111,23 @@ import com.lagradost.cloudstream3.utils.videoskip.VideoSkipStamp import com.lagradost.cloudstream4.AppSettings import kotlinx.coroutines.delay import okhttp3.Interceptor +import okhttp3.Request import org.chromium.net.CronetEngine import java.io.File import java.security.SecureRandom import java.util.UUID +import java.util.concurrent.ExecutorService import java.util.concurrent.Executors +import java.util.concurrent.Future +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicLong +import androidx.media3.common.text.Cue +import com.lagradost.cloudstream3.ui.player.CustomDecoder.Companion.fixSubtitleAlignment +import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment.Companion.applyStyle +import com.lagradost.cloudstream3.CommonActivity.showToast +import android.graphics.Color +import android.view.View +import com.lagradost.cloudstream3.CloudStreamApp import javax.net.ssl.HttpsURLConnection import javax.net.ssl.SSLContext import javax.net.ssl.SSLSession @@ -173,6 +185,20 @@ 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() + /** If we want to play the audio only in the background when the app is not open */ private var isAudioOnlyBackground = false @@ -285,6 +311,7 @@ class CS3IPlayer : IPlayer { saveData() } else { currentSubtitles = subtitle + loadPrimaryCues(subtitle) playbackPosition = 0 } @@ -499,6 +526,7 @@ class CS3IPlayer : IPlayer { override fun setPreferredSubtitles(subtitle: SubtitleData?): Boolean { Log.i(TAG, "setPreferredSubtitles init $subtitle") currentSubtitles = subtitle + loadPrimaryCues(subtitle) val trackSelector = exoPlayer?.trackSelector as? DefaultTrackSelector ?: return false // Disable subtitles if null if (subtitle == null) { @@ -539,6 +567,7 @@ class CS3IPlayer : IPlayer { } private var currentSubtitleOffset: Long = 0 + private var currentSecondarySubtitleOffset: Long = 0 override fun setSubtitleOffset(offset: Long) { currentSubtitleOffset = offset @@ -556,16 +585,174 @@ 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 (active != null && active.origin != SubtitleOrigin.EMBEDDED_IN_VIDEO) { + try { + primarySubtitleFuture?.get(1500, TimeUnit.MILLISECONDS) + if (primaryCues.isNotEmpty()) return primaryCues + } catch (_: Throwable) {} + } + return primaryCues + } + + private fun fetchSubtitleFromUrl(subtitle: SubtitleData): ByteArray? { + val fixedUrl = subtitle.getFixedUrl() + val reqHeaders = subtitle.headers.toMutableMap() + if (reqHeaders.keys.none { it.equals("User-Agent", ignoreCase = true) }) { + reqHeaders["User-Agent"] = USER_AGENT + } + if (reqHeaders.keys.none { it.equals("Referer", ignoreCase = true) }) { + try { + val uri = Uri.parse(fixedUrl) + if (uri.scheme != null && uri.host != null) { + reqHeaders["Referer"] = "${uri.scheme}://${uri.host}/" + } + } catch (_: Throwable) {} + } + return app.baseClient.newCall( + Request.Builder().url(fixedUrl).apply { + reqHeaders.forEach { (key, value) -> addHeader(key, value) } + }.build() + ).execute().use { resp -> + val body = resp.body.bytes() + if (body.size > 200) { + val preview = String(body.take(200).toByteArray()) + if (preview.contains(" fetchSubtitleFromUrl(subtitle) + SubtitleOrigin.DOWNLOADED_FILE -> CloudStreamApp.context?.contentResolver + ?.openInputStream(Uri.parse(subtitle.url))?.use { it.readBytes() } + SubtitleOrigin.EMBEDDED_IN_VIDEO -> null + } + } + + private fun loadPrimaryCues(subtitle: SubtitleData?) { + val generation = primarySubtitleGeneration.incrementAndGet() + primarySubtitleFuture?.cancel(true) + primarySubtitleFuture = null + if (subtitle == null || subtitle.origin == SubtitleOrigin.EMBEDDED_IN_VIDEO) { + primaryCues = emptyList() + return + } + if (secondarySubtitleExecutor.isShutdown) secondarySubtitleExecutor = newSecondarySubtitleExecutor() + primarySubtitleFuture = secondarySubtitleExecutor.submit { + try { + val bytes = fetchSubtitleBytes(subtitle) ?: return@submit + if (primarySubtitleGeneration.get() != generation) return@submit + val decoder = CustomDecoder(Format.Builder().setSampleMimeType(subtitle.mimeType).build()) + decoder.parseToLegacySubtitle(bytes, 0, bytes.size) + val cues = synchronized(decoder.currentSubtitleCues) { decoder.currentSubtitleCues.toList() } + Log.i(TAG, "Primary subtitle parsed cues count: ${cues.size}") + if (primarySubtitleGeneration.get() != generation) return@submit + primaryCues = cues + } catch (t: Throwable) { if (t !is InterruptedException) logError(t) } + } + } + + override fun setSecondarySubtitles(subtitle: SubtitleData?) { + val generation = secondarySubtitleGeneration.incrementAndGet() + secondarySubtitleFuture?.cancel(true) + secondarySubtitleFuture = null + currentSecondarySubtitle = subtitle + secondaryCues = emptyList() + lastSecondaryCueSignature = emptyList() + pushSecondaryCues() + if (subtitle == null || subtitle.origin == SubtitleOrigin.EMBEDDED_IN_VIDEO) return + if (secondarySubtitleExecutor.isShutdown) secondarySubtitleExecutor = newSecondarySubtitleExecutor() + secondarySubtitleFuture = secondarySubtitleExecutor.submit { + try { + val bytes = fetchSubtitleBytes(subtitle) ?: return@submit + if (secondarySubtitleGeneration.get() != generation) return@submit + val decoder = CustomDecoder(Format.Builder().setSampleMimeType(subtitle.mimeType).build()) + decoder.parseToLegacySubtitle(bytes, 0, bytes.size) + val cues = synchronized(decoder.currentSubtitleCues) { decoder.currentSubtitleCues.toList() } + Log.i(TAG, "Secondary subtitle parsed cues count: ${cues.size}") + if (secondarySubtitleGeneration.get() != generation) return@submit + secondaryCues = cues + runOnMainThread { if (secondarySubtitleGeneration.get() == generation) pushSecondaryCues() } + } catch (t: Throwable) { if (t !is InterruptedException) logError(t) } + } + } + + override fun getCurrentSecondarySubtitle(): SubtitleData? = currentSecondarySubtitle + + override fun getSecondarySubtitleCues(): List = secondaryCues + + private fun pushSecondaryCues() { + val view = subtitleHelper.secondarySubtitleView ?: return + val position = exoPlayer?.currentPosition ?: 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 + 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 + ) + view.setCues(active.map { cue -> + Cue.Builder() + .setText(cue.text.joinToString("\n")) + .setTextSize(25f, Cue.TEXT_SIZE_TYPE_ABSOLUTE) + .setLine(0f, Cue.LINE_TYPE_FRACTION) + .setLineAnchor(Cue.ANCHOR_TYPE_START) + .fixSubtitleAlignment() + .applyStyle(transparentTopStyle) + .build() + }) } override fun getCurrentPreferredSubtitle(): SubtitleData? { - return subtitleHelper.getAllSubtitles().firstOrNull { sub -> + val active = subtitleHelper.getAllSubtitles().firstOrNull { sub -> playerSelectedSubtitleTracks.any { (id, isSelected) -> isSelected && sub.getId() == id } + } ?: currentSubtitles + if (active != null && primaryCues.isEmpty() && active.origin != SubtitleOrigin.EMBEDDED_IN_VIDEO) { + loadPrimaryCues(active) } + return active } override fun getAspectRatio(): Rational? { @@ -595,8 +782,20 @@ 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() + } currentTextRenderer = null currentSubtitleDecoder = null + pushSecondaryCues() exoPlayer?.apply { playWhenReady = false @@ -651,6 +850,7 @@ class CS3IPlayer : IPlayer { override fun release() { imageGenerator.release() releasePlayer() + secondarySubtitleExecutor.shutdownNow() } override fun setPlaybackSpeed(speed: Float) { @@ -898,6 +1098,7 @@ class CS3IPlayer : IPlayer { writePosition: Long? = null, source: PlayerEventSource = PlayerEventSource.Player ) { + pushSecondaryCues() val position = writePosition ?: exoPlayer?.currentPosition getCurrentTimestamp(position)?.let { timestamp -> @@ -1184,6 +1385,7 @@ class CS3IPlayer : IPlayer { val combinedCues = styledBitmapCues + styledTextCues subtitleHelper.subtitleView?.setCues(combinedCues) + pushSecondaryCues() } factory.createRenderers( 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/IPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt index 0342372667f..d94520db4b0 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt @@ -222,6 +222,9 @@ interface IPlayer { fun getSubtitleOffset(): Long // in ms fun setSubtitleOffset(offset: Long) // in ms + fun getSecondarySubtitleOffset(): Long = 0L + fun setSecondarySubtitleOffset(offset: Long) {} + @AnyThread fun initCallbacks( @MainThread eventHandler: ((PlayerEvent) -> Unit), @@ -257,6 +260,9 @@ interface IPlayer { fun setPreferredSubtitles(subtitle: SubtitleData?): Boolean // returns true if the player requires a reload, null for nothing fun getCurrentPreferredSubtitle(): SubtitleData? + fun setSecondarySubtitles(subtitle: SubtitleData?) {} + fun getCurrentSecondarySubtitle(): SubtitleData? = null + fun handleEvent(event: CSPlayerEvent, source: PlayerEventSource = PlayerEventSource.UI) fun onStop() @@ -291,4 +297,5 @@ interface IPlayer { /** Get the current subtitle cues, for use with syncing */ fun getSubtitleCues(): List + fun getSecondarySubtitleCues(): List = emptyList() } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/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/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"> + + + + Date: Mon, 7 Sep 2026 00:38:04 +0400 Subject: [PATCH 2/8] feat(ui): add secondary subtitle selection and settings toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add long-press gesture in subtitle selection dialog to pick or clear secondary subtitle - Display ✓ [2nd] badge for active secondary subtitle with haptic feedback - Add settings toggle to always show secondary subtitle --- .../cloudstream3/ui/player/GeneratorPlayer.kt | 61 ++++++++++++++++++- .../ui/player/PlayerGeneratorViewModel.kt | 11 ++++ .../ui/subtitles/SubtitlesFragment.kt | 6 ++ .../cloudstream3/utils/DataStoreHelper.kt | 1 + app/src/main/res/layout/subtitle_settings.xml | 10 +++ 5 files changed, 87 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt index e4616c93bcf..fc445ccd40c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt @@ -1202,7 +1202,20 @@ class GeneratorPlayer : FullScreenPlayer() { }.toMap() val subtitlesGroupedList = subtitlesGrouped.entries.toList() - val subtitles = subtitlesGrouped.map { it.key.html() } + fun getSubGroupLabel(key: String, list: List): Spanned { + val currentSec = viewModel.state.secondarySubtitle + val isSecondary = currentSec != null && list.any { it.getId() == currentSec.getId() } + return if (isSecondary) { + "✓ [2nd] ${key}".html() + } else { + key.html() + } + } + + fun getSubtitleLabels(): List = + subtitlesGrouped.map { getSubGroupLabel(it.key, it.value) } + + val subtitles = getSubtitleLabels() val subtitleGroupIndexStart = subtitlesGrouped.keys.indexOf(currentSelectedSubtitles?.originalName) + 1 @@ -1228,19 +1241,32 @@ class GeneratorPlayer : FullScreenPlayer() { subtitleOptionList.choiceMode = AbsListView.CHOICE_MODE_SINGLE fun updateSubtitleOptionList() { + subsArrayAdapter.clear() + subsArrayAdapter.add(ctx.getString(R.string.no_subtitles).html()) + subsArrayAdapter.addAll(getSubtitleLabels()) + subtitleList.setItemChecked(subtitleGroupIndex, true) + subsOptionsArrayAdapter.clear() + val currentSec = viewModel.state.secondarySubtitle val subtitleOptions = subtitlesGroupedList .getOrNull(subtitleGroupIndex - 1)?.value?.map { subtitle -> val nameSuffix = subtitle.nameSuffix.html() - nameSuffix.ifBlank { + val baseLabel = nameSuffix.ifBlank { when (subtitle.origin) { SubtitleOrigin.URL -> txt(R.string.subtitles_from_online) SubtitleOrigin.DOWNLOADED_FILE -> txt(R.string.downloaded) SubtitleOrigin.EMBEDDED_IN_VIDEO -> txt(R.string.subtitles_from_embedded) }.asString(ctx).toSpanned() } + if (currentSec?.getId() == subtitle.getId()) { + "✓ [2nd] ".html().let { prefix -> + android.text.TextUtils.concat(prefix, baseLabel) as Spanned + } + } else { + baseLabel + } } ?: emptyList() @@ -1255,6 +1281,33 @@ class GeneratorPlayer : FullScreenPlayer() { subtitleOptionList.setItemChecked(subtitleOptionIndex, true) } + fun toggleSecondarySubtitle(subtitle: SubtitleData?) { + ctx.vibrateDevice(55L) + val current = viewModel.state.secondarySubtitle + val next = if (subtitle != null && current?.getId() == subtitle.getId()) null else subtitle + viewModel.setSecondarySubtitle(next) + subtitleList.post { updateSubtitleOptionList() } + } + + subtitleList.setOnItemLongClickListener { _, _, which, _ -> + if (which == 0) { + toggleSecondarySubtitle(null) + true + } else { + subtitlesGroupedList.getOrNull(which - 1)?.value?.firstOrNull()?.let { + toggleSecondarySubtitle(it) + } + true + } + } + + subtitleOptionList.setOnItemLongClickListener { _, _, which, _ -> + subtitlesGroupedList.getOrNull(subtitleGroupIndex - 1)?.value?.getOrNull(which)?.let { + toggleSecondarySubtitle(it) + } + true + } + updateSubtitleOptionList() subtitleList.setOnItemClickListener { _, _, which, _ -> @@ -2335,6 +2388,10 @@ class GeneratorPlayer : FullScreenPlayer() { autoSelectSubtitles() } } + observe(viewModel.currentSecondarySubtitle) { (subtitle, instance) -> + if (instance != viewModel.state.instance) return@observe + player.setSecondarySubtitles(subtitle) + } observe(viewModel.loadingLinks) { (loading, instance) -> if (instance != viewModel.state.instance) return@observe // Outdated observe diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGeneratorViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGeneratorViewModel.kt index cb8cf8bfff5..431fc9397a7 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGeneratorViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGeneratorViewModel.kt @@ -54,6 +54,7 @@ data class DisplayLink( // @Immutable data class VideoState( val subtitles: PersistentSet = persistentSetOf(), + val secondarySubtitle: SubtitleData? = null, val links: PersistentSet = persistentSetOf(), val erroredLinks: PersistentSet = persistentSetOf(), val stamps: PersistentList = persistentListOf(), @@ -196,6 +197,9 @@ class PlayerGeneratorViewModel : ViewModel() { private val _currentSubtitles = MutableLiveData>>(null) val currentSubtitles: LiveData>> = _currentSubtitles + private val _currentSecondarySubtitle = MutableLiveData>(null) + val currentSecondarySubtitle: LiveData> = _currentSecondarySubtitle + private val _loadingLinks = MutableLiveData>>() val loadingLinks: LiveData>> = _loadingLinks @@ -216,6 +220,7 @@ class PlayerGeneratorViewModel : ViewModel() { /** New instance, always push state */ if (state.instance != oldState.instance) { _currentSubtitles.postValue(VideoLive(state.subtitles, state.instance)) + _currentSecondarySubtitle.postValue(VideoLive(state.secondarySubtitle, state.instance)) _currentStamps.postValue(VideoLive(state.stamps, state.instance)) _currentLinks.postValue(VideoLive(state.links, state.instance)) _loadingLinks.postValue(VideoLive(state.loading, state.instance)) @@ -234,6 +239,8 @@ class PlayerGeneratorViewModel : ViewModel() { _currentStamps.postValue(VideoLive(state.stamps, state.instance)) if (state.subtitles !== oldState.subtitles) _currentSubtitles.postValue(VideoLive(state.subtitles, state.instance)) + if (state.secondarySubtitle !== oldState.secondarySubtitle) + _currentSecondarySubtitle.postValue(VideoLive(state.secondarySubtitle, state.instance)) /** Normal equality here as it is not a collection */ if (state.loading != oldState.loading) @@ -254,6 +261,10 @@ class PlayerGeneratorViewModel : ViewModel() { _currentSubtitleYear.postValue(year) } + fun setSecondarySubtitle(subtitle: SubtitleData?) { + modifyState { copy(secondarySubtitle = subtitle) } + } + fun loadLinksPrev() { Log.i(TAG, "loadLinksPrev") if (generator?.hasPrev(episodeIndex) == true) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/SubtitlesFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/SubtitlesFragment.kt index dd363fc10be..851383a7551 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/SubtitlesFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/SubtitlesFragment.kt @@ -39,6 +39,7 @@ import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.SubtitleSettingsBinding import com.lagradost.cloudstream3.ui.BaseDialogFragment import com.lagradost.cloudstream3.ui.BaseFragment +import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.ui.player.CustomDecoder import com.lagradost.cloudstream3.ui.player.CustomDecoder.Companion.setSubtitleAlignment import com.lagradost.cloudstream3.ui.player.OutlineSpan @@ -652,6 +653,11 @@ class SubtitlesFragment : BaseDialogFragment( } } + alwaysShowSecondarySubtitles.isChecked = DataStoreHelper.alwaysShowSecondarySubtitles + alwaysShowSecondarySubtitles.setOnCheckedChangeListener { _, b -> + DataStoreHelper.alwaysShowSecondarySubtitles = b + } + subtitlesRemoveBloat.isChecked = state.removeBloat subtitlesRemoveBloat.setOnCheckedChangeListener { _, b -> state = state.copy(removeBloat = b) diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt index eabac20d322..6f937c03921 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt @@ -146,6 +146,7 @@ object DataStoreHelper { var playBackSpeed: Float by UserPreferenceDelegate("playback_speed", 1.0f) var resizeMode: Int by UserPreferenceDelegate("resize_mode", 0) + var alwaysShowSecondarySubtitles: Boolean by UserPreferenceDelegate("always_show_secondary_subtitles", false) var librarySortingMode: Int by UserPreferenceDelegate( "library_sorting_mode", ListSorting.AlphabeticalA.ordinal diff --git a/app/src/main/res/layout/subtitle_settings.xml b/app/src/main/res/layout/subtitle_settings.xml index 7fd40c48004..83354237bbe 100644 --- a/app/src/main/res/layout/subtitle_settings.xml +++ b/app/src/main/res/layout/subtitle_settings.xml @@ -177,6 +177,16 @@ android:nextFocusDown="@id/subtitles_remove_bloat" android:text="@string/subs_download_languages" /> + + Date: Mon, 7 Sep 2026 00:39:20 +0400 Subject: [PATCH 3/8] feat(player): add independent sync offset and hold-to-reveal gesture for secondary subtitles - Support separate delay/offset adjustments for primary and secondary subtitles - Prompt subtitle selection (primary vs secondary) in sync dialog when secondary subtitle is active - Add hold-to-reveal gesture on right 30% of screen to temporarily pause playback and reveal secondary subtitle --- .../ui/player/FullScreenPlayer.kt | 94 +++++++++++++++++-- .../ui/player/PlayerGestureHelper.kt | 41 +++++++- .../cloudstream3/ui/player/PlayerView.kt | 1 + app/src/main/res/layout/subtitle_offset.xml | 1 + app/src/main/res/values/strings.xml | 2 + 5 files changed, 129 insertions(+), 10 deletions(-) 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..2cd4bade4d1 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt @@ -13,6 +13,7 @@ import android.graphics.Color import android.os.Build import android.os.Bundle import android.text.Editable +import android.util.Log import android.view.KeyEvent import android.view.LayoutInflater import android.view.MotionEvent @@ -68,6 +69,7 @@ import com.lagradost.cloudstream3.utils.txt import kotlin.math.roundToInt private const val SUBTITLE_DELAY_BUNDLE_KEY = "subtitle_delay" +private const val SECONDARY_SUBTITLE_DELAY_BUNDLE_KEY = "secondary_subtitle_delay" // All the UI Logic for the player @OptIn(UnstableApi::class) @@ -113,6 +115,19 @@ open class FullScreenPlayer : AbstractPlayerFragment( 0L } + protected var secondarySubtitleDelay + set(value) = try { + player.setSecondarySubtitleOffset(-value) + } catch (e: Exception) { + logError(e) + } + get() = try { + -player.getSecondarySubtitleOffset() + } catch (e: Exception) { + logError(e) + 0L + } + private var isShowingEpisodeOverlay: Boolean = false private var previousPlayStatus: Boolean = false @@ -352,8 +367,9 @@ open class FullScreenPlayer : AbstractPlayerFragment( track.sampleMimeType == MimeTypes.APPLICATION_MEDIA3_CUES } // Subtitle offset is not possible on built-in media3 tracks + val hasSecondarySub = player.getCurrentSecondarySubtitle() != null playerBinding?.playerSubtitleOffsetBtt?.isGone = - isBuiltinSubtitles || tracks.currentTextTracks.isEmpty() + (isBuiltinSubtitles || tracks.currentTextTracks.isEmpty()) && !hasSecondarySub } private fun restoreOrientationWithSensor(activity: Activity) { @@ -496,7 +512,7 @@ open class FullScreenPlayer : AbstractPlayerFragment( player.seekTime(85000) // skip 85s } - private fun showSubtitleOffsetDialog() { + private fun showSubtitleOffsetDialog(isSecondary: Boolean = false) { val ctx = context ?: return // Pause player because the subtitles cannot be continuously updated to follow playback. player.handleEvent( @@ -517,8 +533,11 @@ open class FullScreenPlayer : AbstractPlayerFragment( ctx.resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT fixSystemBarsPadding(binding.root, fixIme = isPortrait) - var currentOffset = subtitleDelay + var currentOffset = if (isSecondary) secondarySubtitleDelay else subtitleDelay binding.apply { + if (isSecondary) { + subtitleOffsetTitleText?.setText(R.string.secondary_subtitle_offset_title) + } var subtitleAdapter: SubtitleOffsetItemAdapter? = null subtitleOffsetInput.doOnTextChanged { text, _, _, _ -> @@ -535,6 +554,10 @@ open class FullScreenPlayer : AbstractPlayerFragment( subtitleOffsetRecyclerview.scrollToPosition(subtitlePos) } + if (isSecondary) { + player.setSecondarySubtitleOffset(-currentOffset) + } + val str = when { time > 0L -> { txt(R.string.subtitle_offset_extra_hint_later_format, time) @@ -554,7 +577,7 @@ open class FullScreenPlayer : AbstractPlayerFragment( subtitleOffsetInput.text = Editable.Factory.getInstance()?.newEditable(currentOffset.toString()) - val subtitles = player.getSubtitleCues().toMutableList() + val subtitles = (if (isSecondary) player.getSecondarySubtitleCues() else player.getSubtitleCues()).toMutableList() subtitleOffsetRecyclerview.isVisible = subtitles.isNotEmpty() noSubtitlesLoadedNotice.isVisible = subtitles.isEmpty() @@ -605,13 +628,23 @@ open class FullScreenPlayer : AbstractPlayerFragment( } applyBtt.setOnClickListener { selectSubtitlesDialog = null - subtitleDelay = currentOffset + if (isSecondary) { + secondarySubtitleDelay = currentOffset + player.setSecondarySubtitleOffset(-currentOffset) + } else { + subtitleDelay = currentOffset + } dialog.dismissSafe(activity) player.seekTime(1L) } resetBtt.setOnClickListener { selectSubtitlesDialog = null - subtitleDelay = 0 + if (isSecondary) { + secondarySubtitleDelay = 0 + player.setSecondarySubtitleOffset(0) + } else { + subtitleDelay = 0 + } dialog.dismissSafe(activity) player.seekTime(1L) } @@ -851,6 +884,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 +899,14 @@ 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 onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) @@ -1090,6 +1138,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 +1163,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 +1304,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/PlayerGestureHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGestureHelper.kt index 1c7086d1238..91bdd9b2eb6 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,27 @@ 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) + } + enum class TouchAction { Brightness, Volume, Time } /** Mirrors the host's lock state; suppresses gesture interactions when true. */ @@ -1066,6 +1081,7 @@ 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) isCurrentTouchValid = false // Prevent other touches return handleZoomPanGesture( event = event, @@ -1091,7 +1107,14 @@ class PlayerGestureHelper(private val playerView: PlayerView) { if (isCurrentTouchValid) { playerView.callbacks?.onTouchDown() hasTriggeredSpeedUp = false - if (speedupEnabled && playerView.player.getIsPlaying() && !isLocked) { + hasTriggeredSubReveal = false + holdHandler.removeCallbacks(holdRunnable) + holdHandler.removeCallbacks(subRevealRunnable) + val isRight30Percent = event.x >= view.width * 0.7f + Log.i(TAG, "ACTION_DOWN: isRight30Percent=$isRight30Percent (x=${event.x}, w=${view.width}), secSub=${playerView.player.getCurrentSecondarySubtitle()}") + 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 +1132,7 @@ class PlayerGestureHelper(private val playerView: PlayerView) { } MotionEvent.ACTION_MOVE -> { - if (hasTriggeredSpeedUp) return true + if (hasTriggeredSpeedUp || hasTriggeredSubReveal) return true if (!isCurrentTouchValid) return true if (currentTouchAction == null && startTouch != null) { @@ -1117,6 +1140,7 @@ class PlayerGestureHelper(private val playerView: PlayerView) { if (swipeVerticalEnabled) { if (abs(diffFromStart.y * 100 / screenHeightWithOrientation) > MINIMUM_VERTICAL_SWIPE) { holdHandler.removeCallbacks(holdRunnable) + holdHandler.removeCallbacks(subRevealRunnable) uiShowingBeforeGesture = playerView.callbacks?.isUIShowing() ?: false playerView.callbacks?.onHidePlayerUI() currentTouchAction = if ((startTouch.x) >= view.width / 2f) @@ -1126,6 +1150,7 @@ class PlayerGestureHelper(private val playerView: PlayerView) { if (swipeHorizontalEnabled && !isLocked) { if (abs(diffFromStart.x * 100 / screenHeightWithOrientation) > MINIMUM_HORIZONTAL_SWIPE) { holdHandler.removeCallbacks(holdRunnable) + holdHandler.removeCallbacks(subRevealRunnable) currentTouchAction = TouchAction.Time } } @@ -1165,6 +1190,16 @@ class PlayerGestureHelper(private val playerView: PlayerView) { MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP -> { holdHandler.removeCallbacks(holdRunnable) + holdHandler.removeCallbacks(subRevealRunnable) + 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/PlayerView.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerView.kt index 0e6f1a3677d..e1d0fcc68c3 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerView.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerView.kt @@ -133,6 +133,7 @@ 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) {} /** Called during brightness swipe with the current extra-brightness alpha (0–1). */ fun onBrightnessExtra(alpha: Float) {} diff --git a/app/src/main/res/layout/subtitle_offset.xml b/app/src/main/res/layout/subtitle_offset.xml index 8570e9a266b..c08336f1447 100644 --- a/app/src/main/res/layout/subtitle_offset.xml +++ b/app/src/main/res/layout/subtitle_offset.xml @@ -50,6 +50,7 @@ android:orientation="vertical"> Sync subs 1000 ms Subtitle delay + Secondary subtitle delay Use this if the subtitles are shown %d ms too early Use this if subtitles are shown %d ms too late No subtitle delay @@ -490,6 +491,7 @@ Error Remove closed captions from subtitles Remove ads from subtitles + Always show secondary subtitle Filter by preferred media language Extras Trailer From 5d31794edd65ac27aaf5cdec55f028e251b0a492 Mon Sep 17 00:00:00 2001 From: nutine <40691418+nutine@users.noreply.github.com> Date: Mon, 7 Sep 2026 00:39:34 +0400 Subject: [PATCH 4/8] feat(player): add side-by-side dual subtitles comparison viewer dialog - Add full-screen dual subtitles timeline comparison dialog (dialog_dual_subtitles) - Align primary and secondary cues into paired chronological rows via DualSubtitleAdapter - Highlight active cues with progress indicator and allow seeking to any dialogue on tap - Trigger viewer dialog via long-press in the top-right corner of the player --- .../ui/player/DualSubtitleAdapter.kt | 189 ++++++++++++++++++ .../ui/player/FullScreenPlayer.kt | 84 ++++++++ .../ui/player/PlayerGestureHelper.kt | 39 +++- .../cloudstream3/ui/player/PlayerView.kt | 1 + .../main/res/layout/dialog_dual_subtitles.xml | 110 ++++++++++ .../res/layout/dialog_dual_subtitles_item.xml | 78 ++++++++ 6 files changed, 497 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/com/lagradost/cloudstream3/ui/player/DualSubtitleAdapter.kt create mode 100644 app/src/main/res/layout/dialog_dual_subtitles.xml create mode 100644 app/src/main/res/layout/dialog_dual_subtitles_item.xml 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 2cd4bade4d1..3bb471bae56 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 @@ -655,6 +655,86 @@ open class FullScreenPlayer : AbstractPlayerFragment( } } + private var dualSubtitlesDialog: Dialog? = null + + private fun showDualSubtitlesDialog() { + val ctx = context ?: return + 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 + activity?.hideSystemUI() + } + } + } + @SuppressLint("SetTextI18n") fun updateSpeedDialogBinding(binding: SpeedDialogBinding) { val speed = player.getPlaybackSpeed() @@ -907,6 +987,10 @@ open class FullScreenPlayer : AbstractPlayerFragment( secView?.visibility = if (show || DataStoreHelper.alwaysShowSecondarySubtitles) View.VISIBLE else View.GONE } + override fun onOpenDualSubtitleDialog() { + showDualSubtitlesDialog() + } + override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) 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 91bdd9b2eb6..d8c1192363f 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 @@ -154,6 +154,16 @@ class PlayerGestureHelper(private val playerView: PlayerView) { 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. */ @@ -1082,6 +1092,7 @@ class PlayerGestureHelper(private val playerView: PlayerView) { && !hasTriggeredSpeedUp && currentTouchAction == null) { holdHandler.removeCallbacks(holdRunnable) // Remove 2x speed. holdHandler.removeCallbacks(subRevealRunnable) + holdHandler.removeCallbacks(dualSubRunnable) isCurrentTouchValid = false // Prevent other touches return handleZoomPanGesture( event = event, @@ -1108,11 +1119,16 @@ class PlayerGestureHelper(private val playerView: PlayerView) { playerView.callbacks?.onTouchDown() hasTriggeredSpeedUp = false hasTriggeredSubReveal = false + hasTriggeredDualSub = false holdHandler.removeCallbacks(holdRunnable) holdHandler.removeCallbacks(subRevealRunnable) - val isRight30Percent = event.x >= view.width * 0.7f - Log.i(TAG, "ACTION_DOWN: isRight30Percent=$isRight30Percent (x=${event.x}, w=${view.width}), secSub=${playerView.player.getCurrentSecondarySubtitle()}") - if (isRight30Percent && !isLocked && playerView.player.getCurrentSecondarySubtitle() != null) { + 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) @@ -1132,7 +1148,7 @@ class PlayerGestureHelper(private val playerView: PlayerView) { } MotionEvent.ACTION_MOVE -> { - if (hasTriggeredSpeedUp || hasTriggeredSubReveal) return true + if (hasTriggeredSpeedUp || hasTriggeredSubReveal || hasTriggeredDualSub) return true if (!isCurrentTouchValid) return true if (currentTouchAction == null && startTouch != null) { @@ -1141,6 +1157,7 @@ class PlayerGestureHelper(private val playerView: PlayerView) { 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) @@ -1151,6 +1168,7 @@ class PlayerGestureHelper(private val playerView: PlayerView) { if (abs(diffFromStart.x * 100 / screenHeightWithOrientation) > MINIMUM_HORIZONTAL_SWIPE) { holdHandler.removeCallbacks(holdRunnable) holdHandler.removeCallbacks(subRevealRunnable) + holdHandler.removeCallbacks(dualSubRunnable) currentTouchAction = TouchAction.Time } } @@ -1191,6 +1209,19 @@ 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 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 e1d0fcc68c3..c60658d3771 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerView.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerView.kt @@ -134,6 +134,7 @@ class PlayerView @JvmOverloads constructor( /** Called when the hold-for-speedup gesture starts (show=true) or ends (show=false). */ fun onHoldSpeedUp(show: Boolean) {} fun onHoldSecondarySubtitle(show: Boolean) {} + fun onOpenDualSubtitleDialog() {} /** Called during brightness swipe with the current extra-brightness alpha (0–1). */ fun onBrightnessExtra(alpha: Float) {} diff --git a/app/src/main/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 @@ + + + + + + + + + + + + + + + + + + + From c2596e0292ceca2704e3eb155bfb9a46de05f945 Mon Sep 17 00:00:00 2001 From: nutine <40691418+nutine@users.noreply.github.com> Date: Mon, 7 Sep 2026 16:27:11 +0400 Subject: [PATCH 5/8] fix(player): support embedded video tracks for dual subtitles and cues - Add secondary TextRenderer to ExoPlayer RenderersFactory for simultaneous embedded track decoding - Implement applySubtitleSelection to independently route primary and secondary tracks via DefaultTrackSelector - Collect streaming embedded cues into primary and secondary cue lists - Remove embedded origin guard that blocked secondary subtitle selection --- .../cloudstream3/ui/player/CS3IPlayer.kt | 234 ++++++++++++++---- 1 file changed, 181 insertions(+), 53 deletions(-) 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 4d90bdeaeb3..026503dd9b9 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,11 @@ 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.MappingTrackSelector import androidx.media3.exoplayer.trackselection.TrackSelector import androidx.media3.extractor.mp4.FragmentedMp4Extractor import androidx.media3.ui.SubtitleView @@ -199,6 +201,12 @@ class CS3IPlayer : IPlayer { @Volatile private var primarySubtitleFuture: Future<*>? = null @Volatile private var primaryCues: List = emptyList() + private var primaryTextRendererIndex: Int = -1 + private var secondaryTextRendererIndex: Int = -1 + 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 @@ -520,6 +528,72 @@ class CS3IPlayer : IPlayer { ) } + 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 + } + + 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) + } + } + } + + 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) + } + } + } + + trackSelector.setParameters(builder) + } + /** * @return True if the player should be reloaded * */ @@ -527,43 +601,8 @@ class CS3IPlayer : IPlayer { Log.i(TAG, "setPreferredSubtitles init $subtitle") currentSubtitles = subtitle loadPrimaryCues(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 - } - - SubtitleStatus.NOT_FOUND -> { - Log.i(TAG, "setPreferredSubtitles NOT_FOUND") - return true - } - - 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 - } - } + applySubtitleSelection() + return false } private var currentSubtitleOffset: Long = 0 @@ -600,6 +639,7 @@ class CS3IPlayer : IPlayer { 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) @@ -678,7 +718,10 @@ class CS3IPlayer : IPlayer { 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 { @@ -698,10 +741,18 @@ class CS3IPlayer : IPlayer { override fun getCurrentSecondarySubtitle(): SubtitleData? = currentSecondarySubtitle - override fun getSecondarySubtitleCues(): List = secondaryCues + 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 + if (currentSecondarySubtitle?.origin == SubtitleOrigin.EMBEDDED_IN_VIDEO) { + view.setCues(latestEmbeddedSecondaryCues) + return + } val position = exoPlayer?.currentPosition ?: return val active = secondaryCues.filter { it.startTimeMs <= position + currentSecondarySubtitleOffset && position + currentSecondarySubtitleOffset < it.endTimeMs } val activeSignature = active.map { "${it.startTimeMs}:${it.endTimeMs}:${it.text.joinToString(" ")}" } @@ -792,6 +843,9 @@ class CS3IPlayer : IPlayer { primarySubtitleFuture?.cancel(true) primarySubtitleFuture = null primaryCues = emptyList() + latestEmbeddedSecondaryCues = emptyList() + synchronized(embeddedPrimaryCues) { embeddedPrimaryCues.clear() } + synchronized(embeddedSecondaryCues) { embeddedSecondaryCues.clear() } } currentTextRenderer = null currentSubtitleDecoder = null @@ -1384,39 +1438,112 @@ 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( @@ -1694,6 +1821,7 @@ class CS3IPlayer : IPlayer { event(EmbeddedSubtitlesFetchedEvent(tracks = exoPlayerReportedTracks)) event(TracksChangedEvent()) event(SubtitlesUpdatedEvent()) + applySubtitleSelection() } } From ee8753d4834cb94cecc2043f4a92c8b20133489e Mon Sep 17 00:00:00 2001 From: nutine <40691418+nutine@users.noreply.github.com> Date: Mon, 7 Sep 2026 16:37:15 +0400 Subject: [PATCH 6/8] fix(player): implement DualDefaultTrackSelector for simultaneous embedded track decoding - Add DualDefaultTrackSelector to route secondaryTrackId directly to secondary TextRenderer in selectAllTracks - Match cues by playback position in pushSecondaryCues so secondary embedded subtitles persist when paused - Retain recorded cues across playback states --- .../cloudstream3/ui/player/CS3IPlayer.kt | 110 ++++++++++++++++-- 1 file changed, 98 insertions(+), 12 deletions(-) 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 026503dd9b9..a241bf38188 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 @@ -69,6 +69,7 @@ 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 @@ -148,6 +149,65 @@ const val toleranceBeforeUs = 300_000L */ const val toleranceAfterUs = 300_000L +@OptIn(UnstableApi::class) +class DualDefaultTrackSelector(context: Context) : DefaultTrackSelector(context) { + var secondaryTrackId: String? = null + var secondaryRendererIndex: Int = -1 + + override fun selectAllTracks( + mappedTrackInfo: MappingTrackSelector.MappedTrackInfo, + rendererFormatSupports: Array>, + rendererMixedMimeTypeAdaptationSupport: IntArray, + params: Parameters + ): Array { + val definitions = super.selectAllTracks( + mappedTrackInfo, + rendererFormatSupports, + rendererMixedMimeTypeAdaptationSupport, + params + ) + val sIdx = secondaryRendererIndex + val secId = secondaryTrackId + if (sIdx in definitions.indices && secId != null) { + val unmapped = mappedTrackInfo.unmappedTrackGroups + var targetGroup: TrackGroup? = null + var targetTrackIndex: Int = -1 + for (r in 0 until mappedTrackInfo.rendererCount) { + val groups = mappedTrackInfo.getTrackGroups(r) + for (g in 0 until groups.length) { + val group = groups.get(g) + for (t in 0 until group.length) { + if (group.getFormat(t).id?.replace(Regex("""^\d+:"""), "") == secId) { + targetGroup = group + targetTrackIndex = t + break + } + } + if (targetGroup != null) break + } + if (targetGroup != null) break + } + if (targetGroup == null) { + for (g in 0 until unmapped.length) { + val group = unmapped.get(g) + for (t in 0 until group.length) { + if (group.getFormat(t).id?.replace(Regex("""^\d+:"""), "") == secId) { + targetGroup = group + targetTrackIndex = t + break + } + } + if (targetGroup != null) break + } + } + if (targetGroup != null && targetTrackIndex >= 0) { + definitions[sIdx] = ExoTrackSelection.Definition(targetGroup, targetTrackIndex) + } + } + return definitions + } +} + @OptIn(UnstableApi::class) class CS3IPlayer : IPlayer { private var playerListener: Player.Listener? = null @@ -203,6 +263,7 @@ class CS3IPlayer : IPlayer { 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() @@ -591,6 +652,11 @@ class CS3IPlayer : IPlayer { } } + dualTrackSelector?.let { sel -> + sel.secondaryTrackId = currentSecondarySubtitle?.getId() + sel.secondaryRendererIndex = secondaryTextRendererIndex + } + trackSelector.setParameters(builder) } @@ -749,15 +815,7 @@ class CS3IPlayer : IPlayer { private fun pushSecondaryCues() { val view = subtitleHelper.secondarySubtitleView ?: return - if (currentSecondarySubtitle?.origin == SubtitleOrigin.EMBEDDED_IN_VIDEO) { - view.setCues(latestEmbeddedSecondaryCues) - return - } val position = exoPlayer?.currentPosition ?: 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 val baseStyle = CustomDecoder.style ?: SaveCaptionStyle( foregroundColor = Color.WHITE, backgroundColor = Color.TRANSPARENT, @@ -782,6 +840,34 @@ class CS3IPlayer : IPlayer { 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")) @@ -1123,8 +1209,8 @@ class CS3IPlayer : IPlayer { return getMediaItemBuilder(mimeType).setUri(url).build() } - private fun getTrackSelector(context: Context, maxVideoHeight: Int?): TrackSelector { - val trackSelector = DefaultTrackSelector(context) + private fun getTrackSelector(context: Context, maxVideoHeight: Int?): DualDefaultTrackSelector { + val trackSelector = DualDefaultTrackSelector(context) trackSelector.parameters = trackSelector.buildUponParameters() // This will not force higher quality videos to fail // but will make the m3u8 pick the correct preferred @@ -1546,10 +1632,10 @@ class CS3IPlayer : IPlayer { 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)) From 7bfd0b3b1a110617b9cffc1fb6adfb01ee272f58 Mon Sep 17 00:00:00 2001 From: nutine <40691418+nutine@users.noreply.github.com> Date: Mon, 14 Sep 2026 03:11:53 +0400 Subject: [PATCH 7/8] fix(player): add comparison dialog auto-resume and resolve landscape insets --- .../ui/player/FullScreenPlayer.kt | 5 +++++ .../ui/player/PlayerGestureHelper.kt | 21 +++++++++++++------ 2 files changed, 20 insertions(+), 6 deletions(-) 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 3bb471bae56..fe13dd06c25 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt @@ -656,9 +656,11 @@ 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() @@ -730,6 +732,9 @@ open class FullScreenPlayer : AbstractPlayerFragment( dialog.setOnDismissListener { dualSubtitlesDialog = null + if (dualSubWasPlaying) { + player.handleEvent(CSPlayerEvent.Play, PlayerEventSource.UI) + } activity?.hideSystemUI() } } 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 d8c1192363f..c47e8aa9798 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGestureHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGestureHelper.kt @@ -32,7 +32,9 @@ import androidx.media3.exoplayer.ExoPlayer import androidx.media3.ui.AspectRatioFrameLayout import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.CommonActivity.keyEventListener +import com.lagradost.cloudstream3.CommonActivity.screenHeight import com.lagradost.cloudstream3.CommonActivity.screenHeightWithOrientation +import com.lagradost.cloudstream3.CommonActivity.screenWidth import com.lagradost.cloudstream3.CommonActivity.screenWidthWithOrientation import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.R @@ -1072,15 +1074,22 @@ class PlayerGestureHelper(private val playerView: PlayerView) { } private fun isValidTouch(rawX: Float, rawY: Float): Boolean { + val holder = playerView.playerHolder ?: return true + val viewW = holder.width.takeIf { it > 0 } ?: screenWidth + val viewH = holder.height.takeIf { it > 0 } ?: screenHeight + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - val holder = playerView.playerHolder ?: return true - val insets = holder.rootWindowInsets.getInsetsIgnoringVisibility(WindowInsets.Type.systemBars()) - val validHeight = rawY > insets.top && rawY < screenHeightWithOrientation - insets.bottom - val validWidth = rawX > insets.left && rawX < screenWidthWithOrientation - insets.right - return validHeight && validWidth + val rootInsets = holder.rootWindowInsets + if (rootInsets != null) { + val visibleInsets = rootInsets.getInsets(WindowInsets.Type.systemBars()) + val validHeight = rawY >= visibleInsets.top && rawY <= viewH - visibleInsets.bottom + val validWidth = rawX >= visibleInsets.left && rawX <= viewW - visibleInsets.right + return validHeight && validWidth + } + return true } - return rawY > context.getStatusBarHeight() && rawX < screenWidthWithOrientation + return rawY >= context.getStatusBarHeight() && rawX <= viewW } private fun handleGesture(view: View, event: MotionEvent): Boolean { From d5cf9effe6df97e6e3c4ce2aa6952221a7d2a86c Mon Sep 17 00:00:00 2001 From: nutine <40691418+nutine@users.noreply.github.com> Date: Wed, 16 Sep 2026 22:16:56 +0400 Subject: [PATCH 8/8] fix(player): align SaveCaptionStyle with upstream SubtitleFont refactor --- .../java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 a241bf38188..6803694ac51 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt @@ -822,7 +822,7 @@ class CS3IPlayer : IPlayer { windowColor = Color.TRANSPARENT, edgeType = 1, edgeColor = Color.BLACK, - typeface = null, + font = null, typefaceFilePath = null, elevation = 20, fixedTextSize = null, @@ -1545,7 +1545,7 @@ class CS3IPlayer : IPlayer { windowColor = Color.TRANSPARENT, edgeType = 1, edgeColor = Color.BLACK, - typeface = null, + font = null, typefaceFilePath = null, elevation = 20, fixedTextSize = null,