diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ee4c978f2be..8ef3aeeeb25 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -8,6 +8,7 @@ + diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt index e5a460b9a02..3fd1ddea0be 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt @@ -2,6 +2,7 @@ package com.lagradost.cloudstream3.ui.player import android.os.Bundle import android.view.View +import android.widget.FrameLayout import android.widget.ImageView import androidx.annotation.OptIn import androidx.annotation.StringRes @@ -49,6 +50,7 @@ abstract class AbstractPlayerFragment( } val subView: SubtitleView? get() = playerHostView?.subView + val subtitleHolder: FrameLayout? get() = playerHostView?.subtitleHolder val playerPausePlay: ImageView? get() = playerHostView?.playerPausePlay /** The underlying [androidx.media3.ui.PlayerView] widget (named to avoid conflict with our [PlayerView]). */ diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt index d316f28cd19..c010fa5a04b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt @@ -65,9 +65,12 @@ import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.exoplayer.source.MediaSource import androidx.media3.exoplayer.source.MergingMediaSource import androidx.media3.exoplayer.source.SingleSampleMediaSource +import androidx.media3.exoplayer.source.TrackGroupArray import androidx.media3.exoplayer.text.TextOutput import androidx.media3.exoplayer.text.TextRenderer import androidx.media3.exoplayer.trackselection.DefaultTrackSelector +import androidx.media3.exoplayer.trackselection.ExoTrackSelection +import androidx.media3.exoplayer.trackselection.MappingTrackSelector import androidx.media3.exoplayer.trackselection.TrackSelector import androidx.media3.extractor.mp4.FragmentedMp4Extractor import androidx.media3.ui.SubtitleView @@ -110,11 +113,23 @@ import com.lagradost.cloudstream3.utils.WIDEVINE_DRM_UUID import com.lagradost.cloudstream3.utils.videoskip.VideoSkipStamp import kotlinx.coroutines.delay import okhttp3.Interceptor +import okhttp3.Request import org.chromium.net.CronetEngine import java.io.File import java.security.SecureRandom import java.util.UUID +import java.util.concurrent.ExecutorService import java.util.concurrent.Executors +import java.util.concurrent.Future +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicLong +import androidx.media3.common.text.Cue +import com.lagradost.cloudstream3.ui.player.CustomDecoder.Companion.fixSubtitleAlignment +import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment.Companion.applyStyle +import com.lagradost.cloudstream3.CommonActivity.showToast +import android.graphics.Color +import android.view.View +import com.lagradost.cloudstream3.CloudStreamApp import javax.net.ssl.HttpsURLConnection import javax.net.ssl.SSLContext import javax.net.ssl.SSLSession @@ -133,6 +148,65 @@ const val toleranceBeforeUs = 300_000L */ const val toleranceAfterUs = 300_000L +@OptIn(UnstableApi::class) +class DualDefaultTrackSelector(context: Context) : DefaultTrackSelector(context) { + var secondaryTrackId: String? = null + var secondaryRendererIndex: Int = -1 + + override fun selectAllTracks( + mappedTrackInfo: MappingTrackSelector.MappedTrackInfo, + rendererFormatSupports: Array>, + rendererMixedMimeTypeAdaptationSupport: IntArray, + params: Parameters + ): Array { + val definitions = super.selectAllTracks( + mappedTrackInfo, + rendererFormatSupports, + rendererMixedMimeTypeAdaptationSupport, + params + ) + val sIdx = secondaryRendererIndex + val secId = secondaryTrackId + if (sIdx in definitions.indices && secId != null) { + val unmapped = mappedTrackInfo.unmappedTrackGroups + var targetGroup: TrackGroup? = null + var targetTrackIndex: Int = -1 + for (r in 0 until mappedTrackInfo.rendererCount) { + val groups = mappedTrackInfo.getTrackGroups(r) + for (g in 0 until groups.length) { + val group = groups.get(g) + for (t in 0 until group.length) { + if (group.getFormat(t).id?.replace(Regex("""^\d+:"""), "") == secId) { + targetGroup = group + targetTrackIndex = t + break + } + } + if (targetGroup != null) break + } + if (targetGroup != null) break + } + if (targetGroup == null) { + for (g in 0 until unmapped.length) { + val group = unmapped.get(g) + for (t in 0 until group.length) { + if (group.getFormat(t).id?.replace(Regex("""^\d+:"""), "") == secId) { + targetGroup = group + targetTrackIndex = t + break + } + } + if (targetGroup != null) break + } + } + if (targetGroup != null && targetTrackIndex >= 0) { + definitions[sIdx] = ExoTrackSelection.Definition(targetGroup, targetTrackIndex) + } + } + return definitions + } +} + @OptIn(UnstableApi::class) class CS3IPlayer : IPlayer { private var playerListener: Player.Listener? = null @@ -172,6 +246,27 @@ class CS3IPlayer : IPlayer { private val subtitleHelper = PlayerSubtitleHelper() + private var secondarySubtitleExecutor: ExecutorService = newSecondarySubtitleExecutor() + private fun newSecondarySubtitleExecutor(): ExecutorService = Executors.newSingleThreadExecutor { + Thread(it, "secondary-subtitle-decoder").apply { isDaemon = true } + } + private val secondarySubtitleGeneration = AtomicLong(0L) + @Volatile private var secondarySubtitleFuture: Future<*>? = null + @Volatile private var currentSecondarySubtitle: SubtitleData? = null + @Volatile private var secondaryCues: List = emptyList() + private var lastSecondaryCueSignature: List = emptyList() + + private val primarySubtitleGeneration = AtomicLong(0L) + @Volatile private var primarySubtitleFuture: Future<*>? = null + @Volatile private var primaryCues: List = emptyList() + + private var primaryTextRendererIndex: Int = -1 + private var secondaryTextRendererIndex: Int = -1 + private var dualTrackSelector: DualDefaultTrackSelector? = null + private var latestEmbeddedSecondaryCues: List = emptyList() + private val embeddedPrimaryCues = mutableListOf() + private val embeddedSecondaryCues = mutableListOf() + /** If we want to play the audio only in the background when the app is not open */ private var isAudioOnlyBackground = false @@ -284,6 +379,7 @@ class CS3IPlayer : IPlayer { saveData() } else { currentSubtitles = subtitle + loadPrimaryCues(subtitle) playbackPosition = 0 } @@ -492,52 +588,90 @@ class CS3IPlayer : IPlayer { ) } - /** - * @return True if the player should be reloaded - * */ - override fun setPreferredSubtitles(subtitle: SubtitleData?): Boolean { - Log.i(TAG, "setPreferredSubtitles init $subtitle") - currentSubtitles = subtitle - val trackSelector = exoPlayer?.trackSelector as? DefaultTrackSelector ?: return false - // Disable subtitles if null - if (subtitle == null) { - trackSelector.setParameters( - trackSelector.buildUponParameters() - .setTrackTypeDisabled(TRACK_TYPE_TEXT, true) - .clearOverridesOfType(TRACK_TYPE_TEXT) - ) - return false - } - // Handle subtitle based on status - when (subtitleHelper.subtitleStatus(subtitle)) { - SubtitleStatus.REQUIRES_RELOAD -> { - Log.i(TAG, "setPreferredSubtitles REQUIRES_RELOAD") - return true + private fun findTrackInGroups(trackGroups: TrackGroupArray, id: String?): Pair? { + if (id == null) return null + for (g in 0 until trackGroups.length) { + val group = trackGroups.get(g) + for (t in 0 until group.length) { + val format = group.getFormat(t) + if (format.id?.stripTrackId() == id) { + return Pair(g, t) + } } + } + return null + } - SubtitleStatus.NOT_FOUND -> { - Log.i(TAG, "setPreferredSubtitles NOT_FOUND") - return true + private fun applySubtitleSelection() { + val trackSelector = exoPlayer?.trackSelector as? DefaultTrackSelector ?: return + val mappedTrackInfo = trackSelector.currentMappedTrackInfo ?: return + val builder = trackSelector.buildUponParameters() + + if (primaryTextRendererIndex in 0 until mappedTrackInfo.rendererCount) { + val trackGroups = mappedTrackInfo.getTrackGroups(primaryTextRendererIndex) + val sub = currentSubtitles + if (sub == null) { + builder.setRendererDisabled(primaryTextRendererIndex, true) + builder.clearSelectionOverrides(primaryTextRendererIndex) + } else { + val trackPair = findTrackInGroups(trackGroups, sub.getId()) + if (trackPair != null) { + val (gIdx, tIdx) = trackPair + builder.setRendererDisabled(primaryTextRendererIndex, false) + builder.setSelectionOverride( + primaryTextRendererIndex, + trackGroups, + DefaultTrackSelector.SelectionOverride(gIdx, tIdx) + ) + } else { + builder.setRendererDisabled(primaryTextRendererIndex, false) + } } + } - SubtitleStatus.IS_ACTIVE -> { - Log.i(TAG, "setPreferredSubtitles IS_ACTIVE") - exoPlayer?.currentTracks?.groups - ?.filter { it.type == TRACK_TYPE_TEXT } - ?.getTrack(subtitle.getId()) - ?.let { (trackGroup, trackIndex) -> - trackSelector.setParameters( - trackSelector.buildUponParameters() - .setTrackTypeDisabled(TRACK_TYPE_TEXT, false) - .setOverrideForType(TrackSelectionOverride(trackGroup, trackIndex)) - ) - } - return false + if (secondaryTextRendererIndex in 0 until mappedTrackInfo.rendererCount) { + val trackGroups = mappedTrackInfo.getTrackGroups(secondaryTextRendererIndex) + val sub = currentSecondarySubtitle + if (sub == null) { + builder.setRendererDisabled(secondaryTextRendererIndex, true) + builder.clearSelectionOverrides(secondaryTextRendererIndex) + } else { + val trackPair = findTrackInGroups(trackGroups, sub.getId()) + if (trackPair != null) { + val (gIdx, tIdx) = trackPair + builder.setRendererDisabled(secondaryTextRendererIndex, false) + builder.setSelectionOverride( + secondaryTextRendererIndex, + trackGroups, + DefaultTrackSelector.SelectionOverride(gIdx, tIdx) + ) + } else { + builder.setRendererDisabled(secondaryTextRendererIndex, false) + } } } + + dualTrackSelector?.let { sel -> + sel.secondaryTrackId = currentSecondarySubtitle?.getId() + sel.secondaryRendererIndex = secondaryTextRendererIndex + } + + trackSelector.setParameters(builder) + } + + /** + * @return True if the player should be reloaded + * */ + override fun setPreferredSubtitles(subtitle: SubtitleData?): Boolean { + Log.i(TAG, "setPreferredSubtitles init $subtitle") + currentSubtitles = subtitle + loadPrimaryCues(subtitle) + applySubtitleSelection() + return false } private var currentSubtitleOffset: Long = 0 + private var currentSecondarySubtitleOffset: Long = 0 override fun setSubtitleOffset(offset: Long) { currentSubtitleOffset = offset @@ -555,16 +689,243 @@ class CS3IPlayer : IPlayer { return currentSubtitleOffset } + override fun setSecondarySubtitleOffset(offset: Long) { + currentSecondarySubtitleOffset = offset + lastSecondaryCueSignature = emptyList() + pushSecondaryCues() + } + + override fun getSecondarySubtitleOffset(): Long { + return currentSecondarySubtitleOffset + } + override fun getSubtitleCues(): List { - return currentSubtitleDecoder?.getSubtitleCues() ?: emptyList() + val active = getCurrentPreferredSubtitle() + if (primaryCues.isNotEmpty()) return primaryCues + val decoderCues = currentSubtitleDecoder?.getSubtitleCues() + if (!decoderCues.isNullOrEmpty()) return decoderCues + if (embeddedPrimaryCues.isNotEmpty()) return synchronized(embeddedPrimaryCues) { embeddedPrimaryCues.toList() } + if (active != null && active.origin != SubtitleOrigin.EMBEDDED_IN_VIDEO) { + try { + primarySubtitleFuture?.get(1500, TimeUnit.MILLISECONDS) + if (primaryCues.isNotEmpty()) return primaryCues + } catch (_: Throwable) {} + } + return primaryCues + } + + private fun fetchSubtitleFromUrl(subtitle: SubtitleData): ByteArray? { + val fixedUrl = subtitle.getFixedUrl() + val reqHeaders = subtitle.headers.toMutableMap() + if (reqHeaders.keys.none { it.equals("User-Agent", ignoreCase = true) }) { + reqHeaders["User-Agent"] = USER_AGENT + } + if (reqHeaders.keys.none { it.equals("Referer", ignoreCase = true) }) { + try { + val uri = Uri.parse(fixedUrl) + if (uri.scheme != null && uri.host != null) { + reqHeaders["Referer"] = "${uri.scheme}://${uri.host}/" + } + } catch (_: Throwable) {} + } + return app.baseClient.newCall( + Request.Builder().url(fixedUrl).apply { + reqHeaders.forEach { (key, value) -> addHeader(key, value) } + }.build() + ).execute().use { resp -> + val body = resp.body.bytes() + if (body.size > 200) { + val preview = String(body.take(200).toByteArray()) + if (preview.contains(" { + val bytes = fetchSubtitleBytes(subtitle) ?: return emptyList() + val decoder = CustomDecoder(Format.Builder().setSampleMimeType(subtitle.mimeType).build()) + decoder.parseToLegacySubtitle(bytes, 0, bytes.size) + return synchronized(decoder.currentSubtitleCues) { decoder.currentSubtitleCues.toList() } + } + + private fun fetchSubtitleBytes(subtitle: SubtitleData): ByteArray? { + return when (subtitle.origin) { + SubtitleOrigin.URL -> fetchSubtitleFromUrl(subtitle) + SubtitleOrigin.DOWNLOADED_FILE -> { + val rawUrl = subtitle.url + try { + val file = File(rawUrl) + if (file.exists() && file.isFile) { + return file.readBytes() + } + val uri = Uri.parse(rawUrl) + if (uri.scheme == "file") { + val f = File(uri.path ?: "") + if (f.exists() && f.isFile) return f.readBytes() + } + CloudStreamApp.context?.contentResolver?.openInputStream(uri)?.use { it.readBytes() } + } catch (t: Throwable) { + logError(t) + null + } + } + SubtitleOrigin.EMBEDDED_IN_VIDEO -> null + } + } + + private fun loadPrimaryCues(subtitle: SubtitleData?) { + val generation = primarySubtitleGeneration.incrementAndGet() + primarySubtitleFuture?.cancel(true) + primarySubtitleFuture = null + if (subtitle == null || subtitle.origin == SubtitleOrigin.EMBEDDED_IN_VIDEO) { + primaryCues = emptyList() + return + } + if (secondarySubtitleExecutor.isShutdown) secondarySubtitleExecutor = newSecondarySubtitleExecutor() + primarySubtitleFuture = secondarySubtitleExecutor.submit { + try { + val bytes = fetchSubtitleBytes(subtitle) ?: return@submit + if (primarySubtitleGeneration.get() != generation) return@submit + val decoder = CustomDecoder(Format.Builder().setSampleMimeType(subtitle.mimeType).build()) + decoder.parseToLegacySubtitle(bytes, 0, bytes.size) + val cues = synchronized(decoder.currentSubtitleCues) { decoder.currentSubtitleCues.toList() } + Log.i(TAG, "Primary subtitle parsed cues count: ${cues.size}") + if (primarySubtitleGeneration.get() != generation) return@submit + primaryCues = cues + } catch (t: Throwable) { if (t !is InterruptedException) logError(t) } + } + } + + override fun setSecondarySubtitles(subtitle: SubtitleData?) { + val generation = secondarySubtitleGeneration.incrementAndGet() + secondarySubtitleFuture?.cancel(true) + secondarySubtitleFuture = null + currentSecondarySubtitle = subtitle + secondaryCues = emptyList() + lastSecondaryCueSignature = emptyList() + latestEmbeddedSecondaryCues = emptyList() + synchronized(embeddedSecondaryCues) { embeddedSecondaryCues.clear() } + pushSecondaryCues() + applySubtitleSelection() + if (subtitle == null || subtitle.origin == SubtitleOrigin.EMBEDDED_IN_VIDEO) return + if (secondarySubtitleExecutor.isShutdown) secondarySubtitleExecutor = newSecondarySubtitleExecutor() + secondarySubtitleFuture = secondarySubtitleExecutor.submit { + try { + val bytes = fetchSubtitleBytes(subtitle) ?: return@submit + if (secondarySubtitleGeneration.get() != generation) return@submit + val decoder = CustomDecoder(Format.Builder().setSampleMimeType(subtitle.mimeType).build()) + decoder.parseToLegacySubtitle(bytes, 0, bytes.size) + val cues = synchronized(decoder.currentSubtitleCues) { decoder.currentSubtitleCues.toList() } + Log.i(TAG, "Secondary subtitle parsed cues count: ${cues.size}") + if (secondarySubtitleGeneration.get() != generation) return@submit + secondaryCues = cues + runOnMainThread { if (secondarySubtitleGeneration.get() == generation) pushSecondaryCues() } + } catch (t: Throwable) { if (t !is InterruptedException) logError(t) } + } + } + + override fun setDirectSecondaryCues(cues: List, subtitle: SubtitleData?) { + val generation = secondarySubtitleGeneration.incrementAndGet() + secondarySubtitleFuture?.cancel(true) + secondarySubtitleFuture = null + currentSecondarySubtitle = subtitle + secondaryCues = cues + lastSecondaryCueSignature = emptyList() + latestEmbeddedSecondaryCues = emptyList() + synchronized(embeddedSecondaryCues) { embeddedSecondaryCues.clear() } + pushSecondaryCues() + applySubtitleSelection() + Log.i(TAG, "setDirectSecondaryCues applied directly with ${cues.size} cues") + } + + override fun getCurrentSecondarySubtitle(): SubtitleData? = currentSecondarySubtitle + + override fun getSecondarySubtitleCues(): List { + if (secondaryCues.isNotEmpty()) return secondaryCues + if (embeddedSecondaryCues.isNotEmpty()) return synchronized(embeddedSecondaryCues) { embeddedSecondaryCues.toList() } + return secondaryCues + } + + private fun pushSecondaryCues() { + val view = subtitleHelper.secondarySubtitleView ?: return + val position = exoPlayer?.currentPosition ?: return + val baseStyle = CustomDecoder.style ?: SaveCaptionStyle( + foregroundColor = Color.WHITE, + backgroundColor = Color.TRANSPARENT, + windowColor = Color.TRANSPARENT, + edgeType = 1, + edgeColor = Color.BLACK, + typeface = null, + typefaceFilePath = null, + elevation = 20, + fixedTextSize = null, + edgeSize = null, + removeCaptions = false, + removeBloat = true, + upperCase = false, + bold = false, + italic = false, + backgroundRadius = null, + alignment = null + ) + val transparentTopStyle = baseStyle.copy( + backgroundColor = Color.TRANSPARENT, + windowColor = Color.TRANSPARENT, + backgroundRadius = null + ) + if (currentSecondarySubtitle?.origin == SubtitleOrigin.EMBEDDED_IN_VIDEO) { + val matchingCue = synchronized(embeddedSecondaryCues) { + embeddedSecondaryCues.lastOrNull { + position in it.startTimeMs..(it.startTimeMs + it.durationMs) + } ?: embeddedSecondaryCues.lastOrNull { + kotlin.math.abs(it.startTimeMs - position) < 3000L + } + } + if (matchingCue != null) { + view.setCues(matchingCue.text.map { line -> + Cue.Builder() + .setText(line) + .setTextSize(25f, Cue.TEXT_SIZE_TYPE_ABSOLUTE) + .setLine(0f, Cue.LINE_TYPE_FRACTION) + .setLineAnchor(Cue.ANCHOR_TYPE_START) + .fixSubtitleAlignment() + .applyStyle(transparentTopStyle) + .build() + }) + } else if (latestEmbeddedSecondaryCues.isNotEmpty()) { + view.setCues(latestEmbeddedSecondaryCues) + } + return + } + val active = secondaryCues.filter { it.startTimeMs <= position + currentSecondarySubtitleOffset && position + currentSecondarySubtitleOffset < it.endTimeMs } + val activeSignature = active.map { "${it.startTimeMs}:${it.endTimeMs}:${it.text.joinToString(" ")}" } + if (activeSignature == lastSecondaryCueSignature) return + lastSecondaryCueSignature = activeSignature + view.setCues(active.map { cue -> + Cue.Builder() + .setText(cue.text.joinToString("\n")) + .setTextSize(25f, Cue.TEXT_SIZE_TYPE_ABSOLUTE) + .setLine(0f, Cue.LINE_TYPE_FRACTION) + .setLineAnchor(Cue.ANCHOR_TYPE_START) + .fixSubtitleAlignment() + .applyStyle(transparentTopStyle) + .build() + }) } override fun getCurrentPreferredSubtitle(): SubtitleData? { - return subtitleHelper.getAllSubtitles().firstOrNull { sub -> + val active = subtitleHelper.getAllSubtitles().firstOrNull { sub -> playerSelectedSubtitleTracks.any { (id, isSelected) -> isSelected && sub.getId() == id } + } ?: currentSubtitles + if (active != null && primaryCues.isEmpty() && active.origin != SubtitleOrigin.EMBEDDED_IN_VIDEO) { + loadPrimaryCues(active) } + return active } override fun getAspectRatio(): Rational? { @@ -594,8 +955,23 @@ class CS3IPlayer : IPlayer { if (saveTime) updatedTime() + secondarySubtitleGeneration.incrementAndGet() + secondarySubtitleFuture?.cancel(true) + secondarySubtitleFuture = null + secondaryCues = emptyList() + lastSecondaryCueSignature = emptyList() + if (!saveTime) { + primarySubtitleGeneration.incrementAndGet() + primarySubtitleFuture?.cancel(true) + primarySubtitleFuture = null + primaryCues = emptyList() + latestEmbeddedSecondaryCues = emptyList() + synchronized(embeddedPrimaryCues) { embeddedPrimaryCues.clear() } + synchronized(embeddedSecondaryCues) { embeddedSecondaryCues.clear() } + } currentTextRenderer = null currentSubtitleDecoder = null + pushSecondaryCues() exoPlayer?.apply { playWhenReady = false @@ -650,6 +1026,7 @@ class CS3IPlayer : IPlayer { override fun release() { imageGenerator.release() releasePlayer() + secondarySubtitleExecutor.shutdownNow() } override fun setPlaybackSpeed(speed: Float) { @@ -868,8 +1245,8 @@ class CS3IPlayer : IPlayer { return getMediaItemBuilder(mimeType).setUri(url).build() } - private fun getTrackSelector(context: Context, maxVideoHeight: Int?): TrackSelector { - val trackSelector = DefaultTrackSelector(context) + private fun getTrackSelector(context: Context, maxVideoHeight: Int?): DualDefaultTrackSelector { + val trackSelector = DualDefaultTrackSelector(context) trackSelector.parameters = trackSelector.buildUponParameters() // This will not force higher quality videos to fail // but will make the m3u8 pick the correct preferred @@ -897,6 +1274,7 @@ class CS3IPlayer : IPlayer { writePosition: Long? = null, source: PlayerEventSource = PlayerEventSource.Player ) { + pushSecondaryCues() val position = writePosition ?: exoPlayer?.currentPosition getCurrentTimestamp(position)?.let { timestamp -> @@ -1182,44 +1560,118 @@ class CS3IPlayer : IPlayer { val combinedCues = styledBitmapCues + styledTextCues + val pos = exoPlayer?.currentPosition ?: 0L + val textLines = textCues.mapNotNull { it.text?.toString() } + if (textLines.isNotEmpty()) { + synchronized(embeddedPrimaryCues) { + if (embeddedPrimaryCues.none { kotlin.math.abs(it.startTimeMs - pos) < 800L && it.text == textLines }) { + embeddedPrimaryCues.add(SubtitleCue(pos, 3000L, textLines)) + } + } + } + subtitleHelper.subtitleView?.setCues(combinedCues) + pushSecondaryCues() } - factory.createRenderers( + val secondaryTextOutput = TextOutput { cueGroup -> + val baseStyle = CustomDecoder.style ?: SaveCaptionStyle( + foregroundColor = Color.WHITE, + backgroundColor = Color.TRANSPARENT, + windowColor = Color.TRANSPARENT, + edgeType = 1, + edgeColor = Color.BLACK, + typeface = null, + typefaceFilePath = null, + elevation = 20, + fixedTextSize = null, + edgeSize = null, + removeCaptions = false, + removeBloat = true, + upperCase = false, + bold = false, + italic = false, + backgroundRadius = null, + alignment = null + ) + val transparentTopStyle = baseStyle.copy( + backgroundColor = Color.TRANSPARENT, + windowColor = Color.TRANSPARENT, + backgroundRadius = null + ) + val styledCues = cueGroup.cues.map { cue -> + cue.buildUpon() + .setLine(0f, Cue.LINE_TYPE_FRACTION) + .setLineAnchor(Cue.ANCHOR_TYPE_START) + .fixSubtitleAlignment() + .applyStyle(transparentTopStyle) + .build() + } + latestEmbeddedSecondaryCues = styledCues + val pos = exoPlayer?.currentPosition ?: 0L + val textLines = cueGroup.cues.mapNotNull { it.text?.toString() } + if (textLines.isNotEmpty()) { + synchronized(embeddedSecondaryCues) { + if (embeddedSecondaryCues.none { kotlin.math.abs(it.startTimeMs - pos) < 800L && it.text == textLines }) { + embeddedSecondaryCues.add(SubtitleCue(pos, 3000L, textLines)) + } + } + } + pushSecondaryCues() + } + + val renderersList = mutableListOf() + var pIdx = -1 + var sIdx = -1 + for (r in factory.createRenderers( eventHandler, videoRendererEventListener, audioRendererEventListener, customTextOutput, metadataRendererOutput - ).map { - if (it is TextRenderer) { + )) { + if (r is TextRenderer) { CustomDecoder.subtitleOffset = subtitleOffset - val decoder = CustomSubtitleDecoderFactory() - - // @OptIn(ExperimentalApi::class) - val currentTextRenderer = TextRenderer( + val primaryDecoder = CustomSubtitleDecoderFactory() + val primaryRenderer = TextRenderer( customTextOutput, eventHandler.looper, - decoder + primaryDecoder ).apply { - // Required to make the decoder work with old subtitles - // Upgrade CustomSubtitleDecoderFactory when media3 supports it @Suppress("DEPRECATION") experimentalSetLegacyDecodingEnabled(true) }.also { renderer -> currentTextRenderer = renderer - currentSubtitleDecoder = decoder + currentSubtitleDecoder = primaryDecoder + } + + val secondaryDecoder = CustomSubtitleDecoderFactory() + val secondaryRenderer = TextRenderer( + secondaryTextOutput, + eventHandler.looper, + secondaryDecoder + ).apply { + @Suppress("DEPRECATION") + experimentalSetLegacyDecodingEnabled(true) } - currentTextRenderer - } else - it - }.toTypedArray() + + pIdx = renderersList.size + renderersList.add(primaryRenderer) + sIdx = renderersList.size + renderersList.add(secondaryRenderer) + } else { + renderersList.add(r) + } + } + primaryTextRendererIndex = pIdx + secondaryTextRendererIndex = sIdx + renderersList.toTypedArray() } .setTrackSelector( - trackSelector ?: getTrackSelector( + (trackSelector as? DualDefaultTrackSelector ?: getTrackSelector( context, maxVideoHeight - ) + )).also { dualTrackSelector = it } ) // Allows any seeking to be +- 0.3s to allow for faster seeking .setSeekParameters(SeekParameters(toleranceBeforeUs, toleranceAfterUs)) @@ -1484,6 +1936,7 @@ class CS3IPlayer : IPlayer { event(EmbeddedSubtitlesFetchedEvent(tracks = exoPlayerReportedTracks)) event(TracksChangedEvent()) event(SubtitlesUpdatedEvent()) + applySubtitleSelection() } } @@ -1718,7 +2171,9 @@ class CS3IPlayer : IPlayer { ): Pair, List> { val activeSubtitles = ArrayList() val subSources = subHelper.getAllSubtitles().mapNotNull { sub -> - val subConfig = MediaItem.SubtitleConfiguration.Builder(sub.getFixedUrl().toUri()) + val fixedUrl = sub.getFixedUrl() + val uri = if (fixedUrl.startsWith("/")) File(fixedUrl).toUri() else fixedUrl.toUri() + val subConfig = MediaItem.SubtitleConfiguration.Builder(uri) .setMimeType(sub.mimeType) .setLanguage("_${sub.name}") .setId(sub.getId()) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubtitleDecoderFactory.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubtitleDecoderFactory.kt index 61d6f556450..0dc7abaeb62 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubtitleDecoderFactory.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubtitleDecoderFactory.kt @@ -356,7 +356,7 @@ class CustomDecoder(private val fallbackFormat: Format?) : SubtitleParser { } override fun reset() { - currentSubtitleCues.clear() + // Do not clear currentSubtitleCues here so they remain available for subtitle sync and comparison super.reset() } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/DualSubtitleAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/DualSubtitleAdapter.kt new file mode 100644 index 00000000000..9b9428dc3d1 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/DualSubtitleAdapter.kt @@ -0,0 +1,189 @@ +package com.lagradost.cloudstream3.ui.player + +import android.animation.ObjectAnimator +import android.view.LayoutInflater +import android.view.ViewGroup +import android.view.animation.DecelerateInterpolator +import androidx.core.view.isInvisible +import com.lagradost.cloudstream3.databinding.DialogDualSubtitlesItemBinding +import com.lagradost.cloudstream3.ui.BaseDiffCallback +import com.lagradost.cloudstream3.ui.NoStateAdapter +import com.lagradost.cloudstream3.ui.ViewHolderState +import java.util.Locale +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt + +data class DualSubtitleCue( + val startTimeMs: Long, + val endTimeMs: Long, + val primaryText: String?, + val secondaryText: String?, +) + +object DualSubtitleAligner { + fun align( + primaryCues: List, + primaryOffset: Long, + secondaryCues: List, + secondaryOffset: Long + ): List { + val pList = primaryCues.map { + SubtitleCue(it.startTimeMs - primaryOffset, it.durationMs, it.text) + }.sortedBy { it.startTimeMs } + + val sList = secondaryCues.map { + SubtitleCue(it.startTimeMs - secondaryOffset, it.durationMs, it.text) + }.sortedBy { it.startTimeMs } + + if (pList.isEmpty() && sList.isEmpty()) return emptyList() + if (pList.isEmpty()) { + return sList.map { DualSubtitleCue(it.startTimeMs, it.endTimeMs, null, it.text.joinToString("\n")) } + } + if (sList.isEmpty()) { + return pList.map { DualSubtitleCue(it.startTimeMs, it.endTimeMs, it.text.joinToString("\n"), null) } + } + + val result = mutableListOf() + var pIdx = 0 + var sIdx = 0 + + while (pIdx < pList.size && sIdx < sList.size) { + val p = pList[pIdx] + val s = sList[sIdx] + + val overlaps = (p.startTimeMs < s.endTimeMs && s.startTimeMs < p.endTimeMs) || + kotlin.math.abs(p.startTimeMs - s.startTimeMs) <= 1200L + + if (overlaps) { + result.add( + DualSubtitleCue( + startTimeMs = min(p.startTimeMs, s.startTimeMs), + endTimeMs = max(p.endTimeMs, s.endTimeMs), + primaryText = p.text.joinToString("\n"), + secondaryText = s.text.joinToString("\n") + ) + ) + pIdx++ + sIdx++ + } else if (p.startTimeMs < s.startTimeMs) { + result.add( + DualSubtitleCue( + startTimeMs = p.startTimeMs, + endTimeMs = p.endTimeMs, + primaryText = p.text.joinToString("\n"), + secondaryText = null + ) + ) + pIdx++ + } else { + result.add( + DualSubtitleCue( + startTimeMs = s.startTimeMs, + endTimeMs = s.endTimeMs, + primaryText = null, + secondaryText = s.text.joinToString("\n") + ) + ) + sIdx++ + } + } + + while (pIdx < pList.size) { + val p = pList[pIdx++] + result.add(DualSubtitleCue(p.startTimeMs, p.endTimeMs, p.text.joinToString("\n"), null)) + } + while (sIdx < sList.size) { + val s = sList[sIdx++] + result.add(DualSubtitleCue(s.startTimeMs, s.endTimeMs, null, s.text.joinToString("\n"))) + } + + return result + } +} + +class DualSubtitleAdapter( + private var currentTimeMs: Long, + val clickCallback: (DualSubtitleCue) -> Unit +) : NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> + a.startTimeMs == b.startTimeMs && a.endTimeMs == b.endTimeMs +})) { + + companion object { + fun formatTime(timeMs: Long): String { + val totalSeconds = (timeMs / 1000).coerceAtLeast(0) + val seconds = totalSeconds % 60 + val minutes = totalSeconds / 60 % 60 + val hours = totalSeconds / 3600 + return if (hours > 0) { + String.format(Locale.US, "%d:%02d:%02d", hours, minutes, seconds) + } else { + String.format(Locale.US, "%02d:%02d", minutes, seconds) + } + } + } + + override fun onCreateContent(parent: ViewGroup): ViewHolderState { + val inflater = LayoutInflater.from(parent.context) + val binding = DialogDualSubtitlesItemBinding.inflate(inflater, parent, false) + return ViewHolderState(binding) + } + + override fun onBindContent(holder: ViewHolderState, item: DualSubtitleCue, position: Int) { + val binding = holder.view as? DialogDualSubtitlesItemBinding ?: return + + binding.root.setOnClickListener { + clickCallback.invoke(item) + } + + binding.primarySubText.text = item.primaryText ?: "—" + binding.secondarySubText.text = item.secondaryText ?: "—" + binding.timestampBadge.text = formatTime(item.startTimeMs) + + val timeMs = currentTimeMs + val startTime = item.startTimeMs + val endTime = item.endTimeMs + + val isActive = timeMs in startTime..= startTime) 1.0f else 0.5f + binding.root.alpha = newAlpha + + binding.dualSubProgress.isInvisible = !isActive + if (isActive && endTime > startTime) { + val progressValue = ((timeMs - startTime) * 1000f / (endTime - startTime)).roundToInt() + ObjectAnimator.ofInt( + binding.dualSubProgress, + "progress", + binding.dualSubProgress.progress, + progressValue + ).apply { + duration = 200 + interpolator = DecelerateInterpolator() + }.start() + } else { + binding.dualSubProgress.progress = 0 + } + } + + fun getLatestActiveItem(position: Long): Int { + return immutableCurrentList.withIndex().lastOrNull { + position >= it.value.startTimeMs + }?.index ?: 0 + } + + fun updateTime(timeMs: Long) { + val previousTime = currentTimeMs + currentTimeMs = timeMs + + val earlyTime = minOf(previousTime, timeMs) + val lateTime = maxOf(previousTime, timeMs) + + val affectedItems = immutableCurrentList.withIndex().filter { cue -> + cue.value.startTimeMs in (earlyTime - 5000)..(lateTime + 5000) + } + + affectedItems.forEach { item -> + this.notifyItemChanged(item.index) + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt index d90b6043f28..3d497c94740 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt @@ -13,6 +13,7 @@ import android.graphics.Color import android.os.Build import android.os.Bundle import android.text.Editable +import android.util.Log import android.view.KeyEvent import android.view.LayoutInflater import android.view.MotionEvent @@ -68,6 +69,7 @@ import com.lagradost.cloudstream3.utils.txt import kotlin.math.roundToInt private const val SUBTITLE_DELAY_BUNDLE_KEY = "subtitle_delay" +private const val SECONDARY_SUBTITLE_DELAY_BUNDLE_KEY = "secondary_subtitle_delay" // All the UI Logic for the player @OptIn(UnstableApi::class) @@ -113,6 +115,19 @@ open class FullScreenPlayer : AbstractPlayerFragment( 0L } + protected var secondarySubtitleDelay + set(value) = try { + player.setSecondarySubtitleOffset(-value) + } catch (e: Exception) { + logError(e) + } + get() = try { + -player.getSecondarySubtitleOffset() + } catch (e: Exception) { + logError(e) + 0L + } + private var isShowingEpisodeOverlay: Boolean = false private var previousPlayStatus: Boolean = false @@ -352,8 +367,10 @@ open class FullScreenPlayer : AbstractPlayerFragment( track.sampleMimeType == MimeTypes.APPLICATION_MEDIA3_CUES } // Subtitle offset is not possible on built-in media3 tracks - playerBinding?.playerSubtitleOffsetBtt?.isGone = - isBuiltinSubtitles || tracks.currentTextTracks.isEmpty() + val hasSecondarySub = player.getCurrentSecondarySubtitle() != null + val noSubs = (isBuiltinSubtitles || tracks.currentTextTracks.isEmpty()) && !hasSecondarySub + playerBinding?.playerSubtitleOffsetBtt?.isGone = noSubs + val hasAnySubs = tracks.currentTextTracks.isNotEmpty() || hasSecondarySub } private fun restoreOrientationWithSensor(activity: Activity) { @@ -496,7 +513,7 @@ open class FullScreenPlayer : AbstractPlayerFragment( player.seekTime(85000) // skip 85s } - private fun showSubtitleOffsetDialog() { + private fun showSubtitleOffsetDialog(isSecondary: Boolean = false) { val ctx = context ?: return // Pause player because the subtitles cannot be continuously updated to follow playback. player.handleEvent( @@ -517,8 +534,11 @@ open class FullScreenPlayer : AbstractPlayerFragment( ctx.resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT fixSystemBarsPadding(binding.root, fixIme = isPortrait) - var currentOffset = subtitleDelay + var currentOffset = if (isSecondary) secondarySubtitleDelay else subtitleDelay binding.apply { + if (isSecondary) { + subtitleOffsetTitleText?.setText(R.string.secondary_subtitle_offset_title) + } var subtitleAdapter: SubtitleOffsetItemAdapter? = null subtitleOffsetInput.doOnTextChanged { text, _, _, _ -> @@ -535,6 +555,10 @@ open class FullScreenPlayer : AbstractPlayerFragment( subtitleOffsetRecyclerview.scrollToPosition(subtitlePos) } + if (isSecondary) { + player.setSecondarySubtitleOffset(-currentOffset) + } + val str = when { time > 0L -> { txt(R.string.subtitle_offset_extra_hint_later_format, time) @@ -554,7 +578,7 @@ open class FullScreenPlayer : AbstractPlayerFragment( subtitleOffsetInput.text = Editable.Factory.getInstance()?.newEditable(currentOffset.toString()) - val subtitles = player.getSubtitleCues().toMutableList() + val subtitles = (if (isSecondary) player.getSecondarySubtitleCues() else player.getSubtitleCues()).toMutableList() subtitleOffsetRecyclerview.isVisible = subtitles.isNotEmpty() noSubtitlesLoadedNotice.isVisible = subtitles.isEmpty() @@ -605,13 +629,23 @@ open class FullScreenPlayer : AbstractPlayerFragment( } applyBtt.setOnClickListener { selectSubtitlesDialog = null - subtitleDelay = currentOffset + if (isSecondary) { + secondarySubtitleDelay = currentOffset + player.setSecondarySubtitleOffset(-currentOffset) + } else { + subtitleDelay = currentOffset + } dialog.dismissSafe(activity) player.seekTime(1L) } resetBtt.setOnClickListener { selectSubtitlesDialog = null - subtitleDelay = 0 + if (isSecondary) { + secondarySubtitleDelay = 0 + player.setSecondarySubtitleOffset(0) + } else { + subtitleDelay = 0 + } dialog.dismissSafe(activity) player.seekTime(1L) } @@ -622,6 +656,91 @@ open class FullScreenPlayer : AbstractPlayerFragment( } } + private var dualSubtitlesDialog: Dialog? = null + private var dualSubWasPlaying = false + + private fun showDualSubtitlesDialog() { + val ctx = context ?: return + dualSubWasPlaying = player.getIsPlaying() + player.handleEvent(CSPlayerEvent.Pause, PlayerEventSource.UI) + + val primarySub = player.getCurrentPreferredSubtitle() + val secondarySub = player.getCurrentSecondarySubtitle() + + val pOffset = player.getSubtitleOffset() + val sOffset = player.getSecondarySubtitleOffset() + + val pCues = player.getSubtitleCues() + val sCues = player.getSecondarySubtitleCues() + + val alignedCues = DualSubtitleAligner.align(pCues, pOffset, sCues, sOffset) + + val binding = com.lagradost.cloudstream3.databinding.DialogDualSubtitlesBinding.inflate( + LayoutInflater.from(ctx), null, false + ) + val dialog = Dialog(ctx, R.style.DialogFullscreenPlayer).apply { + setContentView(binding.root) + } + this.dualSubtitlesDialog = dialog + dialog.show() + + val isPortrait = + ctx.resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT + fixSystemBarsPadding(binding.root, fixIme = isPortrait) + + binding.apply { + val pName = primarySub?.name?.ifBlank { primarySub.originalName } + ?: if (pCues.isNotEmpty()) "Active" else "None" + val sName = secondarySub?.name?.ifBlank { secondarySub.originalName } + ?: if (sCues.isNotEmpty()) "Active" else "None" + + primarySubHeader.text = "Primary: $pName" + secondarySubHeader.text = "Secondary: $sName" + + noDualSubtitlesNotice.isVisible = alignedCues.isEmpty() + dualSubtitlesRecyclerview.isVisible = alignedCues.isNotEmpty() + + val currentPosition = player.getPosition() ?: 0L + val adapter = DualSubtitleAdapter(currentPosition) { cue -> + ctx.vibrateDevice(30L) + player.seekTo(cue.startTimeMs, PlayerEventSource.UI) + player.handleEvent(CSPlayerEvent.Play, PlayerEventSource.UI) + dialog.dismissSafe(activity) + } + adapter.submitList(alignedCues) + dualSubtitlesRecyclerview.adapter = adapter + + val activeIndex = adapter.getLatestActiveItem(currentPosition) + if (activeIndex in alignedCues.indices) { + dualSubtitlesRecyclerview.scrollToPosition(activeIndex) + } + + if (pCues.isEmpty() && primarySub != null && primarySub.origin != SubtitleOrigin.EMBEDDED_IN_VIDEO) { + root.postDelayed({ + val delayedPCues = player.getSubtitleCues() + if (delayedPCues.isNotEmpty()) { + val newAligned = DualSubtitleAligner.align(delayedPCues, pOffset, player.getSecondarySubtitleCues(), sOffset) + adapter.submitList(newAligned) + noDualSubtitlesNotice.isVisible = newAligned.isEmpty() + dualSubtitlesRecyclerview.isVisible = newAligned.isNotEmpty() + } + }, 500) + } + + dualSubCloseBtt.setOnClickListener { + dialog.dismissSafe(activity) + } + + dialog.setOnDismissListener { + dualSubtitlesDialog = null + if (dualSubWasPlaying) { + player.handleEvent(CSPlayerEvent.Play, PlayerEventSource.UI) + } + activity?.hideSystemUI() + } + } + } + @SuppressLint("SetTextI18n") fun updateSpeedDialogBinding(binding: SpeedDialogBinding) { val speed = player.getPlaybackSpeed() @@ -851,6 +970,13 @@ open class FullScreenPlayer : AbstractPlayerFragment( override fun playerStatusChanged() { super.playerStatusChanged() scheduleMetadataVisibility() + val secView = subtitleHolder?.findViewById(R.id.secondary_subtitle_view) + ?: playerBinding?.root?.findViewById(R.id.secondary_subtitle_view) + ?: binding?.root?.findViewById(R.id.secondary_subtitle_view) + val isPaused = currentPlayerStatus == CSPlayerLoading.IsPaused + if (player.getCurrentSecondarySubtitle() != null) { + secView?.visibility = if (isPaused || DataStoreHelper.alwaysShowSecondarySubtitles) View.VISIBLE else View.GONE + } } // When the hold-speedup gesture fires, hide controls so the video is unobstructed. @@ -859,6 +985,65 @@ open class FullScreenPlayer : AbstractPlayerFragment( if (show && isShowing) onClickChange() } + override fun onHoldSecondarySubtitle(show: Boolean) { + val secView = subtitleHolder?.findViewById(R.id.secondary_subtitle_view) + ?: playerBinding?.root?.findViewById(R.id.secondary_subtitle_view) + ?: binding?.root?.findViewById(R.id.secondary_subtitle_view) + Log.i("FullScreenPlayer", "onHoldSecondarySubtitle: show=$show, secView=$secView") + secView?.visibility = if (show || DataStoreHelper.alwaysShowSecondarySubtitles) View.VISIBLE else View.GONE + } + + override fun onOpenDualSubtitleDialog() { + showDualSubtitlesDialog() + } + + private val subJumpHideRunnable = Runnable { + playerBinding?.playerTimeText?.isVisible = false + } + + private fun jumpToSubtitle(next: Boolean) { + val pos = player.getPosition() ?: return + val primary = player.getSubtitleCues() + val secondary = player.getSecondarySubtitleCues() + val cues = (if (primary.size >= secondary.size && primary.isNotEmpty()) primary else secondary) + .sortedBy { it.startTimeMs } + + if (cues.isEmpty()) { + context?.let { ctx -> + com.lagradost.cloudstream3.CommonActivity.showToast(activity, "No subtitles loaded", android.widget.Toast.LENGTH_SHORT) + } + return + } + + val target = if (next) { + cues.firstOrNull { it.startTimeMs > pos + 400L } + } else { + val currentCue = cues.lastOrNull { it.startTimeMs <= pos && pos <= it.endTimeMs + 500L } + if (currentCue != null && pos - currentCue.startTimeMs > 1000L) { + currentCue + } else { + cues.lastOrNull { it.startTimeMs < pos - 1000L } ?: cues.firstOrNull() + } + } + + if (target != null) { + context?.vibrateDevice(35L) + player.seekTo(target.startTimeMs, PlayerEventSource.UI) + val snippet = target.text.firstOrNull()?.replace("\n", " ")?.trim()?.take(45) ?: "" + val hudText = "${if (next) "⏭" else "⏮"} $snippet" + playerBinding?.playerTimeText?.apply { + isVisible = true + text = hudText + removeCallbacks(subJumpHideRunnable) + postDelayed(subJumpHideRunnable, 1200L) + } + } + } + + override fun onJumpSubtitle(next: Boolean) { + jumpToSubtitle(next) + } + override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) @@ -1090,6 +1275,7 @@ open class FullScreenPlayer : AbstractPlayerFragment( override fun onSaveInstanceState(outState: Bundle) { // As this is video specific it is better to not do any setKey/getKey outState.putLong(SUBTITLE_DELAY_BUNDLE_KEY, subtitleDelay) + outState.putLong(SECONDARY_SUBTITLE_DELAY_BUNDLE_KEY, secondarySubtitleDelay) super.onSaveInstanceState(outState) } @@ -1114,6 +1300,9 @@ open class FullScreenPlayer : AbstractPlayerFragment( savedInstanceState?.getLong(SUBTITLE_DELAY_BUNDLE_KEY)?.let { subtitleDelay = it } + savedInstanceState?.getLong(SECONDARY_SUBTITLE_DELAY_BUNDLE_KEY)?.let { + secondarySubtitleDelay = it + } // handle tv controls directly based on player state setupKeyEventListener() @@ -1252,7 +1441,35 @@ open class FullScreenPlayer : AbstractPlayerFragment( } playerSubtitleOffsetBtt.setOnClickListener { - showSubtitleOffsetDialog() + if (player.getCurrentSecondarySubtitle() != null) { + val activity = activity ?: return@setOnClickListener + val options = listOf( + getString(R.string.subtitle_offset_title), + getString(R.string.secondary_subtitle_offset_title) + ) + com.lagradost.cloudstream3.utils.SingleSelectionHelper.run { + activity.showDialog( + items = options, + selectedIndex = 0, + name = getString(R.string.subtitle_offset), + showApply = false, + dismissCallback = {}, + callback = { index: Int -> + showSubtitleOffsetDialog(isSecondary = index == 1) + } + ) + } + } else { + showSubtitleOffsetDialog(isSecondary = false) + } + } + playerSubtitleOffsetBtt.setOnLongClickListener { + if (player.getCurrentSecondarySubtitle() != null) { + showSubtitleOffsetDialog(isSecondary = true) + true + } else { + false + } } playerGoBack.setOnClickListener { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeminiSubtitleTranslator.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeminiSubtitleTranslator.kt new file mode 100644 index 00000000000..25d9a19baa9 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeminiSubtitleTranslator.kt @@ -0,0 +1,204 @@ +package com.lagradost.cloudstream3.ui.player + +import android.content.Context +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.mvvm.logError +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.util.concurrent.TimeUnit + +object GeminiSubtitleTranslator { + private const val BATCH_SIZE = 50 + private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaType() + + private val httpClient by lazy { + app.baseClient.newBuilder() + .callTimeout(60, TimeUnit.SECONDS) + .readTimeout(45, TimeUnit.SECONDS) + .connectTimeout(15, TimeUnit.SECONDS) + .build() + } + + suspend fun translateCues( + cues: List, + targetLanguage: String, + key: String + ): Result> = withContext(Dispatchers.IO) { + runCatching { + if (cues.isEmpty()) return@runCatching emptyList() + + val translatedCues = mutableListOf() + val chunks = cues.chunked(BATCH_SIZE) + var successfulChunks = 0 + + for ((chunkIdx, chunk) in chunks.withIndex()) { + val promptBuilder = StringBuilder() + promptBuilder.append("Translate each line into $targetLanguage. Output ONLY the translated lines with their exact line numbers [N] so lines match up, with no introductory or concluding text:\n") + chunk.forEachIndexed { index, cue -> + val text = cue.text.joinToString(" ").replace("\n", " ").trim() + promptBuilder.append("[${index + 1}] $text\n") + } + + val lineMap = try { + val responseText = callGeminiWithFallback(promptBuilder.toString(), key) + val parsed = parseNumberedLines(responseText) + if (parsed.isNotEmpty()) successfulChunks++ + parsed + } catch (e: Exception) { + logError(e) + emptyMap() + } + + chunk.forEachIndexed { index, cue -> + val translatedText = lineMap[index + 1] ?: cue.text.joinToString(" ") + translatedCues.add( + SubtitleCue( + startTimeMs = cue.startTimeMs, + durationMs = if (cue.durationMs > 0) cue.durationMs else 2500L, + text = listOf(translatedText) + ) + ) + } + } + + if (successfulChunks == 0 && cues.isNotEmpty()) { + throw Exception("AI translation failed: could not connect to Gemini or invalid API key") + } + + translatedCues + } + } + + private fun callGeminiWithFallback(prompt: String, key: String): String { + val models = listOf("gemini-2.5-flash", "gemini-3.5-flash", "gemini-flash-latest") + var lastException: Exception? = null + + for (model in models) { + try { + return executeGeminiRequest(model, prompt, key) + } catch (e: Exception) { + logError(e) + lastException = e + try { Thread.sleep(500) } catch (_: Throwable) {} + } + } + throw lastException ?: Exception("Gemini request failed: all models returned error") + } + + private fun executeGeminiRequest(model: String, prompt: String, key: String): String { + val url = "https://generativelanguage.googleapis.com/v1beta/models/$model:generateContent?key=$key" + + val jsonPayload = JSONObject().apply { + val contents = JSONArray().apply { + val contentObj = JSONObject().apply { + val parts = JSONArray().apply { + put(JSONObject().apply { + put("text", prompt) + }) + } + put("parts", parts) + } + put(contentObj) + } + put("contents", contents) + + val genConfig = JSONObject().apply { + val thinkingConfig = JSONObject().apply { + put("thinkingBudget", 0) + } + put("thinkingConfig", thinkingConfig) + put("temperature", 0.2) + } + put("generationConfig", genConfig) + } + + val request = Request.Builder() + .url(url) + .post(jsonPayload.toString().toRequestBody(JSON_MEDIA_TYPE)) + .build() + + val response = httpClient.newCall(request).execute() + val responseBody = response.body.string() + + if (!response.isSuccessful) { + throw Exception("API call to $model failed with code ${response.code}: $responseBody") + } + + val root = JSONObject(responseBody) + val candidates = root.optJSONArray("candidates") + ?: throw Exception("No candidates in response: $responseBody") + if (candidates.length() == 0) throw Exception("Empty candidates list") + + val firstCandidate = candidates.getJSONObject(0) + val content = firstCandidate.optJSONObject("content") + ?: throw Exception("No content in candidate: $responseBody") + val parts = content.optJSONArray("parts") + ?: throw Exception("No parts in candidate content: $responseBody") + if (parts.length() == 0) throw Exception("Empty parts list") + + val sb = StringBuilder() + for (i in 0 until parts.length()) { + val part = parts.optJSONObject(i) ?: continue + val text = part.optString("text", "") + if (text.isNotBlank()) { + sb.append(text).append("\n") + } + } + val resultText = sb.toString().trim() + if (resultText.isEmpty()) throw Exception("Empty text returned from $model") + return resultText + } + + private fun parseNumberedLines(response: String): Map { + val result = mutableMapOf() + val lineRegex = Regex("""^\[?(\d+)\]?[\s.:\)-]*(.*)$""") + + response.lines().forEach { rawLine -> + val line = rawLine.replace("*", "").trim() + val match = lineRegex.find(line) + if (match != null) { + val num = match.groupValues[1].toIntOrNull() + val text = match.groupValues[2].trim() + if (num != null && text.isNotEmpty()) { + result[num] = text + } + } + } + return result + } + + fun cuesToVttFile(cues: List, targetFile: File): File { + targetFile.parentFile?.let { if (!it.exists()) it.mkdirs() } + targetFile.bufferedWriter().use { writer -> + writer.write("WEBVTT\n\n") + cues.forEach { cue -> + val start = formatVttTime(cue.startTimeMs) + val duration = if (cue.durationMs > 0) cue.durationMs else 2500L + val end = formatVttTime(cue.startTimeMs + duration) + val text = cue.text.joinToString("\n").ifBlank { "..." } + writer.write("$start --> $end\n$text\n\n") + } + } + return targetFile + } + + fun cuesToVttFile(context: Context, cues: List, fileName: String): File { + val file = File(context.cacheDir, fileName) + return cuesToVttFile(cues, file) + } + + private fun formatVttTime(ms: Long): String { + val totalSeconds = ms.coerceAtLeast(0) / 1000 + val millis = ms.coerceAtLeast(0) % 1000 + val seconds = totalSeconds % 60 + val minutes = totalSeconds / 60 % 60 + val hours = totalSeconds / 3600 + return "%02d:%02d:%02d.%03d".format(hours, minutes, seconds, millis) + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt index 4495a560262..bb4da0874b5 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt @@ -2,6 +2,7 @@ package com.lagradost.cloudstream3.ui.player import android.animation.ValueAnimator import android.annotation.SuppressLint +import android.app.Activity import android.app.Dialog import android.app.PendingIntent import android.content.Context @@ -11,8 +12,11 @@ import android.graphics.Bitmap import android.graphics.Typeface import android.os.Build import android.os.Bundle +import android.os.Handler +import android.os.Looper import android.text.Spanned import android.util.Log +import java.io.File import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -907,6 +911,284 @@ class GeneratorPlayer : FullScreenPlayer() { ) } + private var isAiTranslating: Boolean = false + private var currentAiTranslatingLang: String? = null + private var activeAiFooter: TextView? = null + private val aiHandler = Handler(Looper.getMainLooper()) + private var aiAnimationRunnable: Runnable? = null + private val aiDotsFrames = listOf(". ", ".. ", "...", " ..", " .", " ") + private var aiDotsIndex = 0 + + private fun startAiFooterAnimation(footer: TextView, lang: String) { + activeAiFooter = footer + footer.isEnabled = false + if (aiAnimationRunnable == null) { + aiDotsIndex = 0 + val r = object : Runnable { + override fun run() { + if (!isAiTranslating) return + val frame = aiDotsFrames[aiDotsIndex % aiDotsFrames.size] + aiDotsIndex++ + activeAiFooter?.text = "⏳ Translating to $lang $frame" + aiHandler.postDelayed(this, 300L) + } + } + aiAnimationRunnable = r + aiHandler.post(r) + } else { + val frame = aiDotsFrames[aiDotsIndex % aiDotsFrames.size] + footer.text = "⏳ Translating to $lang $frame" + } + } + + private fun stopAiFooterAnimation(ctx: Context?) { + aiAnimationRunnable?.let { aiHandler.removeCallbacks(it) } + aiAnimationRunnable = null + activeAiFooter?.apply { + text = ctx?.getString(R.string.translate_with_ai) ?: "Translate with AI" + isEnabled = true + } + activeAiFooter = null + } + + private fun getAiSubDir(ctx: Context): File { + val dir = File(ctx.filesDir, "ai_subtitles") + if (!dir.exists()) dir.mkdirs() + return dir + } + + private fun getMediaKey(): String { + val meta = getMetaData() + val raw = meta.name ?: currentSelectedLink?.first?.name ?: currentSelectedLink?.first?.url ?: "media" + val safe = raw.replace(Regex("[^a-zA-Z0-9_]"), "_").trim('_') + val season = meta.season?.let { "_S$it" } ?: "" + val ep = meta.episode?.let { "_E$it" } ?: "" + return "${safe}${season}${ep}".ifBlank { "media_default" } + } + + private fun loadCachedAiSubtitles(ctx: Context) { + try { + val dir = getAiSubDir(ctx) + val mediaKey = getMediaKey() + val files = dir.listFiles { f: File -> + f.isFile && f.name.startsWith("ai_${mediaKey}_") && f.name.endsWith(".vtt") + } ?: return + + val loaded = files.mapNotNull { f: File -> + val lang = f.name.removePrefix("ai_${mediaKey}_").removeSuffix(".vtt") + SubtitleData( + originalName = "AI: $lang", + nameSuffix = "", + url = f.absolutePath, + origin = SubtitleOrigin.DOWNLOADED_FILE, + mimeType = "text/vtt", + headers = emptyMap(), + languageCode = lang + ) + } + if (loaded.isNotEmpty()) { + val existing = viewModel.state.subtitles.map { sub: SubtitleData -> sub.url }.toSet() + val newOnes = loaded.filter { sub: SubtitleData -> sub.url !in existing } + if (newOnes.isNotEmpty()) { + viewModel.addSubtitles(newOnes.toSet()) + } + } + } catch (e: Exception) { + logError(e) + } + } + + private fun handleAiSubtitleTranslationClick( + ctx: Context, + sourceDialog: Dialog, + footer: TextView, + onUpdateList: () -> Unit + ) { + if (isAiTranslating) { + showToast("AI is already translating subtitles (${currentAiTranslatingLang ?: "..."}). Please wait.") + return + } + val act = activity ?: return + + val key = DataStoreHelper.geminiApiKey + if (key.isNullOrBlank()) { + promptGeminiApiKey(act) { enteredKey -> + pickLanguageAndTranslate(ctx, act, sourceDialog, footer, enteredKey, onUpdateList) + } + } else { + pickLanguageAndTranslate(ctx, act, sourceDialog, footer, key, onUpdateList) + } + } + + private fun promptGeminiApiKey(act: Activity, onKeyEntered: (String) -> Unit) { + val input = android.widget.EditText(act).apply { + setSingleLine() + inputType = android.text.InputType.TYPE_CLASS_TEXT or android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD + } + androidx.appcompat.app.AlertDialog.Builder(act) + .setTitle(R.string.gemini_api_key_prompt) + .setView(input) + .setPositiveButton(android.R.string.ok) { _, _ -> + val entered = input.text.toString().trim() + if (entered.isNotBlank()) { + DataStoreHelper.geminiApiKey = entered + onKeyEntered(entered) + } else { + showToast(R.string.gemini_api_key_empty) + } + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + private fun pickLanguageAndTranslate( + ctx: Context, + act: Activity, + sourceDialog: Dialog, + footer: TextView, + authKey: String, + onUpdateList: () -> Unit + ) { + val languages = listOf( + "🇦🇿 Azerbaijani" to "Azerbaijani", + "🇹🇷 Turkish" to "Turkish", + "🇬🇧 English" to "English", + "🇷🇺 Russian" to "Russian", + "🇩🇪 German" to "German", + "🇪🇸 Spanish" to "Spanish", + "🇫🇷 French" to "French", + "🇮🇹 Italian" to "Italian", + "🇸🇦 Arabic" to "Arabic", + "🇵🇹 Portuguese" to "Portuguese" + ) + val displayItems = languages.map { it.first } + + com.lagradost.cloudstream3.utils.SingleSelectionHelper.run { + act.showDialogNoCheckmark( + items = displayItems, + name = ctx.getString(R.string.gemini_target_language), + dismissCallback = {}, + callback = { index: Int -> + val chosen = languages.getOrNull(index)?.second ?: "Azerbaijani" + DataStoreHelper.geminiTargetLanguage = chosen + + val dir = getAiSubDir(ctx) + val mediaKey = getMediaKey() + val cachedFile = File(dir, "ai_${mediaKey}_${chosen}.vtt") + if (cachedFile.exists() && cachedFile.length() > 50) { + showToast("Loading cached translation for $chosen…") + val cachedSub = SubtitleData( + originalName = "AI: $chosen", + nameSuffix = "", + url = cachedFile.absolutePath, + origin = SubtitleOrigin.DOWNLOADED_FILE, + mimeType = "text/vtt", + headers = emptyMap(), + languageCode = chosen + ) + addAndSelectSubtitles(cachedSub) + sourceDialog.dismissSafe(activity) + return@showDialogNoCheckmark + } + + val pendingSub = SubtitleData( + originalName = "AI: $chosen (⏳ Translating...)", + nameSuffix = "", + url = "pending_ai_translation", + origin = SubtitleOrigin.DOWNLOADED_FILE, + mimeType = "text/vtt", + headers = emptyMap(), + languageCode = chosen + ) + viewModel.addSubtitles(setOf(pendingSub)) + onUpdateList() + + executeAiTranslation(ctx, sourceDialog, footer, authKey, chosen, pendingSub, onUpdateList) + } + ) + } + } + + private fun executeAiTranslation( + ctx: Context, + sourceDialog: Dialog, + footer: TextView, + authKey: String, + targetLang: String, + pendingSub: SubtitleData, + onUpdateList: () -> Unit + ) { + showToast("${ctx.getString(R.string.translating_subtitles)} ($targetLang)") + isAiTranslating = true + currentAiTranslatingLang = targetLang + startAiFooterAnimation(footer, targetLang) + + ioSafe { + var cues = player.getSubtitleCues().ifEmpty { player.getSecondarySubtitleCues() } + if (cues.isEmpty()) { + val activeSub = player.getCurrentPreferredSubtitle() + ?: player.getCurrentSecondarySubtitle() + ?: viewModel.state.subtitles.firstOrNull { it.url != "pending_ai_translation" } + if (activeSub != null) { + cues = player.loadSubtitleCues(activeSub) + } + } + + if (cues.isEmpty()) { + activity?.runOnUiThread { + isAiTranslating = false + currentAiTranslatingLang = null + stopAiFooterAnimation(ctx) + showToast("No subtitles found. Please select a subtitle first.") + viewModel.removeSubtitles(setOf(pendingSub)) + onUpdateList() + } + return@ioSafe + } + + val result = GeminiSubtitleTranslator.translateCues(cues, targetLang, authKey) + val translated = result.getOrNull() + if (result.isSuccess && translated != null) { + val dir = getAiSubDir(ctx) + val mediaKey = getMediaKey() + val targetFile = File(dir, "ai_${mediaKey}_${targetLang}.vtt") + GeminiSubtitleTranslator.cuesToVttFile(translated, targetFile) + + val aiSubData = SubtitleData( + originalName = "AI: $targetLang", + nameSuffix = "", + url = targetFile.absolutePath, + origin = SubtitleOrigin.DOWNLOADED_FILE, + mimeType = "text/vtt", + headers = emptyMap(), + languageCode = targetLang + ) + + activity?.runOnUiThread { + isAiTranslating = false + currentAiTranslatingLang = null + stopAiFooterAnimation(ctx) + + viewModel.removeSubtitles(setOf(pendingSub)) + addAndSelectSubtitles(aiSubData) + showToast(R.string.translation_completed) + sourceDialog.dismissSafe(activity) + } + } else { + val errorMsg = result.exceptionOrNull()?.message ?: "Unknown error" + activity?.runOnUiThread { + isAiTranslating = false + currentAiTranslatingLang = null + stopAiFooterAnimation(ctx) + + showToast(ctx.getString(R.string.translation_failed, errorMsg)) + viewModel.removeSubtitles(setOf(pendingSub)) + onUpdateList() + } + } + } + } + // Open file picker private val subsPathPicker = registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> @@ -1016,6 +1298,7 @@ class GeneratorPlayer : FullScreenPlayer() { currentSelectedSubtitles = player.getCurrentPreferredSubtitle() //println("CURRENT SELECTED :$currentSelectedSubtitles of $currentSubs") context?.let { ctx -> + loadCachedAiSubtitles(ctx) val isPlaying = player.getIsPlaying() player.handleEvent(CSPlayerEvent.Pause, PlayerEventSource.UI) val currentSubtitles = sortSubs(viewModel.state.subtitles) @@ -1042,6 +1325,17 @@ class GeneratorPlayer : FullScreenPlayer() { } subtitleList.addFooterView(loadFromFileFooter) + val translateWithAiFooter: TextView = + layoutInflater.inflate(R.layout.sort_bottom_footer_add_choice, null) as TextView + if (isAiTranslating) { + val lang = currentAiTranslatingLang ?: "..." + startAiFooterAnimation(translateWithAiFooter, lang) + } else { + translateWithAiFooter.text = ctx.getString(R.string.translate_with_ai) + translateWithAiFooter.isEnabled = true + } + subtitleList.addFooterView(translateWithAiFooter) + var shouldDismiss = true binding.subtitleSettingsBtt.setOnClickListener { @@ -1056,6 +1350,9 @@ class GeneratorPlayer : FullScreenPlayer() { if (isPlaying) { player.handleEvent(CSPlayerEvent.Play) } + if (activeAiFooter === translateWithAiFooter) { + activeAiFooter = null + } activity?.hideSystemUI() } @@ -1201,7 +1498,20 @@ class GeneratorPlayer : FullScreenPlayer() { }.toMap() val subtitlesGroupedList = subtitlesGrouped.entries.toList() - val subtitles = subtitlesGrouped.map { it.key.html() } + fun getSubGroupLabel(key: String, list: List): Spanned { + val currentSec = viewModel.state.secondarySubtitle + val isSecondary = currentSec != null && list.any { it.getId() == currentSec.getId() } + return if (isSecondary) { + "✓ [2nd] ${key}".html() + } else { + key.html() + } + } + + fun getSubtitleLabels(): List = + subtitlesGrouped.map { getSubGroupLabel(it.key, it.value) } + + val subtitles = getSubtitleLabels() val subtitleGroupIndexStart = subtitlesGrouped.keys.indexOf(currentSelectedSubtitles?.originalName) + 1 @@ -1227,19 +1537,32 @@ class GeneratorPlayer : FullScreenPlayer() { subtitleOptionList.choiceMode = AbsListView.CHOICE_MODE_SINGLE fun updateSubtitleOptionList() { + subsArrayAdapter.clear() + subsArrayAdapter.add(ctx.getString(R.string.no_subtitles).html()) + subsArrayAdapter.addAll(getSubtitleLabels()) + subtitleList.setItemChecked(subtitleGroupIndex, true) + subsOptionsArrayAdapter.clear() + val currentSec = viewModel.state.secondarySubtitle val subtitleOptions = subtitlesGroupedList .getOrNull(subtitleGroupIndex - 1)?.value?.map { subtitle -> val nameSuffix = subtitle.nameSuffix.html() - nameSuffix.ifBlank { + val baseLabel = nameSuffix.ifBlank { when (subtitle.origin) { SubtitleOrigin.URL -> txt(R.string.subtitles_from_online) SubtitleOrigin.DOWNLOADED_FILE -> txt(R.string.downloaded) SubtitleOrigin.EMBEDDED_IN_VIDEO -> txt(R.string.subtitles_from_embedded) }.asString(ctx).toSpanned() } + if (currentSec?.getId() == subtitle.getId()) { + "✓ [2nd] ".html().let { prefix -> + android.text.TextUtils.concat(prefix, baseLabel) as Spanned + } + } else { + baseLabel + } } ?: emptyList() @@ -1254,20 +1577,66 @@ class GeneratorPlayer : FullScreenPlayer() { subtitleOptionList.setItemChecked(subtitleOptionIndex, true) } + fun toggleSecondarySubtitle(subtitle: SubtitleData?) { + ctx.vibrateDevice(55L) + val current = viewModel.state.secondarySubtitle + val next = if (subtitle != null && current?.getId() == subtitle.getId()) null else subtitle + viewModel.setSecondarySubtitle(next) + subtitleList.post { updateSubtitleOptionList() } + } + + subtitleList.setOnItemLongClickListener { _, _, which, _ -> + if (which == 0) { + toggleSecondarySubtitle(null) + true + } else { + val sub = subtitlesGroupedList.getOrNull(which - 1)?.value?.firstOrNull() + if (sub?.url == "pending_ai_translation") { + showToast("AI is translating subtitles... Please wait a moment.") + true + } else { + sub?.let { toggleSecondarySubtitle(it) } + true + } + } + } + + subtitleOptionList.setOnItemLongClickListener { _, _, which, _ -> + val sub = subtitlesGroupedList.getOrNull(subtitleGroupIndex - 1)?.value?.getOrNull(which) + if (sub?.url == "pending_ai_translation") { + showToast("AI is translating subtitles... Please wait a moment.") + true + } else { + sub?.let { toggleSecondarySubtitle(it) } + true + } + } + updateSubtitleOptionList() + translateWithAiFooter.setOnClickListener { + handleAiSubtitleTranslationClick( + ctx, + sourceDialog, + translateWithAiFooter, + ::updateSubtitleOptionList + ) + } + subtitleList.setOnItemClickListener { _, _, which, _ -> - if (which > subtitlesGrouped.size) { - // Since android TV is funky the setOnItemClickListener will be triggered - // instead of setOnClickListener when selecting. To override this we programmatically - // click the view when selecting an item outside the list. - - // Cheeky way of getting the view at that position to click it - // to avoid keeping track of the various footers. - // getChildAt() gives null :( + if (which == subtitlesGrouped.size + 1) { + loadFromFileFooter.performClick() + } else if (which == subtitlesGrouped.size + 2) { + translateWithAiFooter.performClick() + } else if (which > subtitlesGrouped.size) { val child = subtitleList.adapter.getView(which, null, subtitleList) child?.performClick() } else { + val sub = subtitlesGroupedList.getOrNull(which - 1)?.value?.firstOrNull() + if (sub?.url == "pending_ai_translation") { + showToast("AI is translating subtitles... Please wait a moment.") + return@setOnItemClickListener + } if (subtitleGroupIndex != which) { subtitleGroupIndex = which subtitleOptionIndex = @@ -1289,6 +1658,11 @@ class GeneratorPlayer : FullScreenPlayer() { val child = subtitleOptionList.adapter.getView(which, null, subtitleList) child?.performClick() } else { + val sub = subtitlesGroupedList.getOrNull(subtitleGroupIndex - 1)?.value?.getOrNull(which) + if (sub?.url == "pending_ai_translation") { + showToast("AI is translating subtitles... Please wait a moment.") + return@setOnItemClickListener + } subtitleOptionIndex = which subtitleOptionList.setItemChecked(which, true) } @@ -1385,10 +1759,20 @@ class GeneratorPlayer : FullScreenPlayer() { init = init or if (subtitleGroupIndex <= 0) { noSubtitles() } else { - subtitlesGroupedList.getOrNull(subtitleGroupIndex - 1)?.value?.getOrNull( + val selected = subtitlesGroupedList.getOrNull(subtitleGroupIndex - 1)?.value?.getOrNull( subtitleOptionIndex - )?.let { - setSubtitles(it, true) + ) + if (selected?.url == "pending_ai_translation") { + showToast("AI is translating subtitles... Please wait a moment.") + return@setOnClickListener + } + selected?.let { + val needsReload = setSubtitles(it, true) + if (needsReload) { + player.saveData() + context?.let { ctx -> player.reloadPlayer(ctx) } + } + true } ?: false } } @@ -1714,6 +2098,7 @@ class GeneratorPlayer : FullScreenPlayer() { } override fun onDestroy() { + stopAiFooterAnimation(null) ResultFragment.updateUI() currentVerifyLink?.cancel() super.onDestroy() @@ -2324,6 +2709,7 @@ class GeneratorPlayer : FullScreenPlayer() { observe(viewModel.currentSubtitles) { (subtitles, instance) -> if (instance != viewModel.state.instance) return@observe // Outdated observe + context?.let { loadCachedAiSubtitles(it) } player.setActiveSubtitles(subtitles) // If the file is downloaded then do not select auto select the subtitles @@ -2334,6 +2720,10 @@ class GeneratorPlayer : FullScreenPlayer() { autoSelectSubtitles() } } + observe(viewModel.currentSecondarySubtitle) { (subtitle, instance) -> + if (instance != viewModel.state.instance) return@observe + player.setSecondarySubtitles(subtitle) + } observe(viewModel.loadingLinks) { (loading, instance) -> if (instance != viewModel.state.instance) return@observe // Outdated observe diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt index 0342372667f..d2433bf6a42 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt @@ -222,6 +222,9 @@ interface IPlayer { fun getSubtitleOffset(): Long // in ms fun setSubtitleOffset(offset: Long) // in ms + fun getSecondarySubtitleOffset(): Long = 0L + fun setSecondarySubtitleOffset(offset: Long) {} + @AnyThread fun initCallbacks( @MainThread eventHandler: ((PlayerEvent) -> Unit), @@ -257,6 +260,11 @@ interface IPlayer { fun setPreferredSubtitles(subtitle: SubtitleData?): Boolean // returns true if the player requires a reload, null for nothing fun getCurrentPreferredSubtitle(): SubtitleData? + fun setSecondarySubtitles(subtitle: SubtitleData?) {} + fun getCurrentSecondarySubtitle(): SubtitleData? = null + fun setDirectSecondaryCues(cues: List, subtitle: SubtitleData?) {} + fun loadSubtitleCues(subtitle: SubtitleData): List = emptyList() + fun handleEvent(event: CSPlayerEvent, source: PlayerEventSource = PlayerEventSource.UI) fun onStop() @@ -291,4 +299,5 @@ interface IPlayer { /** Get the current subtitle cues, for use with syncing */ fun getSubtitleCues(): List + fun getSecondarySubtitleCues(): List = emptyList() } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGeneratorViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGeneratorViewModel.kt index cb8cf8bfff5..6b5e7481180 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGeneratorViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGeneratorViewModel.kt @@ -54,6 +54,7 @@ data class DisplayLink( // @Immutable data class VideoState( val subtitles: PersistentSet = persistentSetOf(), + val secondarySubtitle: SubtitleData? = null, val links: PersistentSet = persistentSetOf(), val erroredLinks: PersistentSet = persistentSetOf(), val stamps: PersistentList = persistentListOf(), @@ -196,6 +197,9 @@ class PlayerGeneratorViewModel : ViewModel() { private val _currentSubtitles = MutableLiveData>>(null) val currentSubtitles: LiveData>> = _currentSubtitles + private val _currentSecondarySubtitle = MutableLiveData>(null) + val currentSecondarySubtitle: LiveData> = _currentSecondarySubtitle + private val _loadingLinks = MutableLiveData>>() val loadingLinks: LiveData>> = _loadingLinks @@ -216,6 +220,7 @@ class PlayerGeneratorViewModel : ViewModel() { /** New instance, always push state */ if (state.instance != oldState.instance) { _currentSubtitles.postValue(VideoLive(state.subtitles, state.instance)) + _currentSecondarySubtitle.postValue(VideoLive(state.secondarySubtitle, state.instance)) _currentStamps.postValue(VideoLive(state.stamps, state.instance)) _currentLinks.postValue(VideoLive(state.links, state.instance)) _loadingLinks.postValue(VideoLive(state.loading, state.instance)) @@ -234,6 +239,8 @@ class PlayerGeneratorViewModel : ViewModel() { _currentStamps.postValue(VideoLive(state.stamps, state.instance)) if (state.subtitles !== oldState.subtitles) _currentSubtitles.postValue(VideoLive(state.subtitles, state.instance)) + if (state.secondarySubtitle !== oldState.secondarySubtitle) + _currentSecondarySubtitle.postValue(VideoLive(state.secondarySubtitle, state.instance)) /** Normal equality here as it is not a collection */ if (state.loading != oldState.loading) @@ -254,6 +261,10 @@ class PlayerGeneratorViewModel : ViewModel() { _currentSubtitleYear.postValue(year) } + fun setSecondarySubtitle(subtitle: SubtitleData?) { + modifyState { copy(secondarySubtitle = subtitle) } + } + fun loadLinksPrev() { Log.i(TAG, "loadLinksPrev") if (generator?.hasPrev(episodeIndex) == true) { @@ -333,6 +344,12 @@ class PlayerGeneratorViewModel : ViewModel() { } } + fun removeSubtitles(file: Set) { + modifyState { + copy(subtitles = (subtitles - file).toPersistentSet()) + } + } + private var currentJob: Job? = null private var currentStampJob: Job? = null diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGestureHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGestureHelper.kt index 1c7086d1238..976f5d8d208 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGestureHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGestureHelper.kt @@ -11,6 +11,7 @@ import android.media.audiofx.LoudnessEnhancer import android.os.Build import android.os.Handler import android.os.Looper +import android.util.Log import android.provider.Settings import android.view.KeyEvent import android.view.LayoutInflater @@ -132,13 +133,37 @@ class PlayerGestureHelper(private val playerView: PlayerView) { /** Hold / speed-up */ val holdHandler = Handler(Looper.getMainLooper()) var hasTriggeredSpeedUp = false - val holdRunnable = Runnable { + val holdRunnable: Runnable = Runnable { + holdHandler.removeCallbacks(subRevealRunnable) playerView.player.setPlaybackSpeed(2.0f) showOrHideSpeedUp(true) playerView.callbacks?.onHoldSpeedUp(true) hasTriggeredSpeedUp = true } + var hasTriggeredSubReveal = false + private var subRevealWasPlaying = false + val subRevealRunnable: Runnable = Runnable { + holdHandler.removeCallbacks(holdRunnable) + hasTriggeredSubReveal = true + subRevealWasPlaying = playerView.player.getIsPlaying() + if (subRevealWasPlaying) { + playerView.player.handleEvent(CSPlayerEvent.Pause, PlayerEventSource.UI) + } + Log.i(TAG, "subRevealRunnable triggered -> pausing and showing secondary subtitle") + playerView.callbacks?.onHoldSecondarySubtitle(true) + } + + var hasTriggeredDualSub = false + val dualSubRunnable: Runnable = Runnable { + holdHandler.removeCallbacks(holdRunnable) + holdHandler.removeCallbacks(subRevealRunnable) + hasTriggeredDualSub = true + context.vibrateDevice(50L) + Log.i(TAG, "dualSubRunnable triggered -> opening dual subtitle comparison dialog") + playerView.callbacks?.onOpenDualSubtitleDialog() + } + enum class TouchAction { Brightness, Volume, Time } /** Mirrors the host's lock state; suppresses gesture interactions when true. */ @@ -207,6 +232,13 @@ class PlayerGestureHelper(private val playerView: PlayerView) { private var scaleGestureDetector: ScaleGestureDetector? = null + private var twoPointerStartX: Float? = null + private var twoPointerStartY: Float? = null + private var twoPointerDownTime: Long = 0L + private var twoPointerMaxDisplacement: Float = 0f + private var twoPointerFired: Boolean = false + private var twoPointerIsScaling: Boolean = false + /** Midpoint of the two-finger pan, null when no pan is active. */ var lastPan: Vector2? = null @@ -722,50 +754,96 @@ class PlayerGestureHelper(private val playerView: PlayerView) { when (event.actionMasked) { MotionEvent.ACTION_POINTER_DOWN -> { + if (event.pointerCount >= 2) { + twoPointerStartX = (event.getX(0) + event.getX(1)) / 2f + twoPointerStartY = (event.getY(0) + event.getY(1)) / 2f + twoPointerDownTime = System.currentTimeMillis() + twoPointerMaxDisplacement = 0f + twoPointerFired = false + twoPointerIsScaling = false + } onFirstPointerDown() } MotionEvent.ACTION_MOVE -> { if (event.pointerCount >= 2) { - val newPan = Vector2( - (event.getX(0) + event.getX(1)) / 2f, - (event.getY(0) + event.getY(1)) / 2f - ) - val oldPan = lastPan - if (oldPan != null) { - val matrix = currentZoomMatrix() - matrix.postTranslate(newPan.x - oldPan.x, newPan.y - oldPan.y) - applyZoomMatrix(matrix, false) + val midX = (event.getX(0) + event.getX(1)) / 2f + val midY = (event.getY(0) + event.getY(1)) / 2f + val startX = twoPointerStartX ?: midX + val startY = twoPointerStartY ?: midY + val diffX = midX - startX + val diffY = midY - startY + val displacement = kotlin.math.hypot(diffX.toDouble(), diffY.toDouble()).toFloat() + if (displacement > twoPointerMaxDisplacement) { + twoPointerMaxDisplacement = displacement + } + + val density = ctx.resources.displayMetrics.density + if (!twoPointerFired && !twoPointerIsScaling && abs(diffX) > 40f * density && abs(diffX) > abs(diffY) * 1.3f) { + twoPointerFired = true + playerView.callbacks?.onJumpSubtitle(diffX < 0) + } + + if (scaleGestureDetector?.isInProgress == true) { + twoPointerIsScaling = true + } + + if (twoPointerIsScaling) { + val newPan = Vector2(midX, midY) + val oldPan = lastPan + if (oldPan != null) { + val matrix = currentZoomMatrix() + matrix.postTranslate(newPan.x - oldPan.x, newPan.y - oldPan.y) + applyZoomMatrix(matrix, false) + } + lastPan = newPan } - lastPan = newPan } } MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_POINTER_UP, MotionEvent.ACTION_UP -> { + val upTime = System.currentTimeMillis() + val startX = twoPointerStartX + val density = ctx.resources.displayMetrics.density + if (!twoPointerFired && !twoPointerIsScaling && startX != null && upTime - twoPointerDownTime < 400L && twoPointerMaxDisplacement < 30f * density) { + twoPointerFired = true + val isRightSide = startX >= screenWidthWithOrientation / 2f + playerView.callbacks?.onJumpSubtitle(isRightSide) + } + + twoPointerStartX = null + twoPointerStartY = null + twoPointerDownTime = 0L + twoPointerMaxDisplacement = 0f + twoPointerFired = false + val wasScaling = twoPointerIsScaling + twoPointerIsScaling = false lastPan = null videoOutline?.isVisible = false matrixAnimation?.cancel() matrixAnimation = null - // Snap to desired matrix after zoom gesture ends - matrixAnimation = ValueAnimator.ofFloat(0f, 1f).apply { - startDelay = 0 - duration = 200 - val startMatrix = currentZoomMatrix() - val endMatrix = desiredMatrix ?: return@apply - val (startX, startY, startScale) = matrixToTranslationAndScale(startMatrix) - val (endX, endY, endScale) = matrixToTranslationAndScale(endMatrix) - addUpdateListener { anim -> - val v = anim.animatedValue as Float - val vInv = 1f - v - val m = Matrix() - m.setScale(startScale * vInv + endScale * v, startScale * vInv + endScale * v) - m.postTranslate(startX * vInv + endX * v, startY * vInv + endY * v) - applyZoomMatrix(m, true) + if (wasScaling) { + // Snap to desired matrix after zoom gesture ends + matrixAnimation = ValueAnimator.ofFloat(0f, 1f).apply { + startDelay = 0 + duration = 200 + val startMatrix = currentZoomMatrix() + val endMatrix = desiredMatrix ?: return@apply + val (startX, startY, startScale) = matrixToTranslationAndScale(startMatrix) + val (endX, endY, endScale) = matrixToTranslationAndScale(endMatrix) + addUpdateListener { anim -> + val v = anim.animatedValue as Float + val vInv = 1f - v + val m = Matrix() + m.setScale(startScale * vInv + endScale * v, startScale * vInv + endScale * v) + m.postTranslate(startX * vInv + endX * v, startY * vInv + endY * v) + applyZoomMatrix(m, true) + } + start() } - start() } onGestureEnd() @@ -1066,6 +1144,8 @@ class PlayerGestureHelper(private val playerView: PlayerView) { if ((event.pointerCount >= 2 || lastPan != null) && isFullScreen && !isLocked && !hasTriggeredSpeedUp && currentTouchAction == null) { holdHandler.removeCallbacks(holdRunnable) // Remove 2x speed. + holdHandler.removeCallbacks(subRevealRunnable) + holdHandler.removeCallbacks(dualSubRunnable) isCurrentTouchValid = false // Prevent other touches return handleZoomPanGesture( event = event, @@ -1091,7 +1171,19 @@ class PlayerGestureHelper(private val playerView: PlayerView) { if (isCurrentTouchValid) { playerView.callbacks?.onTouchDown() hasTriggeredSpeedUp = false - if (speedupEnabled && playerView.player.getIsPlaying() && !isLocked) { + hasTriggeredSubReveal = false + hasTriggeredDualSub = false + holdHandler.removeCallbacks(holdRunnable) + holdHandler.removeCallbacks(subRevealRunnable) + holdHandler.removeCallbacks(dualSubRunnable) + val isTopRightCorner = event.x >= view.width * 0.75f && event.y <= view.height * 0.25f + val isRight30Percent = event.x >= view.width * 0.7f && !isTopRightCorner + Log.i(TAG, "ACTION_DOWN: isTopRightCorner=$isTopRightCorner, isRight30Percent=$isRight30Percent (x=${event.x}, y=${event.y}, w=${view.width}, h=${view.height}), secSub=${playerView.player.getCurrentSecondarySubtitle()}") + if (isTopRightCorner && !isLocked) { + holdHandler.postDelayed(dualSubRunnable, 400) + } else if (isRight30Percent && !isLocked && playerView.player.getCurrentSecondarySubtitle() != null) { + holdHandler.postDelayed(subRevealRunnable, 200) + } else if (speedupEnabled && playerView.player.getIsPlaying() && !isLocked) { holdHandler.postDelayed(holdRunnable, 500) } isVolumeLocked = currentRequestedVolume < 1.0f @@ -1109,7 +1201,7 @@ class PlayerGestureHelper(private val playerView: PlayerView) { } MotionEvent.ACTION_MOVE -> { - if (hasTriggeredSpeedUp) return true + if (hasTriggeredSpeedUp || hasTriggeredSubReveal || hasTriggeredDualSub) return true if (!isCurrentTouchValid) return true if (currentTouchAction == null && startTouch != null) { @@ -1117,6 +1209,8 @@ class PlayerGestureHelper(private val playerView: PlayerView) { if (swipeVerticalEnabled) { if (abs(diffFromStart.y * 100 / screenHeightWithOrientation) > MINIMUM_VERTICAL_SWIPE) { holdHandler.removeCallbacks(holdRunnable) + holdHandler.removeCallbacks(subRevealRunnable) + holdHandler.removeCallbacks(dualSubRunnable) uiShowingBeforeGesture = playerView.callbacks?.isUIShowing() ?: false playerView.callbacks?.onHidePlayerUI() currentTouchAction = if ((startTouch.x) >= view.width / 2f) @@ -1126,6 +1220,8 @@ class PlayerGestureHelper(private val playerView: PlayerView) { if (swipeHorizontalEnabled && !isLocked) { if (abs(diffFromStart.x * 100 / screenHeightWithOrientation) > MINIMUM_HORIZONTAL_SWIPE) { holdHandler.removeCallbacks(holdRunnable) + holdHandler.removeCallbacks(subRevealRunnable) + holdHandler.removeCallbacks(dualSubRunnable) currentTouchAction = TouchAction.Time } } @@ -1165,6 +1261,29 @@ class PlayerGestureHelper(private val playerView: PlayerView) { MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP -> { holdHandler.removeCallbacks(holdRunnable) + holdHandler.removeCallbacks(subRevealRunnable) + holdHandler.removeCallbacks(dualSubRunnable) + if (hasTriggeredDualSub) { + hasTriggeredDualSub = false + isCurrentTouchValid = false + currentTouchStart = null + currentLastTouchAction = null + currentTouchAction = null + currentTouchStartPlayerTime = null + currentTouchLast = null + currentTouchStartTime = null + uiShowingBeforeGesture = false + return true + } + if (hasTriggeredSubReveal) { + hasTriggeredSubReveal = false + val wasPlaying = subRevealWasPlaying + subRevealWasPlaying = false + playerView.callbacks?.onHoldSecondarySubtitle(false) + if (wasPlaying) { + playerView.player.handleEvent(CSPlayerEvent.Play, PlayerEventSource.UI) + } + } if (hasTriggeredSpeedUp) { playerView.player.setPlaybackSpeed(DataStoreHelper.playBackSpeed) showOrHideSpeedUp(false) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerSubtitleHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerSubtitleHelper.kt index f62bad58d91..0e33a9706d9 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerSubtitleHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerSubtitleHelper.kt @@ -97,6 +97,7 @@ class PlayerSubtitleHelper { } var subtitleView: SubtitleView? = null + var secondarySubtitleView: SubtitleView? = null companion object { fun String.toSubtitleMimeType(): String { @@ -135,10 +136,14 @@ class PlayerSubtitleHelper { Log.i(TAG, "SET STYLE = $style") subtitleView?.translationY = -style.elevation.toPx.toFloat() setSubtitleViewStyle(subtitleView, style, true) + setSubtitleViewStyle(secondarySubtitleView, style.copy(backgroundColor = android.graphics.Color.TRANSPARENT, windowColor = android.graphics.Color.TRANSPARENT), false) } fun initSubtitles(subView: SubtitleView?, subHolder: FrameLayout?, style: SaveCaptionStyle?) { subtitleView = subView + secondarySubtitleView = subHolder?.findViewById(com.lagradost.cloudstream3.R.id.secondary_subtitle_view) + secondarySubtitleView?.isClickable = false + secondarySubtitleView?.isLongClickable = false subView?.let { sView -> (sView.parent as ViewGroup?)?.removeView(sView) subHolder?.addView(sView) @@ -148,3 +153,18 @@ class PlayerSubtitleHelper { } } } + +fun android.content.Context.vibrateDevice(durationMillis: Long = 55L) { + try { + val vibrator = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) { + getSystemService(android.os.VibratorManager::class.java)?.defaultVibrator + } else { + @Suppress("DEPRECATION") getSystemService(android.content.Context.VIBRATOR_SERVICE) as? android.os.Vibrator + } + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { + vibrator?.vibrate(android.os.VibrationEffect.createOneShot(durationMillis, android.os.VibrationEffect.DEFAULT_AMPLITUDE)) + } else { + @Suppress("DEPRECATION") vibrator?.vibrate(durationMillis) + } + } catch (_: Throwable) {} +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerView.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerView.kt index 0e6f1a3677d..f15e1517046 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerView.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerView.kt @@ -133,8 +133,11 @@ class PlayerView @JvmOverloads constructor( fun onSingleTap() {} /** Called when the hold-for-speedup gesture starts (show=true) or ends (show=false). */ fun onHoldSpeedUp(show: Boolean) {} + fun onHoldSecondarySubtitle(show: Boolean) {} + fun onOpenDualSubtitleDialog() {} /** Called during brightness swipe with the current extra-brightness alpha (0–1). */ fun onBrightnessExtra(alpha: Float) {} + fun onJumpSubtitle(next: Boolean) {} /** Touch event callbacks */ diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsPlayer.kt index 2949281153f..cf59c93937e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsPlayer.kt @@ -23,6 +23,7 @@ import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpTo import com.lagradost.cloudstream3.ui.subtitles.ChromecastSubtitlesFragment import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.Qualities import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog @@ -247,6 +248,46 @@ class SettingsPlayer : BasePreferenceFragmentCompat() { return@setOnPreferenceClickListener true } + fun updateGeminiPrefSummary() { + val authKey = DataStoreHelper.geminiApiKey + val pref = getPref(R.string.gemini_api_key_settings_key) + if (authKey.isNullOrBlank()) { + pref?.summary = getString(R.string.gemini_api_key_not_set) + } else { + val masked = if (authKey.length > 8) "${authKey.take(4)}...${authKey.takeLast(4)}" else "••••••••" + pref?.summary = "${getString(R.string.gemini_api_key_configured)} ($masked)" + } + } + updateGeminiPrefSummary() + + getPref(R.string.gemini_api_key_settings_key)?.setOnPreferenceClickListener { + val act = activity ?: return@setOnPreferenceClickListener false + val currentAuthKey = DataStoreHelper.geminiApiKey ?: "" + val input = android.widget.EditText(act).apply { + setSingleLine() + inputType = android.text.InputType.TYPE_CLASS_TEXT or android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD + setText(currentAuthKey) + setSelection(currentAuthKey.length) + } + + androidx.appcompat.app.AlertDialog.Builder(act) + .setTitle(R.string.gemini_api_key_prompt) + .setMessage(R.string.gemini_api_key_dialog_message) + .setView(input) + .setPositiveButton(android.R.string.ok) { _, _ -> + val entered = input.text.toString().trim() + DataStoreHelper.geminiApiKey = entered.ifBlank { null } + updateGeminiPrefSummary() + com.lagradost.cloudstream3.CommonActivity.showToast( + act, + if (entered.isNotBlank()) R.string.gemini_api_key_saved else R.string.gemini_api_key_cleared + ) + } + .setNegativeButton(android.R.string.cancel, null) + .show() + return@setOnPreferenceClickListener true + } + getPref(R.string.player_source_priority_key)?.setOnPreferenceClickListener { ioSafe { val defaultSources = QualityProfileDialog.getAllDefaultSources() diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/SubtitlesFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/SubtitlesFragment.kt index bf77ef24767..020d9128f87 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/SubtitlesFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/SubtitlesFragment.kt @@ -36,6 +36,7 @@ import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.SubtitleSettingsBinding import com.lagradost.cloudstream3.ui.BaseDialogFragment import com.lagradost.cloudstream3.ui.BaseFragment +import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.ui.player.CustomDecoder import com.lagradost.cloudstream3.ui.player.CustomDecoder.Companion.setSubtitleAlignment import com.lagradost.cloudstream3.ui.player.OutlineSpan @@ -590,6 +591,11 @@ class SubtitlesFragment : BaseDialogFragment( } } + alwaysShowSecondarySubtitles.isChecked = DataStoreHelper.alwaysShowSecondarySubtitles + alwaysShowSecondarySubtitles.setOnCheckedChangeListener { _, b -> + DataStoreHelper.alwaysShowSecondarySubtitles = b + } + subtitlesRemoveBloat.isChecked = state.removeBloat subtitlesRemoveBloat.setOnCheckedChangeListener { _, b -> state.removeBloat = b diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt index 340266f6c52..a699c8bd16f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt @@ -58,6 +58,8 @@ const val RESULT_SEASON = "result_season" const val RESULT_DUB = "result_dub" const val KEY_RESULT_SORT = "result_sort" const val USER_PINNED_PROVIDERS = "user_pinned_providers" // Key for pinned user set +const val GEMINI_API_KEY = "gemini_api_key" +const val GEMINI_TARGET_LANGUAGE = "gemini_target_language" class UserPreferenceDelegate( private val key: String, @@ -144,6 +146,7 @@ object DataStoreHelper { var playBackSpeed: Float by UserPreferenceDelegate("playback_speed", 1.0f) var resizeMode: Int by UserPreferenceDelegate("resize_mode", 0) + var alwaysShowSecondarySubtitles: Boolean by UserPreferenceDelegate("always_show_secondary_subtitles", false) var librarySortingMode: Int by UserPreferenceDelegate( "library_sorting_mode", ListSorting.AlphabeticalA.ordinal @@ -827,4 +830,12 @@ object DataStoreHelper { var pinnedProviders: Array get() = getKey>(USER_PINNED_PROVIDERS) ?: emptyArray() set(value) = setKey(USER_PINNED_PROVIDERS, value) + + var geminiApiKey: String? + get() = getKey(GEMINI_API_KEY) + set(value) = setKey(GEMINI_API_KEY, value) + + var geminiTargetLanguage: String + get() = getKey(GEMINI_TARGET_LANGUAGE) ?: "Azerbaijani" + set(value) = setKey(GEMINI_TARGET_LANGUAGE, value) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/SingleSelectionHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/SingleSelectionHelper.kt index 26c710103fa..b6af597a62e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/SingleSelectionHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/SingleSelectionHelper.kt @@ -288,6 +288,38 @@ object SingleSelectionHelper { ) } + fun Activity?.showDialogNoCheckmark( + items: List, + name: String, + dismissCallback: () -> Unit = {}, + callback: (Int) -> Unit, + ) { + if (this == null) return + + val binding: BottomSelectionDialogBinding = BottomSelectionDialogBinding.inflate( + LayoutInflater.from(this) + ) + val builder = + AlertDialog.Builder(this, R.style.AlertDialogCustom) + .setView(binding.root) + + val dialog = builder.create() + dialog.show() + + showDialog( + binding, + dialog, + items, + emptyList(), + name, + showApply = false, + false, + { if (it.isNotEmpty()) callback.invoke(it.first()) }, + dismissCallback, + R.layout.sort_bottom_single_choice_no_checkmark + ) + } + /** Only for a low amount of items */ fun Activity?.showBottomDialog( items: List, diff --git a/app/src/main/res/drawable/ic_baseline_auto_awesome_24.xml b/app/src/main/res/drawable/ic_baseline_auto_awesome_24.xml new file mode 100644 index 00000000000..16d9479a4d3 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_auto_awesome_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/dialog_dual_subtitles.xml b/app/src/main/res/layout/dialog_dual_subtitles.xml new file mode 100644 index 00000000000..638acf5ac79 --- /dev/null +++ b/app/src/main/res/layout/dialog_dual_subtitles.xml @@ -0,0 +1,110 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/dialog_dual_subtitles_item.xml b/app/src/main/res/layout/dialog_dual_subtitles_item.xml new file mode 100644 index 00000000000..2eb3a7cea91 --- /dev/null +++ b/app/src/main/res/layout/dialog_dual_subtitles_item.xml @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/player_custom_layout.xml b/app/src/main/res/layout/player_custom_layout.xml index 5ccc3ff09ac..77c41acf602 100644 --- a/app/src/main/res/layout/player_custom_layout.xml +++ b/app/src/main/res/layout/player_custom_layout.xml @@ -1090,6 +1090,13 @@ android:layout_width="match_parent" android:layout_height="match_parent"> + + + + + + Sync subs 1000 ms Subtitle delay + Secondary subtitle delay + Previous subtitle + Next subtitle + Gemini API Key + gemini_api_key_settings_key + AI Subtitle Translation (Gemini) + Not configured. Tap to set API key. + Configured + Enter your Google Gemini API key to enable on-the-fly subtitle translation. + Gemini API key saved + Gemini API key cleared + Set Google Gemini API key for subtitle translation + AI Target Language + Translate with AI + Translating subtitles with Gemini AI… + Enter Google Gemini API Key + Please enter a valid Gemini API Key + AI subtitle translation completed + AI translation failed: %s Use this if the subtitles are shown %d ms too early Use this if subtitles are shown %d ms too late No subtitle delay @@ -486,6 +505,7 @@ Error Remove closed captions from subtitles Remove bloat from subtitles + Always show secondary subtitle Filter by preferred media language Extras Trailer diff --git a/app/src/main/res/xml/settings_player.xml b/app/src/main/res/xml/settings_player.xml index 6e136747448..a420f6d1f4b 100644 --- a/app/src/main/res/xml/settings_player.xml +++ b/app/src/main/res/xml/settings_player.xml @@ -53,6 +53,11 @@ android:key="@string/subtitle_settings_chromecast_key" android:title="@string/chromecast_subtitles_settings" app:summary="@string/chromecast_subtitles_settings_des" /> + diff --git a/app/src/test/java/com/lagradost/cloudstream3/GeminiSubtitleTranslatorTest.kt b/app/src/test/java/com/lagradost/cloudstream3/GeminiSubtitleTranslatorTest.kt new file mode 100644 index 00000000000..fa046c74e5d --- /dev/null +++ b/app/src/test/java/com/lagradost/cloudstream3/GeminiSubtitleTranslatorTest.kt @@ -0,0 +1,56 @@ +package com.lagradost.cloudstream3 + +import com.lagradost.cloudstream3.ui.player.GeminiSubtitleTranslator +import com.lagradost.cloudstream3.ui.player.SubtitleCue +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +class GeminiSubtitleTranslatorTest { + + @Test + fun `test cuesToVttFile generates valid WebVTT formatting`() { + val cues = listOf( + SubtitleCue( + startTimeMs = 1000L, + durationMs = 2500L, + text = listOf("Salam, necəsən?") + ), + SubtitleCue( + startTimeMs = 4000L, + durationMs = 3000L, + text = listOf("Yaxşıyam, çox sağ ol!") + ) + ) + + val tempDir = File(System.getProperty("java.io.tmpdir") ?: "/tmp") + val file = File(tempDir, "test_generated.vtt") + file.bufferedWriter().use { writer -> + writer.write("WEBVTT\n\n") + cues.forEach { cue -> + val start = "%02d:%02d:%02d.%03d".format( + cue.startTimeMs / 1000 / 3600, + cue.startTimeMs / 1000 / 60 % 60, + cue.startTimeMs / 1000 % 60, + cue.startTimeMs % 1000 + ) + val endMs = cue.startTimeMs + cue.durationMs + val end = "%02d:%02d:%02d.%03d".format( + endMs / 1000 / 3600, + endMs / 1000 / 60 % 60, + endMs / 1000 % 60, + endMs % 1000 + ) + writer.write("$start --> $end\n${cue.text.joinToString("\n")}\n\n") + } + } + + assertTrue("VTT file should exist", file.exists()) + val content = file.readText() + assertTrue("Header should be WEBVTT", content.startsWith("WEBVTT")) + assertTrue("Content should contain first line", content.contains("Salam, necəsən?")) + assertTrue("Content should contain second line", content.contains("Yaxşıyam, çox sağ ol!")) + file.delete() + } +}