Added setting for persisting subtitles across multiple videos when the same language exists.

This commit is contained in:
Koen J
2025-11-05 15:02:33 +01:00
parent e2ef8c2593
commit 9b68394f70
11 changed files with 81 additions and 3 deletions
@@ -408,6 +408,10 @@ class Settings : FragmentedStorageFileJson() {
else -> null 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) @FormField(R.string.prefer_original_audio, FieldForm.TOGGLE, R.string.prefer_original_audio_description, -1)
var preferOriginalAudio: Boolean = true; var preferOriginalAudio: Boolean = true;
@@ -41,6 +41,7 @@ class HLSVariantSubtitleUrlSource(
override val format: String, override val format: String,
) : ISubtitleSource { ) : ISubtitleSource {
override val hasFetch: Boolean = false override val hasFetch: Boolean = false
override val language: String? = null
override fun getSubtitles(): String? { override fun getSubtitles(): String? {
return null return null
@@ -9,13 +9,15 @@ class LocalSubtitleSource : ISubtitleSource {
override val name: String; override val name: String;
override val url: String?; override val url: String?;
override val format: String?; override val format: String?;
override val language: String?
override val hasFetch: Boolean get() = false; override val hasFetch: Boolean get() = false;
val filePath: String; val filePath: String;
constructor(name: String, format: String?, filePath: String) { constructor(name: String, language: String?, format: String?, filePath: String) {
this.name = name; this.name = name;
this.format = format; this.format = format;
this.language = language
this.filePath = filePath; this.filePath = filePath;
this.url = Uri.fromFile(File(filePath)).toString(); this.url = Uri.fromFile(File(filePath)).toString();
} }
@@ -32,6 +34,7 @@ class LocalSubtitleSource : ISubtitleSource {
fun fromSource(source: SubtitleRawSource, path: String): LocalSubtitleSource { fun fromSource(source: SubtitleRawSource, path: String): LocalSubtitleSource {
return LocalSubtitleSource( return LocalSubtitleSource(
source.name, source.name,
source.language,
source.format, source.format,
path path
); );
@@ -6,6 +6,7 @@ import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
@kotlinx.serialization.Serializable @kotlinx.serialization.Serializable
class SubtitleRawSource( class SubtitleRawSource(
override val name: String, override val name: String,
override val language: String?,
override val format: String?, override val format: String?,
val _subtitles: String, val _subtitles: String,
override val url: String? = null, override val url: String? = null,
@@ -7,6 +7,7 @@ interface ISubtitleSource {
val url: String?; val url: String?;
val format: String?; val format: String?;
val hasFetch: Boolean; val hasFetch: Boolean;
val language: String?
fun getSubtitles(): String?; fun getSubtitles(): String?;
@@ -22,6 +22,7 @@ class JSSubtitleSource : ISubtitleSource {
override val name: String; override val name: String;
override val url: String?; override val url: String?;
override val format: String?; override val format: String?;
override val language: String?
override val hasFetch: Boolean; override val hasFetch: Boolean;
constructor(config: SourcePluginConfig, v8Value: V8ValueObject) { constructor(config: SourcePluginConfig, v8Value: V8ValueObject) {
@@ -29,6 +30,7 @@ class JSSubtitleSource : ISubtitleSource {
val context = "JSSubtitles"; val context = "JSSubtitles";
name = v8Value.getOrThrow(config, "name", context, false); name = v8Value.getOrThrow(config, "name", context, false);
language = v8Value.getOrThrow(config, "language", context, false);
url = v8Value.getOrThrow(config, "url", context, true); url = v8Value.getOrThrow(config, "url", context, true);
format = v8Value.getOrThrow(config, "format", context, true); format = v8Value.getOrThrow(config, "format", context, true);
hasFetch = v8Value.has("getSubtitles"); hasFetch = v8Value.has("getSubtitles");
@@ -357,6 +357,7 @@ class VideoDetailView : ConstraintLayout {
Pair(-5 * 60, 30), //around 5 minutes, try every 30 seconds Pair(-5 * 60, 30), //around 5 minutes, try every 30 seconds
Pair(0, 10) //around live, try every 10 seconds Pair(0, 10) //around live, try every 10 seconds
); );
private var _subtitleLanguage: String? = null
@androidx.annotation.OptIn(UnstableApi::class) @androidx.annotation.OptIn(UnstableApi::class)
constructor(context: Context, attrs : AttributeSet? = null) : super(context, attrs) { constructor(context: Context, attrs : AttributeSet? = null) : super(context, attrs) {
@@ -1975,7 +1976,7 @@ class VideoDetailView : ConstraintLayout {
try { try {
val videoSource = _lastVideoSource ?: _player.getPreferredVideoSource(video, Settings.instance.playback.getCurrentPreferredQualityPixelCount()); val videoSource = _lastVideoSource ?: _player.getPreferredVideoSource(video, Settings.instance.playback.getCurrentPreferredQualityPixelCount());
val audioSource = _lastAudioSource ?: _player.getPreferredAudioSource(video, Settings.instance.playback.getPrimaryLanguage(context)); 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)") Logger.i(TAG, "loadCurrentVideo(videoSource=$videoSource, audioSource=$audioSource, subtitleSource=$subtitleSource, resumePositionMs=$resumePositionMs)")
if(videoSource == null && audioSource == null) { if(videoSource == null && audioSource == null) {
@@ -2659,6 +2660,7 @@ class VideoDetailView : ConstraintLayout {
} }
} }
_lastSubtitleSource = toSet; _lastSubtitleSource = toSet;
_subtitleLanguage = toSet?.language
} }
private fun handleUnavailableVideo(msg: String? = null) { private fun handleUnavailableVideo(msg: String? = null) {
@@ -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.IVideoSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource 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.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.models.video.IPlatformVideoDetails
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSAudioUrlRangeSource import com.futo.platformplayer.api.media.platforms.js.models.sources.JSAudioUrlRangeSource
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawAudioSource import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawAudioSource
@@ -134,6 +136,62 @@ class VideoHelper {
return bestSource; return bestSource;
} }
fun selectBestSubtitleSource(sources: Iterable<ISubtitleSource>, 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<Int, Int, String, String>? = 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<A : Comparable<A>, B : Comparable<B>, C : Comparable<C>, D : Comparable<D>>(
val a: A, val b: B, val c: C, val d: D
) : Comparable<Quad<A, B, C, D>> {
override fun compareTo(other: Quad<A, B, C, D>): 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) @OptIn(UnstableApi::class)
fun convertItagSourceToChunkedDashSource(videoSource: JSVideoUrlRangeSource) : Pair<MediaSource, String> { fun convertItagSourceToChunkedDashSource(videoSource: JSVideoUrlRangeSource) : Pair<MediaSource, String> {
val urlToUse = videoSource.getVideoUrl(); val urlToUse = videoSource.getVideoUrl();
@@ -439,7 +439,7 @@ class StateDownloads {
} else { } else {
throw NotImplementedError("Unsuported scheme"); 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<Int, Long> { fun cleanupDownloads(): Pair<Int, Long> {
@@ -914,6 +914,10 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
return VideoHelper.selectBestAudioSource(video.video, PREFERED_AUDIO_CONTAINERS, preferredLanguage); 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) @OptIn(UnstableApi::class)
private fun loadSelectedSources(play: Boolean, resume: Boolean): Boolean { private fun loadSelectedSources(play: Boolean, resume: Boolean): Boolean {
val sourceVideo = if(!isAudioMode || _lastAudioMediaSource == null) _lastVideoMediaSource else null; val sourceVideo = if(!isAudioMode || _lastAudioMediaSource == null) _lastVideoMediaSource else null;
+2
View File
@@ -505,6 +505,8 @@
<string name="preferred_preview_quality">Preferred Preview Quality</string> <string name="preferred_preview_quality">Preferred Preview Quality</string>
<string name="preferred_preview_quality_description">Default quality while previewing a video in a feed</string> <string name="preferred_preview_quality_description">Default quality while previewing a video in a feed</string>
<string name="primary_language">Primary Language</string> <string name="primary_language">Primary Language</string>
<string name="sticky_subtitles">Persist Subtitles</string>
<string name="sticky_subtitles_description">Once a subtitle language is selected, search for a best match once a new video is played</string>
<string name="prefer_original_audio">Prefer Original Audio</string> <string name="prefer_original_audio">Prefer Original Audio</string>
<string name="prefer_original_audio_description">Use original audio instead of preferred language when it is known</string> <string name="prefer_original_audio_description">Use original audio instead of preferred language when it is known</string>
<string name="default_comment_section">Default Comment Section</string> <string name="default_comment_section">Default Comment Section</string>