diff --git a/app/src/main/java/com/futo/platformplayer/Settings.kt b/app/src/main/java/com/futo/platformplayer/Settings.kt index d67a531a..8315fa15 100644 --- a/app/src/main/java/com/futo/platformplayer/Settings.kt +++ b/app/src/main/java/com/futo/platformplayer/Settings.kt @@ -408,6 +408,10 @@ class Settings : FragmentedStorageFileJson() { else -> null } } + + @FormField(R.string.sticky_subtitles, FieldForm.TOGGLE, R.string.sticky_subtitles_description, -1) + var stickySubtitles: Boolean = true; + @FormField(R.string.prefer_original_audio, FieldForm.TOGGLE, R.string.prefer_original_audio_description, -1) var preferOriginalAudio: Boolean = true; diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/HLSVariantUrlSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/HLSVariantUrlSource.kt index 854cf9b8..858c53ca 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/HLSVariantUrlSource.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/HLSVariantUrlSource.kt @@ -41,6 +41,7 @@ class HLSVariantSubtitleUrlSource( override val format: String, ) : ISubtitleSource { override val hasFetch: Boolean = false + override val language: String? = null override fun getSubtitles(): String? { return null diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/LocalSubtitleSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/LocalSubtitleSource.kt index 0c866af4..4508dd66 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/LocalSubtitleSource.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/LocalSubtitleSource.kt @@ -9,13 +9,15 @@ class LocalSubtitleSource : ISubtitleSource { override val name: String; override val url: String?; override val format: String?; + override val language: String? override val hasFetch: Boolean get() = false; val filePath: String; - constructor(name: String, format: String?, filePath: String) { + constructor(name: String, language: String?, format: String?, filePath: String) { this.name = name; this.format = format; + this.language = language this.filePath = filePath; this.url = Uri.fromFile(File(filePath)).toString(); } @@ -32,6 +34,7 @@ class LocalSubtitleSource : ISubtitleSource { fun fromSource(source: SubtitleRawSource, path: String): LocalSubtitleSource { return LocalSubtitleSource( source.name, + source.language, source.format, path ); diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/SubtitleRawSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/SubtitleRawSource.kt index f4dc29a2..69b87d2d 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/SubtitleRawSource.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/SubtitleRawSource.kt @@ -6,6 +6,7 @@ import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource @kotlinx.serialization.Serializable class SubtitleRawSource( override val name: String, + override val language: String?, override val format: String?, val _subtitles: String, override val url: String? = null, diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/subtitles/ISubtitleSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/subtitles/ISubtitleSource.kt index fd6e423f..0ab87756 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/subtitles/ISubtitleSource.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/subtitles/ISubtitleSource.kt @@ -7,6 +7,7 @@ interface ISubtitleSource { val url: String?; val format: String?; val hasFetch: Boolean; + val language: String? fun getSubtitles(): String?; diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSSubtitleSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSSubtitleSource.kt index 74843d22..34bd4e43 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSSubtitleSource.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSSubtitleSource.kt @@ -22,6 +22,7 @@ class JSSubtitleSource : ISubtitleSource { override val name: String; override val url: String?; override val format: String?; + override val language: String? override val hasFetch: Boolean; constructor(config: SourcePluginConfig, v8Value: V8ValueObject) { @@ -29,6 +30,7 @@ class JSSubtitleSource : ISubtitleSource { val context = "JSSubtitles"; name = v8Value.getOrThrow(config, "name", context, false); + language = v8Value.getOrThrow(config, "language", context, false); url = v8Value.getOrThrow(config, "url", context, true); format = v8Value.getOrThrow(config, "format", context, true); hasFetch = v8Value.has("getSubtitles"); diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt index 0075109a..8f4deb7a 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt @@ -357,6 +357,7 @@ class VideoDetailView : ConstraintLayout { Pair(-5 * 60, 30), //around 5 minutes, try every 30 seconds Pair(0, 10) //around live, try every 10 seconds ); + private var _subtitleLanguage: String? = null @androidx.annotation.OptIn(UnstableApi::class) constructor(context: Context, attrs : AttributeSet? = null) : super(context, attrs) { @@ -1975,7 +1976,7 @@ class VideoDetailView : ConstraintLayout { try { val videoSource = _lastVideoSource ?: _player.getPreferredVideoSource(video, Settings.instance.playback.getCurrentPreferredQualityPixelCount()); val audioSource = _lastAudioSource ?: _player.getPreferredAudioSource(video, Settings.instance.playback.getPrimaryLanguage(context)); - val subtitleSource = _lastSubtitleSource ?: (if(video is VideoLocal) video.subtitlesSources.firstOrNull() else null); + val subtitleSource = _lastSubtitleSource ?: (if (Settings.instance.playback.stickySubtitles) _player.getPreferredSubtitleSource(video, _subtitleLanguage) else null) ?: (if(video is VideoLocal) video.subtitlesSources.firstOrNull() else null); Logger.i(TAG, "loadCurrentVideo(videoSource=$videoSource, audioSource=$audioSource, subtitleSource=$subtitleSource, resumePositionMs=$resumePositionMs)") if(videoSource == null && audioSource == null) { @@ -2659,6 +2660,7 @@ class VideoDetailView : ConstraintLayout { } } _lastSubtitleSource = toSet; + _subtitleLanguage = toSet?.language } private fun handleUnavailableVideo(msg: String? = null) { diff --git a/app/src/main/java/com/futo/platformplayer/helpers/VideoHelper.kt b/app/src/main/java/com/futo/platformplayer/helpers/VideoHelper.kt index cad49efd..9c65221e 100644 --- a/app/src/main/java/com/futo/platformplayer/helpers/VideoHelper.kt +++ b/app/src/main/java/com/futo/platformplayer/helpers/VideoHelper.kt @@ -19,6 +19,8 @@ import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSour import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource import com.futo.platformplayer.api.media.models.streams.sources.IWidevineSource +import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource +import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails import com.futo.platformplayer.api.media.platforms.js.models.sources.JSAudioUrlRangeSource import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawAudioSource @@ -134,6 +136,62 @@ class VideoHelper { return bestSource; } + fun selectBestSubtitleSource(sources: Iterable, preferredLanguage: String?): ISubtitleSource? { + if (preferredLanguage.isNullOrBlank()) return null + + val prefTag = normalizeTag(preferredLanguage) + val prefPrimary = primarySubtag(prefTag) ?: return null + + var best: ISubtitleSource? = null + var bestKey: Quad? = null + + for (src in sources) { + val raw = src.language ?: continue + val tag = normalizeTag(raw) + val primary = primarySubtag(tag) ?: continue + + val score = when { + tag.equals(prefTag, ignoreCase = true) -> 0 + primary.equals(prefPrimary, ignoreCase = true) && findRegion(tag) == null -> 1 + primary.equals(prefPrimary, ignoreCase = true) -> 2 + else -> 3 + } + if (score >= 3) continue + + val key = Quad(score, src.name.length, tag.lowercase(), src.name) + if (bestKey == null || key < bestKey!!) { + bestKey = key + best = src + } + } + return best + } + + private fun normalizeTag(tag: String): String = tag.trim().replace('_', '-') + private fun primarySubtag(tag: String): String? = tag.split('-').firstOrNull { it.isNotBlank() }?.lowercase() + + private fun findRegion(tag: String): String? { + val parts = tag.split('-').drop(1) // skip primary language + for (p in parts) { + val isAlpha2 = p.length == 2 && p[0].isLetter() && p[1].isLetter() + val isNumeric3 = p.length == 3 && p.all { it.isDigit() } + if (isAlpha2 || isNumeric3) return p.uppercase() + } + return null + } + + private data class Quad, B : Comparable, C : Comparable, D : Comparable>( + val a: A, val b: B, val c: C, val d: D + ) : Comparable> { + override fun compareTo(other: Quad): Int = + when { + a != other.a -> a.compareTo(other.a) + b != other.b -> b.compareTo(other.b) + c != other.c -> c.compareTo(other.c) + else -> d.compareTo(other.d) + } + } + @OptIn(UnstableApi::class) fun convertItagSourceToChunkedDashSource(videoSource: JSVideoUrlRangeSource) : Pair { val urlToUse = videoSource.getVideoUrl(); diff --git a/app/src/main/java/com/futo/platformplayer/states/StateDownloads.kt b/app/src/main/java/com/futo/platformplayer/states/StateDownloads.kt index 42ff55f4..32cc835d 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateDownloads.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateDownloads.kt @@ -439,7 +439,7 @@ class StateDownloads { } else { throw NotImplementedError("Unsuported scheme"); } - return if (subtitles != null) SubtitleRawSource(subtitle.name, subtitle.format, subtitles!!) else null; + return if (subtitles != null) SubtitleRawSource(subtitle.name, subtitle.language, subtitle.format, subtitles!!) else null; } fun cleanupDownloads(): Pair { diff --git a/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt b/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt index 2c2cf4ee..fae32226 100644 --- a/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt +++ b/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt @@ -914,6 +914,10 @@ abstract class FutoVideoPlayerBase : RelativeLayout { return VideoHelper.selectBestAudioSource(video.video, PREFERED_AUDIO_CONTAINERS, preferredLanguage); } + fun getPreferredSubtitleSource(video: IPlatformVideoDetails, preferredLanguage: String?): ISubtitleSource? { + return VideoHelper.selectBestSubtitleSource(video.subtitles, preferredLanguage); + } + @OptIn(UnstableApi::class) private fun loadSelectedSources(play: Boolean, resume: Boolean): Boolean { val sourceVideo = if(!isAudioMode || _lastAudioMediaSource == null) _lastVideoMediaSource else null; diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 92c52cf4..c5468428 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -505,6 +505,8 @@ Preferred Preview Quality Default quality while previewing a video in a feed Primary Language + Persist Subtitles + Once a subtitle language is selected, search for a best match once a new video is played Prefer Original Audio Use original audio instead of preferred language when it is known Default Comment Section