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 7f658691245..0929acdef5c 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 @@ -16,6 +16,7 @@ import androidx.annotation.MainThread import androidx.annotation.OptIn import androidx.appcompat.app.AlertDialog import androidx.core.net.toUri +import androidx.media3.common.C import androidx.media3.common.C.TIME_UNSET import androidx.media3.common.C.TRACK_TYPE_AUDIO import androidx.media3.common.C.TRACK_TYPE_TEXT @@ -32,6 +33,7 @@ import androidx.media3.common.VideoSize import androidx.media3.common.util.UnstableApi import androidx.media3.database.StandaloneDatabaseProvider import androidx.media3.datasource.DataSource +import androidx.media3.datasource.DataSpec import androidx.media3.datasource.DefaultDataSource import androidx.media3.datasource.DefaultHttpDataSource import androidx.media3.datasource.HttpDataSource @@ -87,6 +89,7 @@ import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle +import com.lagradost.cloudstream3.ui.subtitles.SUBTITLE_DUAL_ENABLED_KEY import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment.Companion.applyStyle import com.lagradost.cloudstream3.utils.AppContextUtils.isUsingMobileData import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus @@ -107,6 +110,7 @@ import kotlinx.coroutines.delay import okhttp3.Interceptor import org.chromium.net.CronetEngine import java.io.File +import java.io.ByteArrayOutputStream import java.security.SecureRandom import java.util.UUID import java.util.concurrent.Executors @@ -292,6 +296,9 @@ class CS3IPlayer : IPlayer { saveData() } else { currentSubtitles = subtitle + currentSecondarySubtitles = null + dualMergedTrackId = null + cleanDualSubtitleCache(context) playbackPosition = 0 } @@ -334,9 +341,19 @@ class CS3IPlayer : IPlayer { override fun setActiveSubtitles(subtitles: Set) { Log.i(TAG, "setActiveSubtitles ${subtitles.size}") subtitleHelper.setAllSubtitles(subtitles) + + if (currentSubtitles != null && !subtitles.contains(currentSubtitles)) { + currentSubtitles = null + } + if (currentSecondarySubtitles != null && !subtitles.contains(currentSecondarySubtitles)) { + currentSecondarySubtitles = null + } + dualMergedTrackId = null } private var currentSubtitles: SubtitleData? = null + private var currentSecondarySubtitles: SubtitleData? = null + private var dualMergedTrackId: String? = null private fun List.getTrack(id: String?): Pair? { if (id == null) return null @@ -500,6 +517,7 @@ class CS3IPlayer : IPlayer { * */ override fun setPreferredSubtitles(subtitle: SubtitleData?): Boolean { Log.i(TAG, "setPreferredSubtitles init $subtitle") + val previousPrimary = currentSubtitles currentSubtitles = subtitle val trackSelector = exoPlayer?.trackSelector as? DefaultTrackSelector ?: return false // Disable subtitles if null @@ -511,6 +529,26 @@ class CS3IPlayer : IPlayer { ) return false } + + if (isDualSubtitleTrackSelectionEnabled()) { + if (previousPrimary?.getId() != subtitle.getId()) { + return true + } + val mergedTrackId = dualMergedTrackId ?: return true + exoPlayer?.currentTracks?.groups + ?.filter { it.type == TRACK_TYPE_TEXT } + ?.getTrack(mergedTrackId) + ?.let { (trackGroup, trackIndex) -> + trackSelector.setParameters( + trackSelector.buildUponParameters() + .setTrackTypeDisabled(TRACK_TYPE_TEXT, false) + .setOverrideForType(TrackSelectionOverride(trackGroup, trackIndex)) + ) + return false + } + return true + } + // Handle subtitle based on status when (subtitleHelper.subtitleStatus(subtitle)) { SubtitleStatus.REQUIRES_RELOAD -> { @@ -538,6 +576,32 @@ class CS3IPlayer : IPlayer { } } + override fun setSecondarySubtitles(subtitle: SubtitleData?): Boolean { + val changed = subtitle != currentSecondarySubtitles + currentSecondarySubtitles = subtitle + dualMergedTrackId = null + return changed + } + + override fun getCurrentSecondarySubtitle(): SubtitleData? { + return currentSecondarySubtitles + } + + private fun isDualSubtitleTrackSelectionEnabled(): Boolean { + val enabled = getKey(SUBTITLE_DUAL_ENABLED_KEY) ?: false + return enabled && currentSubtitles != null && currentSecondarySubtitles != null + } + + override fun isDualSubtitleCombinationSupported( + primary: SubtitleData?, + secondary: SubtitleData? + ): Boolean { + if (secondary == null) return true + if (primary == null) return false + val supportedOrigins = setOf(SubtitleOrigin.URL, SubtitleOrigin.DOWNLOADED_FILE) + return primary.origin in supportedOrigins && secondary.origin in supportedOrigins + } + private var currentSubtitleOffset: Long = 0 override fun setSubtitleOffset(offset: Long) { @@ -561,6 +625,9 @@ class CS3IPlayer : IPlayer { } override fun getCurrentPreferredSubtitle(): SubtitleData? { + if (isDualSubtitleTrackSelectionEnabled()) { + return currentSubtitles + } return subtitleHelper.getAllSubtitles().firstOrNull { sub -> playerSelectedSubtitleTracks.any { (id, isSelected) -> isSelected && sub.getId() == id @@ -732,6 +799,10 @@ class CS3IPlayer : IPlayer { field = value } + private const val DUAL_SUB_DIR = "dual_subtitles" + private const val DUAL_SUB_PREFIX = "dual_sub_" + private const val DUAL_SUB_EXTENSION = ".vtt" + private var simpleCache: SimpleCache? = null /// Create a small factory for small things, no cache, no cronet @@ -1635,6 +1706,7 @@ class CS3IPlayer : IPlayer { val offlineSourceFactory = context.createOfflineSource() val (subSources, activeSubtitles) = getSubSources( + context = context, offlineSourceFactory = offlineSourceFactory, subHelper = subtitleHelper, interceptor = null, @@ -1649,10 +1721,46 @@ class CS3IPlayer : IPlayer { } private fun getSubSources( + context: Context, offlineSourceFactory: DataSource.Factory?, subHelper: PlayerSubtitleHelper, interceptor: Interceptor?, ): Pair, List> { + val dualEnabled = getKey(SUBTITLE_DUAL_ENABLED_KEY) ?: false + val selectedPrimary = currentSubtitles + val selectedSecondary = currentSecondarySubtitles + + if (dualEnabled && selectedSecondary != null && selectedPrimary == null) { + Log.w(TAG, "Secondary subtitle selected without primary, falling back to no subtitles") + currentSecondarySubtitles = null + dualMergedTrackId = null + // Fall through to single subtitle loading + } else if (dualEnabled && selectedPrimary != null && selectedSecondary != null) { + if (!isDualSubtitleCombinationSupported(selectedPrimary, selectedSecondary)) { + Log.w(TAG, "Unsupported dual subtitle combination, falling back to primary only") + currentSecondarySubtitles = null + dualMergedTrackId = null + // Fall through to single subtitle loading + } else { + buildMergedDualSubtitleSource( + context = context, + offlineSourceFactory = offlineSourceFactory, + primary = selectedPrimary, + secondary = selectedSecondary, + interceptor = interceptor + )?.let { merged -> + dualMergedTrackId = merged.second.getId() + return Pair(listOf(merged.first), listOf(merged.second)) + } + + Log.w(TAG, "Failed to build merged dual subtitles, falling back to primary only") + currentSecondarySubtitles = null + dualMergedTrackId = null + // Fall through to single subtitle loading + } + } + + dualMergedTrackId = null val activeSubtitles = ArrayList() val subSources = subHelper.getAllSubtitles().mapNotNull { sub -> val subConfig = MediaItem.SubtitleConfiguration.Builder(sub.getFixedUrl().toUri()) @@ -1683,6 +1791,120 @@ class CS3IPlayer : IPlayer { return Pair(subSources, activeSubtitles) } + private fun cleanDualSubtitleCache(context: Context, exclude: File? = null) { + try { + val dualSubDir = File(context.cacheDir, DUAL_SUB_DIR) + if (!dualSubDir.exists()) return + dualSubDir.listFiles { file -> + file.name.startsWith(DUAL_SUB_PREFIX) && file.name.endsWith(DUAL_SUB_EXTENSION) && file != exclude + }?.forEach { it.delete() } + } catch (t: Throwable) { + logError(t) + } + } + + private fun buildMergedDualSubtitleSource( + context: Context, + offlineSourceFactory: DataSource.Factory?, + primary: SubtitleData, + secondary: SubtitleData, + interceptor: Interceptor?, + ): Pair? { + val primaryBytes = loadSubtitleBytes(context, primary, interceptor) ?: return null + val secondaryBytes = loadSubtitleBytes(context, secondary, interceptor) ?: return null + val primaryCues = parseSubtitleCues(primaryBytes, primary) ?: return null + val secondaryCues = parseSubtitleCues(secondaryBytes, secondary) ?: return null + val mergedSegments = DualSubtitleComposer.compose(primaryCues, secondaryCues) + if (mergedSegments.isEmpty()) return null + + val mergedContent = DualSubtitleComposer.toWebVtt(mergedSegments) + val dualSubDir = File(context.cacheDir, DUAL_SUB_DIR).apply { mkdirs() } + val cacheFile = File( + dualSubDir, + "${DUAL_SUB_PREFIX}${primary.getId().hashCode()}_${secondary.getId().hashCode()}${DUAL_SUB_EXTENSION}" + ) + cleanDualSubtitleCache(context, exclude = cacheFile) + cacheFile.writeText(mergedContent) + + val mergedSubtitle = SubtitleData( + originalName = primary.originalName, + nameSuffix = "dual", + url = cacheFile.toUri().toString(), + origin = SubtitleOrigin.DOWNLOADED_FILE, + mimeType = MimeTypes.TEXT_VTT, + headers = emptyMap(), + languageCode = primary.languageCode + ) + val subtitleConfig = MediaItem.SubtitleConfiguration.Builder(mergedSubtitle.url.toUri()) + .setMimeType(mergedSubtitle.mimeType) + .setLanguage("_${mergedSubtitle.name}") + .setId(mergedSubtitle.getId()) + .setSelectionFlags(0) + .build() + + val sourceFactory = offlineSourceFactory ?: context.createOfflineSource() + val source = SingleSampleMediaSource.Factory(sourceFactory) + .createMediaSource(subtitleConfig, TIME_UNSET) + return Pair(source, mergedSubtitle) + } + + private fun parseSubtitleCues(bytes: ByteArray, subtitle: SubtitleData): List? { + return try { + val format = Format.Builder() + .setSampleMimeType(subtitle.mimeType) + .build() + val decoder = CustomDecoder(format) + decoder.parseToLegacySubtitle(bytes, 0, bytes.size) + decoder.currentSubtitleCues.toList() + } catch (t: Throwable) { + logError(t) + null + } + } + + private fun loadSubtitleBytes( + context: Context, + subtitle: SubtitleData, + interceptor: Interceptor?, + ): ByteArray? { + return try { + when (subtitle.origin) { + SubtitleOrigin.URL -> { + val factory = createOnlineSource(subtitle.headers, interceptor) + val dataSource = factory.createDataSource() + val dataSpec = DataSpec.Builder() + .setUri(subtitle.getFixedUrl().toUri()) + .build() + val output = ByteArrayOutputStream() + val buffer = ByteArray(8 * 1024) + try { + dataSource.open(dataSpec) + while (true) { + val read = dataSource.read(buffer, 0, buffer.size) + if (read == C.RESULT_END_OF_INPUT) break + if (read > 0) output.write(buffer, 0, read) + } + } finally { + dataSource.close() + } + output.toByteArray() + } + + SubtitleOrigin.DOWNLOADED_FILE -> { + val uri = subtitle.url.toUri() + context.contentResolver.openInputStream(uri)?.use { + it.readBytes() + } + } + + SubtitleOrigin.EMBEDDED_IN_VIDEO -> null + } + } catch (t: Throwable) { + logError(t) + null + } + } + /** * Creates audio media sources from ExtractorLink's audioTracks * @param audioTracks List of audio tracks from ExtractorLink @@ -1881,6 +2103,7 @@ class CS3IPlayer : IPlayer { val offlineSourceFactory = context.createOfflineSource() val (subSources, activeSubtitles) = getSubSources( + context = context, offlineSourceFactory = offlineSourceFactory, subHelper = subtitleHelper, interceptor = interceptor, // Backwards compatibility, needs a new api to work properly @@ -1952,4 +2175,3 @@ class CS3IPlayer : IPlayer { } } - diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/DualSubtitleComposer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/DualSubtitleComposer.kt new file mode 100644 index 00000000000..a98a1c58a33 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/DualSubtitleComposer.kt @@ -0,0 +1,104 @@ +package com.lagradost.cloudstream3.ui.player + +import java.util.Locale +import java.util.TreeSet + +data class DualSubtitleSegment( + val startTimeMs: Long, + val endTimeMs: Long, + val text: String, +) + +object DualSubtitleComposer { + private const val WEBVTT_HEADER = "WEBVTT\n\n" + + fun compose( + primaryCues: List, + secondaryCues: List, + ): List { + val boundaries = TreeSet() + (primaryCues + secondaryCues).forEach { cue -> + if (cue.endTimeMs > cue.startTimeMs) { + boundaries.add(cue.startTimeMs) + boundaries.add(cue.endTimeMs) + } + } + + if (boundaries.size < 2) return emptyList() + + val points = boundaries.toList() + val segments = ArrayList() + for (i in 0 until points.lastIndex) { + val start = points[i] + val end = points[i + 1] + if (end <= start) continue + + val primaryText = getActiveText(primaryCues, start) + val secondaryText = getActiveText(secondaryCues, start) + if (primaryText.isBlank() && secondaryText.isBlank()) continue + + val mergedText = buildString { + if (secondaryText.isNotBlank()) append(secondaryText) + if (primaryText.isNotBlank()) { + if (isNotEmpty()) append('\n') + append(primaryText) + } + }.trim() + if (mergedText.isBlank()) continue + + val previous = segments.lastOrNull() + if (previous != null && previous.endTimeMs == start && previous.text == mergedText) { + segments[segments.lastIndex] = previous.copy(endTimeMs = end) + } else { + segments.add(DualSubtitleSegment(start, end, mergedText)) + } + } + + return segments + } + + fun toWebVtt(segments: List): String { + if (segments.isEmpty()) return WEBVTT_HEADER + + val output = StringBuilder(WEBVTT_HEADER) + segments.forEachIndexed { index, segment -> + output.append(index + 1).append('\n') + output.append(formatTimestamp(segment.startTimeMs)) + .append(" --> ") + .append(formatTimestamp(segment.endTimeMs)) + .append('\n') + .append(segment.text) + .append("\n\n") + } + return output.toString() + } + + private fun getActiveText(cues: List, timeMs: Long): String { + val lines = LinkedHashSet() + cues.forEach { cue -> + if (timeMs in cue.startTimeMs until cue.endTimeMs) { + cue.text.forEach { line -> + val cleaned = line.trim() + if (cleaned.isNotEmpty()) lines.add(cleaned) + } + } + } + return lines.joinToString("\n") + } + + private fun formatTimestamp(timeMs: Long): String { + val clamped = timeMs.coerceAtLeast(0) + val hours = clamped / 3_600_000 + val minutes = clamped % 3_600_000 / 60_000 + val seconds = clamped % 60_000 / 1_000 + val millis = clamped % 1_000 + return String.format( + Locale.US, + "%02d:%02d:%02d.%03d", + 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 7138e8dadad..8ad086259b6 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 @@ -47,6 +47,7 @@ import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull import com.lagradost.cloudstream3.CloudStreamApp +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.LoadResponse @@ -95,6 +96,7 @@ import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.ui.subtitles.SUBTITLE_AUTO_SELECT_KEY +import com.lagradost.cloudstream3.ui.subtitles.SUBTITLE_DUAL_ENABLED_KEY import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment.Companion.getAutoSelectLanguageTagIETF import com.lagradost.cloudstream3.utils.AppContextUtils.html @@ -165,11 +167,17 @@ class GeneratorPlayer : FullScreenPlayer() { private var currentSelectedLink: Pair? = null private var currentSelectedSubtitles: SubtitleData? = null + private var currentSelectedSecondarySubtitles: SubtitleData? = null private var currentMeta: Any? = null private var nextMeta: Any? = null private var isActive: Boolean = false private var isNextEpisode: Boolean = false // this is used to reset the watch time + private enum class SubtitleSelectionMode { + PRIMARY, + SECONDARY, + } + private var preferredAutoSelectSubtitles: String? = null // null means do nothing, "" means none private var binding: FragmentPlayerBinding? = null @@ -177,6 +185,7 @@ class GeneratorPlayer : FullScreenPlayer() { private fun startLoading() { player.release() currentSelectedSubtitles = null + currentSelectedSecondarySubtitles = null isActive = false binding?.overlayLoadingSkipButton?.isVisible = false binding?.playerLoadingOverlay?.isVisible = true @@ -203,6 +212,15 @@ class GeneratorPlayer : FullScreenPlayer() { return player.setPreferredSubtitles(subtitle) } + private fun setSecondarySubtitles(subtitle: SubtitleData?): Boolean { + currentSelectedSecondarySubtitles = subtitle + return player.setSecondarySubtitles(subtitle) + } + + private fun isDualSubtitleEnabled(): Boolean { + return getKey(SUBTITLE_DUAL_ENABLED_KEY) ?: false + } + override fun embeddedSubtitlesFetched(subtitles: List) { viewModel.addSubtitles(subtitles.toSet()) } @@ -226,10 +244,6 @@ class GeneratorPlayer : FullScreenPlayer() { } } - private fun noSubtitles(): Boolean { - return setSubtitles(null, true) - } - private fun getPos(): Long { val durPos = getViewPos(viewModel.getId()) ?: return 0L if (durPos.duration == 0L) return 0L @@ -516,8 +530,11 @@ class GeneratorPlayer : FullScreenPlayer() { isActive = true setPlayerDimen(null) setTitle() - if (!sameEpisode) + if (!sameEpisode) { hasRequestedStamps = false + currentSelectedSecondarySubtitles = null + setSecondarySubtitles(null) + } loadExtractorJob(link.first) // load player @@ -1012,6 +1029,7 @@ class GeneratorPlayer : FullScreenPlayer() { override fun showMirrorsDialogue() { try { currentSelectedSubtitles = player.getCurrentPreferredSubtitle() + currentSelectedSecondarySubtitles = player.getCurrentSecondarySubtitle() //println("CURRENT SELECTED :$currentSelectedSubtitles of $currentSubs") context?.let { ctx -> val isPlaying = player.getIsPlaying() @@ -1172,31 +1190,105 @@ class GeneratorPlayer : FullScreenPlayer() { val subtitles = subtitlesGrouped.map { it.key.html() } - val subtitleGroupIndexStart = - subtitlesGrouped.keys.indexOf(currentSelectedSubtitles?.originalName) + 1 - var subtitleGroupIndex = subtitleGroupIndexStart + val dualEnabled = isDualSubtitleEnabled() + binding.subtitlesDualControls.isGone = !dualEnabled - val subtitleOptionIndexStart = - subtitlesGrouped[currentSelectedSubtitles?.originalName]?.indexOfFirst { it.nameSuffix == currentSelectedSubtitles?.nameSuffix } + val primaryGroupIndexStart = + subtitlesGrouped.keys.indexOf(currentSelectedSubtitles?.originalName) + 1 + var primaryGroupIndex = primaryGroupIndexStart + val primaryOptionIndexStart = + subtitlesGrouped[currentSelectedSubtitles?.originalName] + ?.indexOfFirst { it.nameSuffix == currentSelectedSubtitles?.nameSuffix } ?: 0 - var subtitleOptionIndex = subtitleOptionIndexStart + var primaryOptionIndex = primaryOptionIndexStart + + val secondaryGroupIndexStart = + subtitlesGrouped.keys.indexOf(currentSelectedSecondarySubtitles?.originalName) + 1 + var secondaryGroupIndex = secondaryGroupIndexStart + val secondaryOptionIndexStart = + subtitlesGrouped[currentSelectedSecondarySubtitles?.originalName] + ?.indexOfFirst { it.nameSuffix == currentSelectedSecondarySubtitles?.nameSuffix } + ?: 0 + var secondaryOptionIndex = secondaryOptionIndexStart + + var selectionMode = SubtitleSelectionMode.PRIMARY subsArrayAdapter.addAll(subtitles) subtitleList.adapter = subsArrayAdapter subtitleList.choiceMode = AbsListView.CHOICE_MODE_SINGLE - subtitleList.setSelection(subtitleGroupIndex) - subtitleList.setItemChecked(subtitleGroupIndex, true) - val subsOptionsArrayAdapter = ArrayAdapter(ctx, R.layout.sort_bottom_single_choice) subtitleOptionList.adapter = subsOptionsArrayAdapter subtitleOptionList.choiceMode = AbsListView.CHOICE_MODE_SINGLE + fun selectedSubtitle(groupIndex: Int, optionIndex: Int): SubtitleData? { + if (groupIndex <= 0) return null + return subtitlesGroupedList.getOrNull(groupIndex - 1)?.value?.getOrNull(optionIndex) + } + + fun getActiveGroupIndex(): Int { + return if (selectionMode == SubtitleSelectionMode.PRIMARY) { + primaryGroupIndex + } else { + secondaryGroupIndex + } + } + + fun getActiveOptionIndex(): Int { + return if (selectionMode == SubtitleSelectionMode.PRIMARY) { + primaryOptionIndex + } else { + secondaryOptionIndex + } + } + + fun getStartGroupIndexForMode(): Int { + return if (selectionMode == SubtitleSelectionMode.PRIMARY) { + primaryGroupIndexStart + } else { + secondaryGroupIndexStart + } + } + + fun getStartOptionIndexForMode(): Int { + return if (selectionMode == SubtitleSelectionMode.PRIMARY) { + primaryOptionIndexStart + } else { + secondaryOptionIndexStart + } + } + + fun setActiveGroupIndex(value: Int) { + if (selectionMode == SubtitleSelectionMode.PRIMARY) { + primaryGroupIndex = value + } else { + secondaryGroupIndex = value + } + } + + fun setActiveOptionIndex(value: Int) { + if (selectionMode == SubtitleSelectionMode.PRIMARY) { + primaryOptionIndex = value + } else { + secondaryOptionIndex = value + } + } + + fun updateModeButtons() { + if (!dualEnabled) return + val isPrimary = selectionMode == SubtitleSelectionMode.PRIMARY + binding.subtitlesEditPrimaryBtt.isSelected = isPrimary + binding.subtitlesEditSecondaryBtt.isSelected = !isPrimary + binding.subtitlesEditPrimaryIndicator.isVisible = isPrimary + binding.subtitlesEditSecondaryIndicator.isVisible = !isPrimary + } + fun updateSubtitleOptionList() { subsOptionsArrayAdapter.clear() + val subtitleGroupIndex = getActiveGroupIndex() val subtitleOptions = subtitlesGroupedList @@ -1219,11 +1311,30 @@ class GeneratorPlayer : FullScreenPlayer() { subsOptionsArrayAdapter.addAll(subtitleOptions) + val subtitleOptionIndex = getActiveOptionIndex() subtitleOptionList.setSelection(subtitleOptionIndex) subtitleOptionList.setItemChecked(subtitleOptionIndex, true) } - updateSubtitleOptionList() + fun updateSubtitleSelection() { + val activeGroup = getActiveGroupIndex() + subtitleList.setSelection(activeGroup) + subtitleList.setItemChecked(activeGroup, true) + updateSubtitleOptionList() + updateModeButtons() + } + + binding.subtitlesEditPrimaryBtt.setOnClickListener { + selectionMode = SubtitleSelectionMode.PRIMARY + updateSubtitleSelection() + } + + binding.subtitlesEditSecondaryBtt.setOnClickListener { + selectionMode = SubtitleSelectionMode.SECONDARY + updateSubtitleSelection() + } + + updateSubtitleSelection() subtitleList.setOnItemClickListener { _, _, which, _ -> if (which > subtitlesGrouped.size) { @@ -1237,28 +1348,31 @@ class GeneratorPlayer : FullScreenPlayer() { val child = subtitleList.adapter.getView(which, null, subtitleList) child?.performClick() } else { - if (subtitleGroupIndex != which) { - subtitleGroupIndex = which - subtitleOptionIndex = - if (subtitleGroupIndex == subtitleGroupIndexStart) { - subtitleOptionIndexStart + val currentGroup = getActiveGroupIndex() + if (currentGroup != which) { + setActiveGroupIndex(which) + setActiveOptionIndex( + if (which == getStartGroupIndexForMode()) { + getStartOptionIndexForMode() } else { 0 } + ) } subtitleList.setItemChecked(which, true) - updateSubtitleOptionList() + updateSubtitleSelection() } } subtitleOptionList.setOnItemClickListener { _, _, which, _ -> + val subtitleGroupIndex = getActiveGroupIndex() if (which >= (subtitlesGroupedList.getOrNull(subtitleGroupIndex - 1)?.value?.size ?: -1) ) { val child = subtitleOptionList.adapter.getView(which, null, subtitleList) child?.performClick() } else { - subtitleOptionIndex = which + setActiveOptionIndex(which) subtitleOptionList.setItemChecked(which, true) } } @@ -1336,17 +1450,38 @@ class GeneratorPlayer : FullScreenPlayer() { binding.applyBtt.setOnClickListener { var init = sourceIndex != startSource - if (subtitleGroupIndex != subtitleGroupIndexStart || subtitleOptionIndex != subtitleOptionIndexStart) { - init = init or if (subtitleGroupIndex <= 0) { - noSubtitles() + val selectedPrimary = selectedSubtitle(primaryGroupIndex, primaryOptionIndex) + val selectedSecondary = + if (dualEnabled) selectedSubtitle(secondaryGroupIndex, secondaryOptionIndex) else null + + if (dualEnabled && !player.isDualSubtitleCombinationSupported( + selectedPrimary, + selectedSecondary + ) + ) { + val messageRes = if (selectedPrimary?.origin == SubtitleOrigin.EMBEDDED_IN_VIDEO || + selectedSecondary?.origin == SubtitleOrigin.EMBEDDED_IN_VIDEO + ) { + R.string.subtitles_secondary_unsupported_embedded } else { - subtitlesGroupedList.getOrNull(subtitleGroupIndex - 1)?.value?.getOrNull( - subtitleOptionIndex - )?.let { - setSubtitles(it, true) - } ?: false + R.string.subtitles_secondary_unsupported_combo } + showToast(messageRes, Toast.LENGTH_LONG) + return@setOnClickListener + } + + if (selectedPrimary != currentSelectedSubtitles) { + init = init or setSubtitles(selectedPrimary, true) } + + if (dualEnabled) { + if (selectedSecondary != currentSelectedSecondarySubtitles) { + init = init or setSecondarySubtitles(selectedSecondary) + } + } else if (currentSelectedSecondarySubtitles != null) { + init = init or setSecondarySubtitles(null) + } + if (init) { sortedUrls.getOrNull(sourceIndex)?.let { loadLink(it, true) 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 183f26f7377..8521cdd1f30 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 @@ -276,6 +276,9 @@ interface IPlayer { fun setActiveSubtitles(subtitles: Set) fun setPreferredSubtitles(subtitle: SubtitleData?): Boolean // returns true if the player requires a reload, null for nothing fun getCurrentPreferredSubtitle(): SubtitleData? + fun setSecondarySubtitles(subtitle: SubtitleData?): Boolean // returns true if the player requires a reload + fun getCurrentSecondarySubtitle(): SubtitleData? + fun isDualSubtitleCombinationSupported(primary: SubtitleData?, secondary: SubtitleData?): Boolean fun handleEvent(event: CSPlayerEvent, source: PlayerEventSource = PlayerEventSource.UI) @@ -311,4 +314,4 @@ interface IPlayer { /** Get the current subtitle cues, for use with syncing */ fun getSubtitleCues(): List -} \ No newline at end of file +} 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 5f716cca3f1..aeb671e8476 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 @@ -59,6 +59,7 @@ import java.io.File const val SUBTITLE_KEY = "subtitle_settings" const val SUBTITLE_AUTO_SELECT_KEY = "subs_auto_select" const val SUBTITLE_DOWNLOAD_KEY = "subs_auto_download" +const val SUBTITLE_DUAL_ENABLED_KEY = "subs_dual_enabled" data class SaveCaptionStyle( @JsonProperty("foregroundColor") var foregroundColor: Int, @@ -300,6 +301,10 @@ class SubtitlesFragment : BaseDialogFragment( fun getAutoSelectLanguageTagIETF(): String { return getKey(SUBTITLE_AUTO_SELECT_KEY) ?: "en" } + + fun isDualSubtitlesEnabled(): Boolean { + return getKey(SUBTITLE_DUAL_ENABLED_KEY) ?: false + } } private fun onColorSelected(stuff: Pair) { @@ -592,6 +597,10 @@ class SubtitlesFragment : BaseDialogFragment( subtitlesRemoveBloat.setOnCheckedChangeListener { _, b -> state.removeBloat = b } + subtitlesDualEnabled.isChecked = isDualSubtitlesEnabled() + subtitlesDualEnabled.setOnCheckedChangeListener { _, isChecked -> + setKey(SUBTITLE_DUAL_ENABLED_KEY, isChecked) + } subtitlesUppercase.isChecked = state.upperCase subtitlesUppercase.setOnCheckedChangeListener { _, b -> state.upperCase = b diff --git a/app/src/main/res/color/subtitle_tab_text_selector.xml b/app/src/main/res/color/subtitle_tab_text_selector.xml new file mode 100644 index 00000000000..7d42e9189c7 --- /dev/null +++ b/app/src/main/res/color/subtitle_tab_text_selector.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/layout-port/player_select_source_and_subs.xml b/app/src/main/res/layout-port/player_select_source_and_subs.xml index 4710473d4fa..b91e2693d4e 100644 --- a/app/src/main/res/layout-port/player_select_source_and_subs.xml +++ b/app/src/main/res/layout-port/player_select_source_and_subs.xml @@ -81,29 +81,105 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" - android:orientation="horizontal" - android:gravity="center_vertical"> + android:orientation="vertical"> - - - + + + + + + + + android:orientation="vertical" + android:paddingTop="4dp"> + + + + + + + + + + + + + + + + + + @@ -113,7 +113,8 @@ android:id="@+id/subtitles_encoding_format" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_centerVertical="true" + android:layout_alignTop="@id/subtitles_text" + android:layout_alignBottom="@id/subtitles_text" android:layout_gravity="center" android:layout_toStartOf="@+id/subtitle_settings_btt" android:layout_toEndOf="@id/subtitles_text" @@ -127,12 +128,88 @@ android:textSize="15sp" tools:text="Thai (TIS 620-2533/ISO 8859-11)" /> + + + + + + + + + + + + + + + + + + + + + + Not downloaded: %d Updated %d plugins CloudStream has no sites installed by default. You need to install the sites from repositories. -\n + \nJoin our Discord or search online. View community repositories Public list @@ -779,4 +779,11 @@ %d download queued %d downloads queued + +Dual subtitles (experimental) + Primary + Secondary + Secondary subtitles do not support embedded tracks yet + This subtitle combination is not supported for dual subtitles + Failed to load dual subtitles diff --git a/app/src/test/java/com/lagradost/cloudstream3/DualSubtitleComposerTest.kt b/app/src/test/java/com/lagradost/cloudstream3/DualSubtitleComposerTest.kt new file mode 100644 index 00000000000..bd7e9b70534 --- /dev/null +++ b/app/src/test/java/com/lagradost/cloudstream3/DualSubtitleComposerTest.kt @@ -0,0 +1,136 @@ +package com.lagradost.cloudstream3 + +import com.lagradost.cloudstream3.ui.player.DualSubtitleComposer +import com.lagradost.cloudstream3.ui.player.DualSubtitleSegment +import com.lagradost.cloudstream3.ui.player.SubtitleCue +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class DualSubtitleComposerTest { + @Test + fun `compose merges overlapping cues into secondary plus primary order`() { + val primary = listOf( + SubtitleCue(startTimeMs = 1000, durationMs = 2000, text = listOf("Primary A")), + SubtitleCue(startTimeMs = 3000, durationMs = 2000, text = listOf("Primary B")) + ) + val secondary = listOf( + SubtitleCue(startTimeMs = 2000, durationMs = 2000, text = listOf("Secondary A")) + ) + + val result = DualSubtitleComposer.compose(primary, secondary) + assertEquals(4, result.size) + + assertEquals(1000, result[0].startTimeMs) + assertEquals(2000, result[0].endTimeMs) + assertEquals("Primary A", result[0].text) + + assertEquals(2000, result[1].startTimeMs) + assertEquals(3000, result[1].endTimeMs) + assertEquals("Secondary A\nPrimary A", result[1].text) + + assertEquals(3000, result[2].startTimeMs) + assertEquals(4000, result[2].endTimeMs) + assertEquals("Secondary A\nPrimary B", result[2].text) + + assertEquals(4000, result[3].startTimeMs) + assertEquals(5000, result[3].endTimeMs) + assertEquals("Primary B", result[3].text) + } + + @Test + fun `compose keeps short cues by using exact boundaries`() { + val primary = listOf( + SubtitleCue(startTimeMs = 1000, durationMs = 5000, text = listOf("Long Primary")) + ) + val secondary = listOf( + SubtitleCue(startTimeMs = 1234, durationMs = 80, text = listOf("Short Secondary")) + ) + + val result = DualSubtitleComposer.compose(primary, secondary) + assertTrue(result.any { it.startTimeMs == 1234L && it.endTimeMs == 1314L }) + assertTrue(result.any { it.text == "Short Secondary\nLong Primary" }) + } + + @Test + fun `compose merges adjacent segments with same text`() { + val primary = listOf( + SubtitleCue(startTimeMs = 1000, durationMs = 1000, text = listOf("Same")), + SubtitleCue(startTimeMs = 2000, durationMs = 1000, text = listOf("Same")) + ) + + val result = DualSubtitleComposer.compose(primary, emptyList()) + assertEquals(1, result.size) + assertEquals(1000, result[0].startTimeMs) + assertEquals(3000, result[0].endTimeMs) + assertEquals("Same", result[0].text) + } + + @Test + fun `compose returns empty list for empty or invalid cues`() { + assertTrue(DualSubtitleComposer.compose(emptyList(), emptyList()).isEmpty()) + + val invalid = listOf( + SubtitleCue(startTimeMs = 1000, durationMs = 0, text = listOf("Invalid")) + ) + assertTrue(DualSubtitleComposer.compose(invalid, emptyList()).isEmpty()) + } + + @Test + fun `toWebVtt produces valid WebVTT header`() { + val segments = listOf( + DualSubtitleSegment(startTimeMs = 1000, endTimeMs = 2000, text = "Hello") + ) + val output = DualSubtitleComposer.toWebVtt(segments) + assertTrue("WebVTT output must start with WEBVTT header", output.startsWith("WEBVTT")) + } + + @Test + fun `toWebVtt produces valid timestamp format`() { + val segments = listOf( + DualSubtitleSegment(startTimeMs = 3661234, endTimeMs = 3665678, text = "Test") + ) + val output = DualSubtitleComposer.toWebVtt(segments) + // Timestamp format: HH:MM:SS.mmm + assertTrue( + "WebVTT output must contain properly formatted timestamps", + output.contains("01:01:01.234 --> 01:01:05.678") + ) + } + + @Test + fun `toWebVtt returns header only for empty segments`() { + val output = DualSubtitleComposer.toWebVtt(emptyList()) + assertTrue(output.startsWith("WEBVTT")) + assertFalse(output.contains("-->")) + } + + @Test + fun `compose handles primary-only cues correctly`() { + val primary = listOf( + SubtitleCue(startTimeMs = 0, durationMs = 1000, text = listOf("Only Primary")) + ) + val result = DualSubtitleComposer.compose(primary, emptyList()) + assertEquals(1, result.size) + assertEquals("Only Primary", result[0].text) + } + + @Test + fun `compose handles secondary-only cues correctly`() { + val secondary = listOf( + SubtitleCue(startTimeMs = 0, durationMs = 1000, text = listOf("Only Secondary")) + ) + val result = DualSubtitleComposer.compose(emptyList(), secondary) + assertEquals(1, result.size) + assertEquals("Only Secondary", result[0].text) + } + + @Test + fun `compose handles negative duration cues`() { + val invalid = listOf( + SubtitleCue(startTimeMs = 2000, durationMs = -500, text = listOf("Negative")) + ) + assertTrue(DualSubtitleComposer.compose(invalid, emptyList()).isEmpty()) + } +}