Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
224 changes: 223 additions & 1 deletion app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -292,6 +296,9 @@ class CS3IPlayer : IPlayer {
saveData()
} else {
currentSubtitles = subtitle
currentSecondarySubtitles = null
dualMergedTrackId = null
cleanDualSubtitleCache(context)
playbackPosition = 0
}

Expand Down Expand Up @@ -334,9 +341,19 @@ class CS3IPlayer : IPlayer {
override fun setActiveSubtitles(subtitles: Set<SubtitleData>) {
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<Tracks.Group>.getTrack(id: String?): Pair<TrackGroup, Int>? {
if (id == null) return null
Expand Down Expand Up @@ -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
Expand All @@ -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 -> {
Expand Down Expand Up @@ -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<Boolean>(SUBTITLE_DUAL_ENABLED_KEY) ?: false
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WET code, use the function you defined

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) {
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1635,6 +1706,7 @@ class CS3IPlayer : IPlayer {
val offlineSourceFactory = context.createOfflineSource()

val (subSources, activeSubtitles) = getSubSources(
context = context,
offlineSourceFactory = offlineSourceFactory,
subHelper = subtitleHelper,
interceptor = null,
Expand All @@ -1649,10 +1721,46 @@ class CS3IPlayer : IPlayer {
}

private fun getSubSources(
context: Context,
offlineSourceFactory: DataSource.Factory?,
subHelper: PlayerSubtitleHelper,
interceptor: Interceptor?,
): Pair<List<SingleSampleMediaSource>, List<SubtitleData>> {
val dualEnabled = getKey<Boolean>(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<SubtitleData>()
val subSources = subHelper.getAllSubtitles().mapNotNull { sub ->
val subConfig = MediaItem.SubtitleConfiguration.Builder(sub.getFixedUrl().toUri())
Expand Down Expand Up @@ -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<SingleSampleMediaSource, SubtitleData>? {
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<SubtitleCue>? {
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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1952,4 +2175,3 @@ class CS3IPlayer : IPlayer {
}

}

Loading