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