mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2026-05-16 13:02:39 +02:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c84cea9ea1 | |||
| 5c162083d5 | |||
| 3230e7c0b4 | |||
| 8437825dd1 | |||
| 0fbe0bb438 |
@@ -387,7 +387,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
@DropdownFieldOptionsId(R.array.audio_languages)
|
||||
var primaryLanguage: Int = 0;
|
||||
|
||||
fun getPrimaryLanguage(context: Context): String? {
|
||||
fun getPrimaryLanguage(context: Context? = null): String? {
|
||||
return when(primaryLanguage) {
|
||||
0 -> "en";
|
||||
1 -> "es";
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.OptIn
|
||||
@@ -74,6 +75,8 @@ import kotlinx.coroutines.withContext
|
||||
import java.io.ByteArrayInputStream
|
||||
import androidx.core.net.toUri
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.SettingsFragment
|
||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuButtonList
|
||||
import kotlin.collections.toList
|
||||
|
||||
class UISlideOverlays {
|
||||
companion object {
|
||||
@@ -573,6 +576,51 @@ class UISlideOverlays {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
val allLanguages = videoSources?.map { it.language }?.distinct() ?: listOf();
|
||||
val langResCombinations = if(videoSources != null) allLanguages.flatMap {
|
||||
lang -> videoSources
|
||||
.filter { v -> v.language == lang }
|
||||
.map { it.height * it.width }
|
||||
.distinct()
|
||||
.map { res -> Pair(res, lang) }
|
||||
} else listOf();
|
||||
var videoSourceItems = mutableListOf<SlideUpMenuItem>();
|
||||
var selectedLanguage: String? = null;
|
||||
val languageFilters = if(allLanguages.filter { it != null }.count() > 1)
|
||||
SlideUpMenuButtonList(container.context, null, "language_filter", true).apply {
|
||||
var languageFilterLabels = allLanguages.filterNotNull().toList();
|
||||
val english = languageFilterLabels.find { it?.lowercase() == "en" };
|
||||
val originalLanguage = videoSources?.find { it.original == true }?.language;
|
||||
val primaryLanguage = Settings.instance.playback.getPrimaryLanguage();
|
||||
val hasPrimaryLanguage = videoSources?.any { it.language == primaryLanguage } ?: false;
|
||||
|
||||
if(english != null)
|
||||
languageFilterLabels = listOf(english).plus(languageFilterLabels.filter { it != english }).toList();
|
||||
if(primaryLanguage != null && languageFilterLabels.contains(primaryLanguage))
|
||||
languageFilterLabels = listOf(primaryLanguage).plus(languageFilterLabels.filter { it != primaryLanguage }).toList();
|
||||
if(originalLanguage != null)
|
||||
languageFilterLabels = listOf(originalLanguage).plus(languageFilterLabels.filter { it != originalLanguage }).toList();
|
||||
Log.i(TAG, "Language filtesr: ${languageFilterLabels.joinToString(", ")}");
|
||||
selectedLanguage = originalLanguage ?: (if(hasPrimaryLanguage) primaryLanguage else null);
|
||||
setButtons(languageFilterLabels, selectedLanguage);
|
||||
onClick.subscribe { selected ->
|
||||
setSelected(selected);
|
||||
|
||||
videoSourceItems.forEach {
|
||||
val item = it.itemTag;
|
||||
if(item is IVideoSource) {
|
||||
if(item.language == selected)
|
||||
it.visibility = View.VISIBLE;
|
||||
else
|
||||
it.visibility = View.GONE;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else null;
|
||||
|
||||
if(languageFilters != null) items.add(languageFilters)
|
||||
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.video), videoSources,
|
||||
listOf((if (audioSources != null) listOf(SlideUpMenuItem(
|
||||
container.context,
|
||||
@@ -609,7 +657,13 @@ class UISlideOverlays {
|
||||
menu?.setOk(container.context.getString(R.string.download));
|
||||
},
|
||||
invokeParent = false
|
||||
)
|
||||
).apply {
|
||||
videoSourceItems.add(this);
|
||||
if(selectedLanguage != null) {
|
||||
if(it.language != selectedLanguage)
|
||||
this.visibility = View.GONE;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is JSDashManifestRawSource -> {
|
||||
@@ -629,7 +683,13 @@ class UISlideOverlays {
|
||||
menu?.setOk(container.context.getString(R.string.download));
|
||||
},
|
||||
invokeParent = false
|
||||
)
|
||||
).apply {
|
||||
videoSourceItems.add(this);
|
||||
if(selectedLanguage != null) {
|
||||
if(it.language != selectedLanguage)
|
||||
this.visibility = View.GONE;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is IHLSManifestSource -> {
|
||||
@@ -643,7 +703,13 @@ class UISlideOverlays {
|
||||
showHlsPicker(video, it, it.url, container)
|
||||
},
|
||||
invokeParent = false
|
||||
)
|
||||
).apply {
|
||||
videoSourceItems.add(this);
|
||||
if(selectedLanguage != null) {
|
||||
if(it.language != selectedLanguage)
|
||||
this.visibility = View.GONE;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
|
||||
@@ -1241,6 +1241,47 @@ abstract class StateCasting {
|
||||
return "http://${address.toUrlAddress().trim('/')}:${_castServer.port}";
|
||||
}
|
||||
|
||||
private fun escapeXml(s: String): String =
|
||||
s.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
.replace("'", "'")
|
||||
|
||||
private fun injectSubtitleAdaptationSet(
|
||||
mpd: String,
|
||||
subtitleUrl: String,
|
||||
mimeType: String,
|
||||
lang: String = "und",
|
||||
label: String = "Subtitles"
|
||||
): String {
|
||||
val mt = mimeType.lowercase()
|
||||
val codecs = when (mt) {
|
||||
"text/vtt", "text/webvtt" -> "wvtt"
|
||||
"application/ttml+xml", "application/ttml" -> "stpp"
|
||||
else -> null
|
||||
}
|
||||
val codecsAttr = codecs?.let { " codecs=\"${escapeXml(it)}\"" } ?: ""
|
||||
|
||||
val adaptation = """
|
||||
<AdaptationSet id="subtitles-1" contentType="text" mimeType="${escapeXml(mimeType)}" lang="${escapeXml(lang)}">
|
||||
<Role schemeIdUri="urn:mpeg:dash:role:2011" value="subtitle"/>
|
||||
<Label>${escapeXml(label)}</Label>
|
||||
<Representation id="subtitles-1"$codecsAttr bandwidth="256" mimeType="${escapeXml(mimeType)}">
|
||||
<BaseURL>${escapeXml(subtitleUrl)}</BaseURL>
|
||||
</Representation>
|
||||
</AdaptationSet>
|
||||
""".trimIndent()
|
||||
|
||||
val periodClose = Regex("</Period\\s*>", RegexOption.IGNORE_CASE)
|
||||
|
||||
return if (periodClose.containsMatchIn(mpd)) {
|
||||
mpd.replaceFirst(periodClose, adaptation + "\n</Period>")
|
||||
} else {
|
||||
mpd
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
private suspend fun castDashRaw(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: JSDashManifestRawSource?, audioSource: JSDashManifestRawAudioSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?, castId: Int, onLoadingEstimate: ((Int) -> Unit)? = null, onLoading: ((Boolean) -> Unit)? = null) : List<String> {
|
||||
val ad = activeDevice ?: return listOf();
|
||||
@@ -1262,30 +1303,42 @@ abstract class StateCasting {
|
||||
val videoUrl = url + videoPath
|
||||
val audioUrl = url + audioPath
|
||||
|
||||
val subtitleMimeTypeFull = subtitleSource?.format ?: "text/vtt"
|
||||
val subtitleMimeTypeForMpd = subtitleMimeTypeFull.substringBefore(';').trim()
|
||||
|
||||
val subtitlesUri = if (subtitleSource != null) withContext(Dispatchers.IO) {
|
||||
return@withContext subtitleSource.getSubtitlesURI();
|
||||
} else null;
|
||||
subtitleSource.getSubtitlesURI()
|
||||
} else null
|
||||
|
||||
var subtitlesUrl: String? = null;
|
||||
var subtitlesUrl: String? = null
|
||||
if (subtitlesUri != null) {
|
||||
if(subtitlesUri.scheme == "file") {
|
||||
var content: String? = null;
|
||||
val inputStream = contentResolver.openInputStream(subtitlesUri);
|
||||
inputStream?.use { stream ->
|
||||
val reader = stream.bufferedReader();
|
||||
content = reader.use { it.readText() };
|
||||
when (subtitlesUri.scheme) {
|
||||
"file", "content" -> {
|
||||
val content = withContext(Dispatchers.IO) {
|
||||
contentResolver.openInputStream(subtitlesUri)?.use { stream ->
|
||||
stream.bufferedReader().use { it.readText() }
|
||||
}
|
||||
}
|
||||
|
||||
if (!content.isNullOrEmpty()) {
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpConstantHandler("GET", subtitlePath, content, subtitleMimeTypeFull)
|
||||
.withHeader("Access-Control-Allow-Origin", "*"),
|
||||
true
|
||||
).withTag("castDashRaw")
|
||||
|
||||
subtitlesUrl = url + subtitlePath
|
||||
}
|
||||
}
|
||||
|
||||
if (content != null) {
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpConstantHandler("GET", subtitlePath, content!!, subtitleSource?.format ?: "text/vtt")
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("cast");
|
||||
"http", "https" -> {
|
||||
// Receiver will fetch directly (works only if it doesn’t need auth/headers)
|
||||
subtitlesUrl = subtitlesUri.toString()
|
||||
}
|
||||
|
||||
subtitlesUrl = url + subtitlePath;
|
||||
} else {
|
||||
subtitlesUrl = subtitlesUri.toString();
|
||||
else -> {
|
||||
Logger.w(TAG, "Unsupported subtitlesUri scheme: ${subtitlesUri.scheme}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1331,6 +1384,14 @@ abstract class StateCasting {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
if (subtitlesUrl != null) {
|
||||
dashContent = injectSubtitleAdaptationSet(
|
||||
dashContent,
|
||||
subtitlesUrl!!,
|
||||
subtitleMimeTypeForMpd
|
||||
)
|
||||
}
|
||||
|
||||
var hasAudioInDash = false
|
||||
for (representation in representationRegex.findAll(dashContent)) {
|
||||
val mediaType = representation.groups[1]?.value ?: throw Exception("Media type should be found")
|
||||
|
||||
+50
-7
@@ -33,6 +33,7 @@ import android.widget.ImageButton
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.compose.ui.text.toLowerCase
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.media3.common.C
|
||||
@@ -2423,7 +2424,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
|
||||
val doDedup = Settings.instance.playback.simplifySources;
|
||||
|
||||
val allLanguages = videoSources?.map { it.language } ?: listOf();
|
||||
val allLanguages = videoSources?.map { it.language }?.distinct() ?: listOf();
|
||||
val langResCombinations = if(videoSources != null) allLanguages.flatMap {
|
||||
lang -> videoSources
|
||||
.filter { v -> v.language == lang }
|
||||
@@ -2432,6 +2433,43 @@ class VideoDetailView : ConstraintLayout {
|
||||
.map { res -> Pair(res, lang) }
|
||||
} else listOf();
|
||||
|
||||
|
||||
Log.i(TAG, "Language count: ${allLanguages}");
|
||||
var videoSourceItems = mutableListOf<SlideUpMenuItem>();
|
||||
var selectedLanguage: String? = null;
|
||||
val languageFilters = if(allLanguages.filter { it != null }.count() > 1)
|
||||
SlideUpMenuButtonList(this.context, null, "language_filter", true).apply {
|
||||
var languageFilterLabels = allLanguages.filterNotNull().toList();
|
||||
val english = languageFilterLabels.find { it?.lowercase() == "en" };
|
||||
val originalLanguage = videoSources?.find { it.original == true }?.language;
|
||||
val primaryLanguage = Settings.instance.playback.getPrimaryLanguage();
|
||||
val hasPrimaryLanguage = videoSources?.any { it.language == primaryLanguage } ?: false;
|
||||
|
||||
if(english != null)
|
||||
languageFilterLabels = listOf(english).plus(languageFilterLabels.filter { it != english }).toList();
|
||||
if(primaryLanguage != null && languageFilterLabels.contains(primaryLanguage))
|
||||
languageFilterLabels = listOf(primaryLanguage).plus(languageFilterLabels.filter { it != primaryLanguage }).toList();
|
||||
if(originalLanguage != null)
|
||||
languageFilterLabels = listOf(originalLanguage).plus(languageFilterLabels.filter { it != originalLanguage }).toList();
|
||||
Log.i(TAG, "Language filtesr: ${languageFilterLabels.joinToString(", ")}");
|
||||
selectedLanguage = originalLanguage ?: (if(hasPrimaryLanguage) primaryLanguage else null);
|
||||
setButtons(languageFilterLabels, selectedLanguage);
|
||||
onClick.subscribe { selected ->
|
||||
setSelected(selected);
|
||||
|
||||
videoSourceItems.forEach {
|
||||
val item = it.itemTag;
|
||||
if(item is IVideoSource) {
|
||||
if(item.language == selected)
|
||||
it.visibility = View.VISIBLE;
|
||||
else
|
||||
it.visibility = View.GONE;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else null;
|
||||
|
||||
val bestVideoSources = if(doDedup && videoSources != null) (langResCombinations
|
||||
?.map { comb -> VideoHelper.selectBestVideoSource(videoSources.filter { comb.first == it.height * it.width && comb.second == it.language }, -1, FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS) }
|
||||
?.plus(videoSources.filter { it is IHLSManifestSource || it is IDashManifestSource }))
|
||||
@@ -2449,7 +2487,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
val currentPlaybackRate = if (_isCasting) StateCasting.instance.activeDevice?.speed else _player.getPlaybackRate()
|
||||
val qualityPlaybackSpeedTitle = if (canSetSpeed) SlideUpMenuTitle(this.context).apply { setTitle(context.getString(R.string.playback_rate) + " (${String.format("%.2f", currentPlaybackRate)})"); } else null;
|
||||
_overlay_quality_selector = SlideUpMenuOverlay(this.context, _overlay_quality_container, context.getString(
|
||||
R.string.quality), null, true,
|
||||
R.string.quality), null, false,
|
||||
qualityPlaybackSpeedTitle,
|
||||
if (canSetSpeed) SlideUpMenuButtonList(this.context, null, "playback_rate").apply {
|
||||
val playbackSpeeds = Settings.instance.playback.getPlaybackSpeeds();
|
||||
@@ -2539,11 +2577,10 @@ class VideoDetailView : ConstraintLayout {
|
||||
call = { _player.selectAudioTrack(it.bitrate) });
|
||||
}.toList().toTypedArray())
|
||||
else null,
|
||||
|
||||
if(languageFilters != null) languageFilters else null,
|
||||
if(bestVideoSources.isNotEmpty())
|
||||
SlideUpMenuGroup(this.context, context.getString(R.string.video), "video",
|
||||
*bestVideoSources
|
||||
.map {
|
||||
(bestVideoSources.map {
|
||||
val estSize = VideoHelper.estimateSourceSize(it);
|
||||
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
|
||||
SlideUpMenuItem(this.context,
|
||||
@@ -2552,8 +2589,14 @@ class VideoDetailView : ConstraintLayout {
|
||||
if (it.width > 0 && it.height > 0) "${it.width}x${it.height}" else "",
|
||||
(prefix + it.codec.trim()).trim(),
|
||||
tag = it,
|
||||
call = { handleSelectVideoTrack(it) });
|
||||
}.toList().toTypedArray())
|
||||
call = { handleSelectVideoTrack(it) }).apply {
|
||||
videoSourceItems.add(this);
|
||||
if(selectedLanguage != null) {
|
||||
if(it.language != selectedLanguage)
|
||||
this.visibility = View.GONE;
|
||||
}
|
||||
};
|
||||
}).toList())
|
||||
else null,
|
||||
if(bestAudioSources.isNotEmpty())
|
||||
SlideUpMenuGroup(this.context, context.getString(R.string.audio), "audio",
|
||||
|
||||
+30
-6
@@ -11,6 +11,7 @@ import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.res.ResourcesCompat
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.dp
|
||||
|
||||
class SlideUpMenuButtonList : LinearLayout {
|
||||
private val _root: LinearLayout;
|
||||
@@ -20,10 +21,16 @@ class SlideUpMenuButtonList : LinearLayout {
|
||||
var _activeText: String? = null;
|
||||
val id: String?
|
||||
|
||||
constructor(context: Context, attrs: AttributeSet? = null, id: String? = null): super(context, attrs) {
|
||||
this.id = id
|
||||
val scrollable: Boolean;
|
||||
|
||||
LayoutInflater.from(context).inflate(R.layout.overlay_slide_up_menu_button_list, this, true);
|
||||
constructor(context: Context, attrs: AttributeSet? = null, id: String? = null, scrollable: Boolean = false): super(context, attrs) {
|
||||
this.id = id
|
||||
this.scrollable = scrollable ?: false;
|
||||
|
||||
LayoutInflater.from(context).inflate(
|
||||
if(!scrollable)
|
||||
R.layout.overlay_slide_up_menu_button_list
|
||||
else R.layout.overlay_slide_up_menu_button_list_scrollable, this, true);
|
||||
|
||||
_root = findViewById(R.id.root);
|
||||
}
|
||||
@@ -37,8 +44,9 @@ class SlideUpMenuButtonList : LinearLayout {
|
||||
buttons.clear();
|
||||
for (t in texts) {
|
||||
val button = LinearLayout(context);
|
||||
button.layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT).apply {
|
||||
weight = 1.0f;
|
||||
button.layoutParams = LinearLayout.LayoutParams(if(!scrollable) 0 else LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.MATCH_PARENT).apply {
|
||||
if(!scrollable)
|
||||
weight = 1.0f;
|
||||
marginStart = marginLeft;
|
||||
marginEnd = marginRight;
|
||||
};
|
||||
@@ -49,7 +57,11 @@ class SlideUpMenuButtonList : LinearLayout {
|
||||
onClick.emit(t);
|
||||
};
|
||||
|
||||
button.setPadding(0, 0, 0, 0);
|
||||
val dp8 = 8.dp(resources)
|
||||
if(!scrollable)
|
||||
button.setPadding(0, 0, 0, 0);
|
||||
else
|
||||
button.setPadding(dp8, 0, dp8, 0);
|
||||
|
||||
val text = TextView(context);
|
||||
text.layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT);
|
||||
@@ -69,6 +81,18 @@ class SlideUpMenuButtonList : LinearLayout {
|
||||
fun setSelected(text: String) {
|
||||
buttons[_activeText]?.background = ContextCompat.getDrawable(context, R.drawable.background_slide_up_option);
|
||||
buttons[text]?.background = ContextCompat.getDrawable(context, R.drawable.background_slide_up_option_selected);
|
||||
|
||||
|
||||
val dp8 = 8.dp(resources)
|
||||
if(!scrollable) {
|
||||
buttons[text]?.setPadding(0, 0, 0, 0);
|
||||
buttons[_activeText]?.setPadding(0, 0, 0, 0);
|
||||
}
|
||||
else {
|
||||
buttons[text]?.setPadding(dp8, 0, dp8, 0);
|
||||
buttons[_activeText]?.setPadding(dp8, 0, dp8, 0);
|
||||
}
|
||||
|
||||
_activeText = text;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<HorizontalScrollView android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="35dp"
|
||||
android:layout_marginTop="10dp"
|
||||
android:id="@+id/root"
|
||||
android:orientation="horizontal"
|
||||
android:paddingStart="0dp"
|
||||
android:paddingEnd="0dp">
|
||||
|
||||
</LinearLayout>
|
||||
</HorizontalScrollView>
|
||||
Reference in New Issue
Block a user