Compare commits

..

18 Commits

Author SHA1 Message Date
Kai d64faac74c simplify quality selector text
Changelog: changed
2025-06-17 10:26:33 -05:00
Koen 2cc873ef60 Merge branch 'quality-selector-fix' into 'master'
fix graphical glitches with quality selector

See merge request videostreaming/grayjay!109
2025-06-16 10:07:16 +00:00
Koen 7a66ce6bcd Merge branch 'sources-tab-scrolling-fix' into 'master'
Sources Scrolling Fix

See merge request videostreaming/grayjay!114
2025-06-16 10:01:45 +00:00
Koen 2730569b6b Merge branch 'tablet-landscape-fix' into 'master'
Tablet Landscape Fix

See merge request videostreaming/grayjay!115
2025-06-16 09:57:57 +00:00
Koen ede5c4409c Merge branch 'watch-later-add-feature' into 'master'
Water Later Add Feature

See merge request videostreaming/grayjay!117
2025-06-16 09:54:43 +00:00
Koen 0dbe398435 Merge branch 'hls-quality-sort' into 'master'
Adaptive Quality Sort

See merge request videostreaming/grayjay!118
2025-06-16 09:41:22 +00:00
Koen J bcab3bccbc Fixed crash when signature fields are wrongly populated. 2025-06-16 10:43:57 +02:00
Koen J 13100dc38d Minor fix in playback speed setting. 2025-06-12 11:21:00 +02:00
Koen J 5227041398 Added setting for hold playback speed increase. Implemented chromecast playback rate adjustment in range [1, 2]. Implemented hold playback speed increase pill. 2025-06-12 10:33:05 +02:00
Kelvin 8491d4da1a Merge branch 'fix-ump-downloads' into 'master'
Revert downloads patch which broke downloads

See merge request videostreaming/grayjay!122
2025-06-11 16:41:20 +00:00
zvonimir 9bea1563ca Revert downloads patch which broke downloads 2025-06-11 18:36:05 +02:00
Koen J 9e7b936663 Implemented hold to play video at 2x speed gesture. 2025-06-11 17:03:53 +02:00
Kai 9944842a2f Change adaptive streaming (HLS and Dash) quality to sort in descending quality to align with YouTube and the rest of Grayjay
Changelog: changed
2025-06-09 17:02:55 -05:00
Kai 99dc50894c update text
Changelog: changed
2025-06-09 16:54:24 -05:00
Kai 6598dff6df add add to watch later setting
add https://github.com/futo-org/grayjay-android/issues/2173

Changelog: added
2025-06-06 23:35:59 -05:00
Kai 623c47fa2e fix https://github.com/futo-org/grayjay-android/issues/2210
Changelog: changed
2025-06-06 15:25:46 -05:00
Kai 19861fe812 fix https://github.com/futo-org/grayjay-android/issues/2316
Changelog: changed
2025-06-06 13:40:20 -05:00
Kai c333300906 fix graphical glitches with quality selector
Changelog: changed
2025-06-05 11:08:19 -05:00
34 changed files with 366 additions and 116 deletions
@@ -11,7 +11,7 @@ import java.nio.ByteBuffer
import kotlin.random.Random
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
/*
class SyncServerTests {
//private val relayHost = "relay.grayjay.app"
@@ -335,4 +335,4 @@ class SyncServerTests {
class AlwaysAuthorized : IAuthorizable {
override val isAuthorized: Boolean get() = true
}
}*/
@@ -13,7 +13,7 @@ import kotlin.random.Random
import java.io.InputStream
import java.io.OutputStream
import kotlin.time.Duration.Companion.seconds
/*
data class PipeStreams(
val initiatorInput: LittleEndianDataInputStream,
val initiatorOutput: LittleEndianDataOutputStream,
@@ -509,4 +509,4 @@ class Authorized : IAuthorizable {
class Unauthorized : IAuthorizable {
override val isAuthorized: Boolean = false
}
}*/
+2
View File
@@ -403,6 +403,8 @@ class VideoUrlSource {
this.bitrate = obj.bitrate ?? 0;
this.duration = obj.duration ?? 0;
this.url = obj.url;
if(obj.frameRate)
this.frameRate = obj.frameRate;
if(obj.requestModifier)
this.requestModifier = obj.requestModifier;
}
@@ -442,14 +442,18 @@ class Settings : FragmentedStorageFileJson() {
fun getPreferredPreviewQualityPixelCount(): Int = preferedQualityToPixels(preferredPreviewQuality);
@AdvancedField
@FormField(R.string.simplify_sources, FieldForm.TOGGLE, R.string.simplify_sources_description, 4)
@FormField(R.string.show_advanced_media_source_metadata, FieldForm.TOGGLE, R.string.show_advanced_media_source_metadata_desc, 4)
var showAdvancedMediaSourceMetadata: Boolean = false;
@AdvancedField
@FormField(R.string.simplify_sources, FieldForm.TOGGLE, R.string.simplify_sources_description, 5)
var simplifySources: Boolean = true;
@AdvancedField
@FormField(R.string.always_allow_reverse_landscape_auto_rotate, FieldForm.TOGGLE, R.string.always_allow_reverse_landscape_auto_rotate_description, 5)
@FormField(R.string.always_allow_reverse_landscape_auto_rotate, FieldForm.TOGGLE, R.string.always_allow_reverse_landscape_auto_rotate_description, 6)
var alwaysAllowReverseLandscapeAutoRotate: Boolean = true
@FormField(R.string.background_behavior, FieldForm.DROPDOWN, -1, 6)
@FormField(R.string.background_behavior, FieldForm.DROPDOWN, -1, 7)
@DropdownFieldOptionsId(R.array.player_background_behavior)
var backgroundPlay: Int = 2;
@@ -457,7 +461,7 @@ class Settings : FragmentedStorageFileJson() {
fun isBackgroundPictureInPicture() = backgroundPlay == 2;
@AdvancedField
@FormField(R.string.resume_after_preview, FieldForm.DROPDOWN, R.string.when_watching_a_video_in_preview_mode_resume_at_the_position_when_opening_the_video_code, 7)
@FormField(R.string.resume_after_preview, FieldForm.DROPDOWN, R.string.when_watching_a_video_in_preview_mode_resume_at_the_position_when_opening_the_video_code, 8)
@DropdownFieldOptionsId(R.array.resume_after_preview)
var resumeAfterPreview: Int = 1;
@@ -469,7 +473,7 @@ class Settings : FragmentedStorageFileJson() {
return false;
}
@FormField(R.string.chapter_update_fps_title, FieldForm.DROPDOWN, R.string.chapter_update_fps_description, 8)
@FormField(R.string.chapter_update_fps_title, FieldForm.DROPDOWN, R.string.chapter_update_fps_description, 9)
@DropdownFieldOptionsId(R.array.chapter_fps)
var chapterUpdateFPS: Int = 0;
@@ -484,7 +488,7 @@ class Settings : FragmentedStorageFileJson() {
}
@AdvancedField
@FormField(R.string.live_chat_webview, FieldForm.TOGGLE, R.string.use_the_live_chat_web_window_when_available_over_native_implementation, 9)
@FormField(R.string.live_chat_webview, FieldForm.TOGGLE, R.string.use_the_live_chat_web_window_when_available_over_native_implementation, 10)
var useLiveChatWindow: Boolean = true;
@FormField(R.string.restart_after_audio_focus_loss, FieldForm.DROPDOWN, R.string.restart_playback_when_gaining_audio_focus_after_a_loss, 11)
@@ -584,6 +588,24 @@ class Settings : FragmentedStorageFileJson() {
playbackSpeeds.sort();
return playbackSpeeds;
}
@FormField(R.string.hold_playback_speed, FieldForm.DROPDOWN, R.string.hold_playback_speed_description, 27)
@DropdownFieldOptionsId(R.array.hold_playback_speeds)
var holdPlaybackSpeed: Int = 3;
fun getHoldPlaybackSpeed(): Double {
return when(holdPlaybackSpeed) {
0 -> 1.25
1 -> 1.5
2 -> 1.75
3 -> 2.0
4 -> 2.25
5 -> 2.5
6 -> 2.75
7 -> 3.0
else -> 2.0
}
}
}
@FormField(R.string.comments, "group", R.string.comments_description, 6)
@@ -999,10 +1021,13 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.playlist_allow_dups, FieldForm.TOGGLE, R.string.playlist_allow_dups_description, 3)
var playlistAllowDups: Boolean = true;
@FormField(R.string.enable_polycentric, FieldForm.TOGGLE, R.string.can_be_disabled_when_you_are_experiencing_issues, 4)
@FormField(R.string.add_to_beginning_of_watch_later, FieldForm.TOGGLE, R.string.add_to_beginning_description, 4)
var addToBeginning: Boolean = true;
@FormField(R.string.enable_polycentric, FieldForm.TOGGLE, R.string.can_be_disabled_when_you_are_experiencing_issues, 5)
var polycentricEnabled: Boolean = true;
@FormField(R.string.polycentric_local_cache, FieldForm.TOGGLE, R.string.polycentric_local_cache_description, 5)
@FormField(R.string.polycentric_local_cache, FieldForm.TOGGLE, R.string.polycentric_local_cache_description, 7)
var polycentricLocalCache: Boolean = true;
}
@@ -74,6 +74,9 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.ByteArrayInputStream
import androidx.core.net.toUri
import androidx.media3.common.Format
import com.futo.platformplayer.others.Language
import java.util.Locale
class UISlideOverlays {
companion object {
@@ -344,14 +347,18 @@ class UISlideOverlays {
if (source is IHLSManifestAudioSource) {
val variant = HLS.mediaRenditionToVariant(MediaRendition("AUDIO", playlist.baseUri, "Single Playlist", null, null, null, null, null))!!
val estSize = VideoHelper.estimateSourceSize(variant);
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
val language = variant.language
val mainText = when {
language != Language.UNKNOWN && variant.bitrate == Format.NO_VALUE -> Locale.forLanguageTag(language).displayLanguage
language == Language.UNKNOWN && variant.bitrate != Format.NO_VALUE -> variant.bitrate.toHumanBitrate()
language != Language.UNKNOWN && variant.bitrate != Format.NO_VALUE -> "${Locale.forLanguageTag(language).displayLanguage} ${variant.bitrate.toHumanBitrate()}"
else -> "Default"
}
audioButtons.add(SlideUpMenuItem(
container.context,
R.drawable.ic_music,
variant.name,
listOf(variant.language, variant.codec).mapNotNull { x -> x.ifEmpty { null } }.joinToString(", "),
(prefix + variant.codec).trim(),
if (variant.name != "") variant.name else mainText,
if (Settings.instance.playback.showAdvancedMediaSourceMetadata) variant.codec.trim() else "",
tag = variant,
call = {
selectedAudioVariant = variant
@@ -363,14 +370,12 @@ class UISlideOverlays {
} else {
val variant = HLS.variantReferenceToVariant(VariantPlaylistReference(playlist.baseUri, StreamInfo(null, null, null, null, null, null, null, null, null)))
val estSize = VideoHelper.estimateSourceSize(variant);
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
videoButtons.add(SlideUpMenuItem(
container.context,
R.drawable.ic_movie,
variant.name,
"${variant.width}x${variant.height}",
(prefix + variant.codec).trim(),
if (variant.name != "") variant.name else "${variant.width}p${variant.frameRate ?: ""}",
if (Settings.instance.playback.showAdvancedMediaSourceMetadata) variant.codec.trim() else "",
if (Settings.instance.playback.showAdvancedMediaSourceMetadata) variant.bitrate?.toHumanBitrate() else "",
tag = variant,
call = {
selectedVideoVariant = variant
@@ -385,16 +390,19 @@ class UISlideOverlays {
} else if (playlist is HlsMultivariantPlaylist) {
masterPlaylist = HLS.parseMasterPlaylist(masterPlaylistContent, resolvedPlaylistUrl)
masterPlaylist.getAudioSources().forEach { it ->
val estSize = VideoHelper.estimateSourceSize(it);
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
masterPlaylist.getAudioSources().forEach {
val language = it.language
val mainText = when {
language != Language.UNKNOWN && it.bitrate == Format.NO_VALUE -> Locale.forLanguageTag(language).displayLanguage
language == Language.UNKNOWN && it.bitrate != Format.NO_VALUE -> it.bitrate.toHumanBitrate()
language != Language.UNKNOWN && it.bitrate != Format.NO_VALUE -> "${Locale.forLanguageTag(language).displayLanguage} ${it.bitrate.toHumanBitrate()}"
else -> "Default"
}
audioButtons.add(SlideUpMenuItem(
container.context,
R.drawable.ic_music,
it.name,
listOf(it.language, it.codec).mapNotNull { x -> x.ifEmpty { null } }.joinToString(", "),
(prefix + it.codec).trim(),
if (it.name != "") it.name else mainText,
if (Settings.instance.playback.showAdvancedMediaSourceMetadata) it.codec.trim() else "",
tag = it,
call = {
selectedAudioVariant = it
@@ -414,14 +422,12 @@ class UISlideOverlays {
}*/
masterPlaylist.getVideoSources().forEach {
val estSize = VideoHelper.estimateSourceSize(it);
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
videoButtons.add(SlideUpMenuItem(
container.context,
R.drawable.ic_movie,
it.name,
"${it.width}x${it.height}",
(prefix + it.codec).trim(),
if (it.name != "") it.name else "${it.height}p${it.frameRate ?: ""}",
if (Settings.instance.playback.showAdvancedMediaSourceMetadata) it.codec.trim() else "",
if (Settings.instance.playback.showAdvancedMediaSourceMetadata) it.bitrate?.toHumanBitrate() else "",
tag = it,
call = {
selectedVideoVariant = it
@@ -535,9 +541,9 @@ class UISlideOverlays {
SlideUpMenuItem(
container.context,
R.drawable.ic_movie,
it.name,
"${it.width}x${it.height}",
(prefix + it.codec).trim(),
if (it.name != "") it.name else "${it.height}p${it.frameRate ?: ""}",
if (Settings.instance.playback.showAdvancedMediaSourceMetadata) it.codec.trim() else "",
if (Settings.instance.playback.showAdvancedMediaSourceMetadata) it.bitrate?.toHumanBitrate() ?: "" else "",
tag = it,
call = {
selectedVideo = it
@@ -573,8 +579,7 @@ class UISlideOverlays {
SlideUpMenuItem(
container.context,
R.drawable.ic_movie,
it.name,
"HLS",
if (it.name != "") it.name else "HLS",
tag = it,
call = {
showHlsPicker(video, it, it.url, container)
@@ -606,14 +611,18 @@ class UISlideOverlays {
.map {
when (it) {
is IAudioUrlSource -> {
val estSize = VideoHelper.estimateSourceSize(it);
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
val mainText = when {
it.language != Language.UNKNOWN && it.bitrate == Format.NO_VALUE -> Locale.forLanguageTag(it.language).displayLanguage
it.language == Language.UNKNOWN && it.bitrate != Format.NO_VALUE -> it.bitrate.toHumanBitrate()
it.language != Language.UNKNOWN && it.bitrate != Format.NO_VALUE -> "${Locale.forLanguageTag(it.language).displayLanguage} ${it.bitrate.toHumanBitrate()}"
else -> "Default"
}
SlideUpMenuItem(
container.context,
R.drawable.ic_music,
it.name,
"${it.bitrate}",
(prefix + it.codec).trim(),
if (it.name != "") it.name else mainText,
if (Settings.instance.playback.showAdvancedMediaSourceMetadata) it.codec.trim() ?: "" else "",
if (Settings.instance.playback.showAdvancedMediaSourceMetadata) (if (it.original) "Original" else "") else "",
tag = it,
call = {
selectedAudio = it
@@ -647,8 +656,7 @@ class UISlideOverlays {
SlideUpMenuItem(
container.context,
R.drawable.ic_movie,
it.name,
"HLS Audio",
if (it.name != "") it.name else "HLS Audio",
tag = it,
call = {
showHlsPicker(video, it, it.url, container)
@@ -1151,6 +1159,8 @@ class UISlideOverlays {
call = {
if(StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video), true))
UIDialogs.appToast("Added to watch later", false);
else
UIDialogs.toast(container.context.getString(R.string.already_in_watch_later))
}),
)
);
@@ -9,6 +9,8 @@ class DashManifestSource : IVideoSource, IDashManifestSource {
override val bitrate: Int? = null;
override val url : String;
override val duration: Long get() = 0;
// only used for single source DASH
override val frameRate: Int? = null
override var priority: Boolean = false;
@@ -9,6 +9,8 @@ class HLSManifestSource : IVideoSource, IHLSManifestSource {
override val bitrate : Int? = null;
override val url : String;
override val duration: Long = 0;
override val frameRate: Int?
get() = null
override var priority: Boolean = false;
@@ -12,7 +12,8 @@ class HLSVariantVideoUrlSource(
override val bitrate: Int?,
override val duration: Long,
override val priority: Boolean,
val url: String
val url: String,
override val frameRate: Int? = null
) : IVideoUrlSource {
override fun getVideoUrl(): String {
return url
@@ -9,4 +9,5 @@ interface IVideoSource {
val bitrate : Int?;
val duration: Long;
val priority: Boolean;
val frameRate: Int?
}
@@ -13,6 +13,7 @@ class LocalVideoSource : IVideoSource, IStreamMetaDataSource {
override val name : String;
override val bitrate : Int;
override val duration : Long;
override val frameRate: Int?
override var priority: Boolean = false;
@@ -22,7 +23,7 @@ class LocalVideoSource : IVideoSource, IStreamMetaDataSource {
//Only for particular videos
override var streamMetaData: StreamMetaData? = null;
constructor(name : String, filePath : String, fileSize: Long, width : Int = 0, height : Int = 0, duration: Long = 0, container : String = "", codec : String = "", bitrate : Int = 0) {
constructor(name : String, filePath : String, fileSize: Long, width : Int = 0, height : Int = 0, duration: Long = 0, container : String = "", codec : String = "", bitrate : Int = 0, frameRate : Int? = null) {
this.name = name;
this.width = width;
this.height = height;
@@ -32,6 +33,7 @@ class LocalVideoSource : IVideoSource, IStreamMetaDataSource {
this.filePath = filePath;
this.fileSize = fileSize;
this.bitrate = bitrate;
this.frameRate = frameRate
}
companion object {
@@ -45,7 +47,8 @@ class LocalVideoSource : IVideoSource, IStreamMetaDataSource {
source.duration,
overrideContainer ?: source.container,
source.codec,
source.bitrate?:0
source.bitrate?:0,
source.frameRate
);
}
}
@@ -13,6 +13,7 @@ open class VideoUrlSource(
override val container : String = "",
override val codec : String = "",
override val bitrate : Int? = 0,
override val frameRate: Int? = null,
override var priority: Boolean = false
) : IVideoUrlSource, IStreamMetaDataSource {
@@ -38,7 +39,8 @@ open class VideoUrlSource(
source.duration,
source.container,
source.codec,
source.bitrate
source.bitrate,
source.frameRate
);
ret.streamMetaData = streamData;
@@ -4,6 +4,7 @@ import android.net.Uri
import com.futo.platformplayer.SignatureProvider
import com.futo.platformplayer.api.media.Serializer
import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.matchesDomain
import com.futo.platformplayer.states.StatePlugins
import kotlinx.serialization.Contextual
@@ -168,12 +169,17 @@ class SourcePluginConfig(
}
fun validate(text: String): Boolean {
if(scriptPublicKey.isNullOrEmpty())
throw IllegalStateException("No public key present");
if(scriptSignature.isNullOrEmpty())
throw IllegalStateException("No signature present");
try {
if (scriptPublicKey.isNullOrEmpty())
throw IllegalStateException("No public key present");
if (scriptSignature.isNullOrEmpty())
throw IllegalStateException("No signature present");
return SignatureProvider.verify(text, scriptSignature, scriptPublicKey);
return SignatureProvider.verify(text, scriptSignature, scriptPublicKey);
} catch (e: Throwable) {
Logger.e(TAG, "Failed to verify due to an unhandled exception", e)
return false
}
}
fun isUrlAllowed(url: String): Boolean {
@@ -204,6 +210,8 @@ class SourcePluginConfig(
obj.sourceUrl = sourceUrl;
return obj;
}
private val TAG = "SourcePluginConfig"
}
@kotlinx.serialization.Serializable
@@ -31,6 +31,8 @@ open class JSDashManifestRawSource: JSSource, IVideoSource, IJSDashManifestRawSo
override val bitrate: Int?;
override val duration: Long;
override val priority: Boolean;
// only used for single source DASH
override val frameRate: Int?
var url: String?;
override var manifest: String?;
@@ -52,6 +54,7 @@ open class JSDashManifestRawSource: JSSource, IVideoSource, IJSDashManifestRawSo
codec = _obj.getOrDefault(config, "codec", contextName, "") ?: "";
bitrate = _obj.getOrDefault(config, "bitrate", contextName, 0) ?: 0;
duration = _obj.getOrDefault(config, "duration", contextName, 0) ?: 0;
frameRate = _obj.getOrNull(config, "frameRate", contextName);
priority = _obj.getOrDefault(config, "priority", contextName, false) ?: false;
canMerge = _obj.getOrDefault(config, "canMerge", contextName, false) ?: false;
hasGenerate = _obj.has("generate");
@@ -18,6 +18,8 @@ class JSDashManifestSource : IVideoUrlSource, IDashManifestSource, JSSource {
override val bitrate: Int? = null;
override val url : String;
override val duration: Long;
// only used for single source DASH
override val frameRate: Int? = null
override var priority: Boolean = false;
@@ -20,6 +20,7 @@ class JSDashManifestWidevineSource : IVideoUrlSource, IDashManifestSource,
override val bitrate: Int? = null
override val url: String
override val duration: Long
override val frameRate: Int? = null
override var priority: Boolean = false
@@ -18,6 +18,7 @@ class JSHLSManifestSource : IHLSManifestSource, JSSource {
override val bitrate : Int? = null;
override val url : String;
override val duration: Long;
override val frameRate: Int? = null
override var priority: Boolean = false;
@@ -16,6 +16,7 @@ open class JSVideoUrlSource : IVideoUrlSource, JSSource {
override val name : String;
override val bitrate : Int;
override val duration: Long;
override val frameRate: Int?
private val url : String;
override var priority: Boolean = false;
@@ -31,6 +32,7 @@ open class JSVideoUrlSource : IVideoUrlSource, JSSource {
name = _obj.getOrThrow(config, "name", contextName);
bitrate = _obj.getOrThrow(config, "bitrate", contextName);
duration = _obj.getOrThrow<Int>(config, "duration", contextName).toLong();
frameRate = _obj.getOrNull(config, "frameRate", contextName);
url = _obj.getOrThrow(config, "url", contextName);
priority = obj.getOrNull(config, "priority", contextName) ?: false;
@@ -19,6 +19,7 @@ class LocalVideoFileSource: IVideoSource {
override val bitrate: Int = 0
override val duration: Long;
override val priority: Boolean = false;
override val frameRate: Int? = null
constructor(file: File) {
name = file.name;
@@ -35,7 +35,7 @@ class ChromecastCastingDevice : CastingDevice {
override var usedRemoteAddress: InetAddress? = null;
override var localAddress: InetAddress? = null;
override val canSetVolume: Boolean get() = true;
override val canSetSpeed: Boolean get() = false; //TODO: Implement
override val canSetSpeed: Boolean get() = true;
var addresses: Array<InetAddress>? = null;
var port: Int = 0;
@@ -144,6 +144,23 @@ class ChromecastCastingDevice : CastingDevice {
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.media", json);
}
override fun changeSpeed(speed: Double) {
if (invokeInIOScopeIfRequired { changeSpeed(speed) }) return
val speedClamped = speed.coerceAtLeast(1.0).coerceAtLeast(1.0).coerceAtMost(2.0)
setSpeed(speedClamped)
val mediaSessionId = _mediaSessionId ?: return
val transportId = _transportId ?: return
val setSpeedObject = JSONObject().apply {
put("type", "SET_PLAYBACK_RATE")
put("mediaSessionId", mediaSessionId)
put("playbackRate", speedClamped)
put("requestId", _requestId++)
}
sendChannelMessage(sourceId = "sender-0", destinationId = transportId, namespace = "urn:x-cast:com.google.cast.media", json = setSpeedObject.toString())
}
override fun changeVolume(volume: Double) {
if (invokeInIOScopeIfRequired({ changeVolume(volume) })) {
return;
@@ -724,7 +724,7 @@ class VideoDownload {
val t = cue.groupValues[1];
val d = cue.groupValues[2];
val url = foundTemplateUrl.replace("\$Number\$", (indexCounter + 1).toString());
val url = foundTemplateUrl.replace("\$Number\$", (indexCounter).toString());
val data = if(executor != null)
executor.executeRequest("GET", url, null, mapOf());
@@ -778,6 +778,8 @@ class ArticleDetailFragment : MainFragment {
view.onAddToWatchLaterClicked.subscribe { a ->
if(StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(content), true))
UIDialogs.toast("Added to watch later\n[${content.name}]")
else
UIDialogs.toast(context.getString(R.string.already_in_watch_later))
}
}
else if(content is IPlatformPost) {
@@ -226,6 +226,8 @@ class ChannelFragment : MainFragment() {
if (content is IPlatformVideo) {
if(StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(content), true))
UIDialogs.toast("Added to watch later\n[${content.name}]")
else
UIDialogs.toast(context.getString(R.string.already_in_watch_later))
}
}
adapter.onUrlClicked.subscribe { url ->
@@ -86,6 +86,8 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
if(it is IPlatformVideo) {
if(StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(it), true))
UIDialogs.toast("Added to watch later\n[${it.name}]");
else
UIDialogs.toast(context.getString(R.string.already_in_watch_later))
}
};
adapter.onLongPress.subscribe(this) {
@@ -101,7 +101,7 @@ class VideoDetailFragment() : MainFragment() {
}
private fun isSmallWindow(): Boolean {
return resources.configuration.smallestScreenWidthDp < resources.getInteger(R.integer.column_width_dp) * 2
return resources.configuration.smallestScreenWidthDp < resources.getInteger(R.integer.smallest_width_dp)
}
private fun isAutoRotateEnabled(): Boolean {
@@ -627,11 +627,6 @@ class VideoDetailFragment() : MainFragment() {
showSystemUI()
}
// temporarily force the device to portrait if auto-rotate is disabled to prevent landscape when exiting full screen on a small device
// @SuppressLint("SourceLockedOrientationActivity")
// if (!isFullscreen && isSmallWindow() && !isAutoRotateEnabled() && !isMinimizingFromFullScreen) {
// activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT
// }
updateOrientation();
_view?.allowMotion = !fullscreen;
}
@@ -101,6 +101,7 @@ import com.futo.platformplayer.getNowDiffSeconds
import com.futo.platformplayer.helpers.VideoHelper
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.Subscription
import com.futo.platformplayer.others.Language
import com.futo.platformplayer.receivers.MediaControlReceiver
import com.futo.platformplayer.selectBestImage
import com.futo.platformplayer.states.AnnouncementType
@@ -1934,8 +1935,8 @@ class VideoDetailView : ConstraintLayout {
}
updateQualityFormatsOverlay(
videoTrackFormats.distinctBy { it.height }.sortedBy { it.height },
audioTrackFormats.distinctBy { it.bitrate }.sortedBy { it.bitrate });
videoTrackFormats.distinctBy { it.height }.sortedByDescending { it.height },
audioTrackFormats.distinctBy { it.bitrate }.sortedByDescending { it.bitrate });
}
}
@@ -2230,8 +2231,9 @@ class VideoDetailView : ConstraintLayout {
.map {
SlideUpMenuItem(this.context,
R.drawable.ic_movie,
it.name,
"${it.width}x${it.height}",
if (it.name != "") it.name else "${it.height}p${it.frameRate ?: ""}",
if (Settings.instance.playback.showAdvancedMediaSourceMetadata) it.codec.trim() else "",
if (Settings.instance.playback.showAdvancedMediaSourceMetadata) it.bitrate?.toHumanBitrate() ?: "" else "",
tag = it,
call = { handleSelectVideoTrack(it) });
}.toList().toTypedArray())
@@ -2240,10 +2242,18 @@ class VideoDetailView : ConstraintLayout {
SlideUpMenuGroup(this.context, context.getString(R.string.offline_audio), "audio",
*localAudioSource
.map {
val mainText = when {
it.language != Language.UNKNOWN && it.bitrate == Format.NO_VALUE -> Locale.forLanguageTag(it.language).displayLanguage
it.language == Language.UNKNOWN && it.bitrate != Format.NO_VALUE -> it.bitrate.toHumanBitrate()
it.language != Language.UNKNOWN && it.bitrate != Format.NO_VALUE -> "${Locale.forLanguageTag(it.language).displayLanguage} ${it.bitrate.toHumanBitrate()}"
else -> "Default"
}
SlideUpMenuItem(this.context,
R.drawable.ic_music,
it.name,
it.bitrate.toHumanBitrate(),
if (it.name != "") it.name else mainText,
if (Settings.instance.playback.showAdvancedMediaSourceMetadata) it.codec.trim() ?: "" else "",
if (Settings.instance.playback.showAdvancedMediaSourceMetadata) (if (it.original) "Original" else "") else "",
tag = it,
call = { handleSelectAudioTrack(it) });
}.toList().toTypedArray())
@@ -2260,36 +2270,49 @@ class VideoDetailView : ConstraintLayout {
this.context, context.getString(R.string.stream_video), "video", (listOf(
SlideUpMenuItem(this.context, R.drawable.ic_movie, "Auto", tag = "auto", call = { _player.selectVideoTrack(-1) })
) + (liveStreamVideoFormats.map {
SlideUpMenuItem(this.context, R.drawable.ic_movie, it.label
?: it.containerMimeType
?: it.bitrate.toString(), "${it.width}x${it.height}", tag = it, call = { _player.selectVideoTrack(it.height) });
val frameRate =
if (it.frameRate.toInt() == Format.NO_VALUE) "" else it.frameRate.toInt()
SlideUpMenuItem(
this.context, R.drawable.ic_movie, "${it.height}p${frameRate}",
if (Settings.instance.playback.showAdvancedMediaSourceMetadata) it.containerMimeType ?: "" else "",
if (Settings.instance.playback.showAdvancedMediaSourceMetadata) it.label ?: it.bitrate.toHumanBitrate() else "",
tag = it, call = { _player.selectVideoTrack(it.height) });
}))
)
else null,
if(liveStreamAudioFormats?.isEmpty() == false)
SlideUpMenuGroup(this.context, context.getString(R.string.stream_audio), "audio",
if (liveStreamAudioFormats?.isEmpty() == false)
SlideUpMenuGroup(
this.context, context.getString(R.string.stream_audio), "audio",
*liveStreamAudioFormats
.map {
SlideUpMenuItem(this.context,
val language = it.language
val mainText = when {
language != null && it.bitrate == Format.NO_VALUE -> Locale.forLanguageTag(language).displayLanguage
language == null && it.bitrate != Format.NO_VALUE -> it.bitrate.toHumanBitrate()
language != null && it.bitrate != Format.NO_VALUE -> "${Locale.forLanguageTag(language).displayLanguage} ${it.bitrate.toHumanBitrate()}"
else -> "Default"
}
SlideUpMenuItem(
this.context,
R.drawable.ic_music,
"${it.label ?: it.containerMimeType} ${it.bitrate}",
"",
mainText,
if (Settings.instance.playback.showAdvancedMediaSourceMetadata) it.containerMimeType ?: "" else "",
if (Settings.instance.playback.showAdvancedMediaSourceMetadata) it.label else "",
tag = it,
call = { _player.selectAudioTrack(it.bitrate) });
}.toList().toTypedArray())
}.toList().toTypedArray()
)
else null,
if(bestVideoSources.isNotEmpty())
SlideUpMenuGroup(this.context, context.getString(R.string.video), "video",
*bestVideoSources
.map {
val estSize = VideoHelper.estimateSourceSize(it);
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
SlideUpMenuItem(this.context,
R.drawable.ic_movie,
it!!.name,
if (it.width > 0 && it.height > 0) "${it.width}x${it.height}" else "",
(prefix + it.codec.trim()).trim(),
if (it.name != "") it.name else "${it.height}p${it.frameRate ?: ""}",
if (Settings.instance.playback.showAdvancedMediaSourceMetadata) it.codec.trim() else "",
if (Settings.instance.playback.showAdvancedMediaSourceMetadata) it.bitrate?.toHumanBitrate() ?: "" else "",
tag = it,
call = { handleSelectVideoTrack(it) });
}.toList().toTypedArray())
@@ -2298,13 +2321,17 @@ class VideoDetailView : ConstraintLayout {
SlideUpMenuGroup(this.context, context.getString(R.string.audio), "audio",
*bestAudioSources
.map {
val estSize = VideoHelper.estimateSourceSize(it);
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
val mainText = when {
it.language != Language.UNKNOWN && it.bitrate == Format.NO_VALUE -> Locale.forLanguageTag(it.language).displayLanguage
it.language == Language.UNKNOWN && it.bitrate != Format.NO_VALUE -> it.bitrate.toHumanBitrate()
it.language != Language.UNKNOWN && it.bitrate != Format.NO_VALUE -> "${Locale.forLanguageTag(it.language).displayLanguage} ${it.bitrate.toHumanBitrate()}"
else -> "Default"
}
SlideUpMenuItem(this.context,
R.drawable.ic_music,
it.name,
it.bitrate.toHumanBitrate(),
(prefix + it.codec.trim()).trim(),
if (it.name != "") it.name else mainText,
if (Settings.instance.playback.showAdvancedMediaSourceMetadata) it.codec.trim() ?: "" else "",
if (Settings.instance.playback.showAdvancedMediaSourceMetadata) (if (it.original) "Original" else "") else "",
tag = it,
call = { handleSelectAudioTrack(it) });
}.toList().toTypedArray())
@@ -2758,6 +2785,8 @@ class VideoDetailView : ConstraintLayout {
if(it is IPlatformVideo) {
if(StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(it), true))
UIDialogs.toast("Added to watch later\n[${it.name}]");
else
UIDialogs.toast(context.getString(R.string.already_in_watch_later))
}
}
onAddToQueueClicked.subscribe(this) {
@@ -178,31 +178,30 @@ class StatePlaylists {
StateDownloads.instance.checkForOutdatedPlaylistVideos(VideoDownload.GROUP_WATCHLATER);
}
}
fun addToWatchLater(video: SerializedPlatformVideo, isUserInteraction: Boolean = false, orderPosition: Int = -1): Boolean {
var wasNew = false;
fun addToWatchLater(video: SerializedPlatformVideo, isUserInteraction: Boolean = false): Boolean {
synchronized(_watchlistStore) {
if(!_watchlistStore.hasItem { it.url == video.url })
wasNew = true;
_watchlistStore.saveAsync(video);
if(orderPosition == -1)
_watchlistOrderStore.set(*(listOf(video.url) + _watchlistOrderStore.values).toTypedArray());
else {
val existing = _watchlistOrderStore.getAllValues().toMutableList();
existing.add(orderPosition, video.url);
_watchlistOrderStore.set(*existing.toTypedArray());
if (_watchlistStore.hasItem { it.url == video.url }) {
return false
}
_watchlistOrderStore.save();
_watchlistStore.saveAsync(video)
if (Settings.instance.other.addToBeginning) {
_watchlistOrderStore.set(*(listOf(video.url) + _watchlistOrderStore.values).toTypedArray())
} else {
_watchlistOrderStore.set(*(_watchlistOrderStore.values + listOf(video.url)).toTypedArray())
}
_watchlistOrderStore.save()
}
onWatchLaterChanged.emit();
if(isUserInteraction) {
if (isUserInteraction) {
val now = OffsetDateTime.now();
_watchLaterAdds.setAndSave(video.url, now);
broadcastWatchLaterAddition(video, now);
}
StateDownloads.instance.checkForOutdatedPlaylists();
return wasNew;
return true;
}
fun getLastPlayedPlaylist() : Playlist? {
@@ -39,6 +39,9 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.text.DecimalFormat
import java.text.DecimalFormatSymbols
import java.util.Locale
class GestureControlView : LinearLayout {
@@ -79,6 +82,9 @@ class GestureControlView : LinearLayout {
private var _adjustingFullscreenDown: Boolean = false;
private var _fullScreenFactorUp = 1.0f;
private var _fullScreenFactorDown = 1.0f;
private val _layoutHoldSpeed: LinearLayout
private val _textHoldFastForward: TextView
private val _imageHoldFastForward: ImageView
private var _scaleGestureDetector: ScaleGestureDetector
private var _scaleFactor = 1.0f
@@ -92,6 +98,11 @@ class GestureControlView : LinearLayout {
private var _surfaceView: View? = null
private var _layoutIndicatorFill: FrameLayout;
private var _layoutIndicatorFit: FrameLayout;
private var _speedHolding = false
private val _speedFormatter = DecimalFormat("#.##", DecimalFormatSymbols(Locale.US)).apply {
roundingMode = java.math.RoundingMode.HALF_UP
}
private val _gestureController: GestureDetectorCompat;
@@ -103,6 +114,8 @@ class GestureControlView : LinearLayout {
val onZoom = Event1<Float>();
val onSoundAdjusted = Event1<Float>();
val onToggleFullscreen = Event0();
val onSpeedHoldStart = Event0()
val onSpeedHoldEnd = Event0()
var fullScreenGestureEnabled = true
@@ -124,6 +137,9 @@ class GestureControlView : LinearLayout {
_layoutControlsFullscreen = findViewById(R.id.layout_controls_fullscreen);
_layoutIndicatorFill = findViewById(R.id.layout_indicator_fill);
_layoutIndicatorFit = findViewById(R.id.layout_indicator_fit);
_layoutHoldSpeed = findViewById(R.id.layout_controls_increased_speed)
_textHoldFastForward = findViewById(R.id.text_holdFastForward)
_imageHoldFastForward = findViewById(R.id.image_holdFastForward)
_scaleGestureDetector = ScaleGestureDetector(context, object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
override fun onScale(detector: ScaleGestureDetector): Boolean {
@@ -216,7 +232,20 @@ class GestureControlView : LinearLayout {
return true;
}
override fun onLongPress(p0: MotionEvent) = Unit
override fun onLongPress(p0: MotionEvent) {
if (!_isControlsLocked
&& !_skipping
&& !_adjustingBrightness
&& !_adjustingSound
&& !_adjustingFullscreenUp
&& !_adjustingFullscreenDown
&& !_isPanning
&& !_isZooming) {
_speedHolding = true
showHoldSpeedControls()
onSpeedHoldStart.emit()
}
}
});
_gestureController.setOnDoubleTapListener(object : GestureDetector.OnDoubleTapListener {
@@ -301,6 +330,17 @@ class GestureControlView : LinearLayout {
onPan.emit(_translationX, _translationY)
}
private fun showHoldSpeedControls() {
_layoutHoldSpeed.visibility = View.VISIBLE
_textHoldFastForward.text = _speedFormatter.format(Settings.instance.playback.getHoldPlaybackSpeed()) + "x"
(_imageHoldFastForward.drawable as? Animatable)?.start()
}
private fun hideHoldSpeedControls() {
_layoutHoldSpeed.visibility = View.GONE
(_imageHoldFastForward.drawable as? Animatable)?.stop()
}
fun setupTouchArea(layoutControls: ViewGroup? = null, background: View? = null) {
_layoutControls = layoutControls;
_background = background;
@@ -309,6 +349,12 @@ class GestureControlView : LinearLayout {
override fun onTouchEvent(event: MotionEvent?): Boolean {
val ev = event ?: return super.onTouchEvent(event);
if (ev.action == MotionEvent.ACTION_UP && _speedHolding) {
_speedHolding = false
hideHoldSpeedControls()
onSpeedHoldEnd.emit()
}
cancelHideJob();
if (_skipping) {
@@ -18,6 +18,7 @@ import androidx.media3.ui.DefaultTimeBar
import androidx.media3.ui.TimeBar
import com.bumptech.glide.Glide
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.api.media.models.chapters.IChapter
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.casting.AirPlayCastingDevice
@@ -58,6 +59,8 @@ class CastView : ConstraintLayout {
private var _inPictureInPicture: Boolean = false;
private var _chapters: List<IChapter>? = null;
private var _currentChapter: IChapter? = null;
private var _speedHoldPrevRate = 1.0
private var _speedHoldWasPlaying = false
val onChapterChanged = Event2<IChapter?, Boolean>();
val onMinimizeClick = Event0();
@@ -87,6 +90,20 @@ class CastView : ConstraintLayout {
_gestureControlView = findViewById(R.id.gesture_control);
_gestureControlView.fullScreenGestureEnabled = false
_gestureControlView.setupTouchArea();
_gestureControlView.onSpeedHoldStart.subscribe {
val d = StateCasting.instance.activeDevice ?: return@subscribe;
_speedHoldWasPlaying = d.isPlaying
_speedHoldPrevRate = d.speed
if (d.canSetSpeed)
d.changeSpeed(Settings.instance.playback.getHoldPlaybackSpeed())
d.resumeVideo()
}
_gestureControlView.onSpeedHoldEnd.subscribe {
val d = StateCasting.instance.activeDevice ?: return@subscribe;
if (!_speedHoldWasPlaying) d.pauseVideo()
d.changeSpeed(_speedHoldPrevRate)
}
_gestureControlView.onSeek.subscribe {
val d = StateCasting.instance.activeDevice ?: return@subscribe;
StateCasting.instance.videoSeekTo(d.expectedCurrentTime + it / 1000);
@@ -13,6 +13,7 @@ import android.widget.RelativeLayout
import android.widget.TextView
import androidx.core.animation.doOnEnd
import androidx.core.view.children
import androidx.core.view.isVisible
import com.futo.platformplayer.R
import com.futo.platformplayer.constructs.Event0
@@ -42,10 +43,14 @@ class SlideUpMenuOverlay : RelativeLayout {
constructor(context: Context, parent: ViewGroup, titleText: String, okText: String?, animated: Boolean, items: List<View>, hideButtons: Boolean = false): super(context){
init(animated, okText);
_container = parent;
if(!_container!!.children.contains(this)) {
_container!!.removeAllViews();
_container!!.addView(this);
_container!!.removeAllViews();
_container!!.addView(this);
if (_container!!.isVisible) {
isVisible = true
_viewBackground.alpha = 1.0f;
_viewOverlayContainer.translationY = 0.0f;
}
_textTitle.text = titleText;
groupItems = items;
@@ -56,6 +61,12 @@ class SlideUpMenuOverlay : RelativeLayout {
}
setItems(items);
if (!isVisible) {
_viewOverlayContainer.measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED);
_viewOverlayContainer.translationY = _viewOverlayContainer.measuredHeight.toFloat()
_viewBackground.alpha = 0f;
}
}
@@ -146,16 +157,9 @@ class SlideUpMenuOverlay : RelativeLayout {
}
isVisible = true;
_container?.post {
_container?.visibility = View.VISIBLE;
_container?.bringToFront();
}
_container?.visibility = View.VISIBLE;
if (_animated) {
_viewOverlayContainer.measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED);
_viewOverlayContainer.translationY = _viewOverlayContainer.measuredHeight.toFloat()
_viewBackground.alpha = 0f;
val animations = arrayListOf<Animator>();
animations.add(ObjectAnimator.ofFloat(_viewBackground, "alpha", 0.0f, 1.0f).setDuration(ANIMATION_DURATION_MS));
animations.add(ObjectAnimator.ofFloat(_viewOverlayContainer, "translationY", _viewOverlayContainer.measuredHeight.toFloat(), 0.0f).setDuration(ANIMATION_DURATION_MS));
@@ -117,6 +117,9 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
private var _isControlsLocked: Boolean = false;
private var _speedHoldPrevRate = 1f
private var _speedHoldWasPlaying = false
private val _time_bar_listener: TimeBar.OnScrubListener;
var isFitMode : Boolean = false
@@ -254,6 +257,20 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
gestureControl = findViewById(R.id.gesture_control);
gestureControl.setupTouchArea(_layoutControls, background);
gestureControl.onSpeedHoldStart.subscribe {
exoPlayer?.player?.let { player ->
_speedHoldWasPlaying = player.isPlaying
_speedHoldPrevRate = getPlaybackRate()
setPlaybackRate(Settings.instance.playback.getHoldPlaybackSpeed().toFloat())
player.play()
}
}
gestureControl.onSpeedHoldEnd.subscribe {
exoPlayer?.player?.let { player ->
if (!_speedHoldWasPlaying) player.pause()
setPlaybackRate(_speedHoldPrevRate)
}
}
gestureControl.onSeek.subscribe { seekFromCurrent(it); };
gestureControl.onSoundAdjusted.subscribe {
if (Settings.instance.gestureControls.useSystemVolume) {
+3 -2
View File
@@ -8,7 +8,7 @@
android:orientation="vertical"
android:paddingTop="10dp"
android:animateLayoutChanges="true">
<ScrollView
<androidx.core.widget.NestedScrollView
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
@@ -152,13 +152,14 @@
android:id="@+id/button_add_sources"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="10dp"
app:buttonIcon="@drawable/ic_explore"
app:buttonText="Add Sources"
app:buttonSubText="Install new sources to see more content."
/>
</LinearLayout>
</ScrollView>
</androidx.core.widget.NestedScrollView>
</LinearLayout>
@@ -195,4 +195,39 @@
app:layout_constraintEnd_toEndOf="parent"
android:visibility="gone"/>
<LinearLayout
android:id="@+id/layout_controls_increased_speed"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:orientation="horizontal"
android:gravity="center_vertical"
android:background="@drawable/background_pill_black"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:paddingTop="4dp"
android:paddingBottom="4dp"
android:layout_marginTop="20dp"
android:visibility="gone">
<TextView
android:id="@+id/text_holdFastForward"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
tools:text="2x"
android:textSize="14dp"
android:textColor="@color/white"
android:fontFamily="@font/inter_regular" />
<ImageView
android:id="@+id/image_holdFastForward"
android:layout_width="wrap_content"
android:layout_height="8dp"
android:adjustViewBounds="true"
app:srcCompat="@drawable/ic_fastforward_animated"
android:layout_marginStart="4dp"/>
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
+1
View File
@@ -3,4 +3,5 @@
<dimen name="minimized_player_max_width">500dp</dimen>
<dimen name="app_bar_height">200dp</dimen>
<integer name="column_width_dp">400</integer>
<integer name="smallest_width_dp">600</integer>
</resources>
+17
View File
@@ -300,6 +300,7 @@
<string name="check_disabled_plugin_updates_description">Check disabled plugins for updates</string>
<string name="planned_content_notifications">Planned Content Notifications</string>
<string name="planned_content_notifications_description">Schedules discovered planned content as notifications, resulting in more accurate notifications for this content.</string>
<string name="show_advanced_media_source_metadata_desc">When displaying media sources show advanced metadata like container and codec</string>
<string name="attempt_to_utilize_byte_ranges">Attempt to utilize byte ranges</string>
<string name="auto_update">Auto Update</string>
<string name="always_allow_reverse_landscape_auto_rotate">Always allow reverse landscape auto-rotate</string>
@@ -348,6 +349,7 @@
<string name="default_audio_quality">Default Audio Quality</string>
<string name="default_playback_speed">Default Playback Speed</string>
<string name="default_video_quality">Default Video Quality</string>
<string name="show_advanced_media_source_metadata">Show Advanced Media Source Metadata</string>
<string name="deletes_license_keys_from_app">Deletes license keys from app</string>
<string name="download_when">Download when</string>
<string name="enable_video_cache">Enable Video Cache</string>
@@ -433,6 +435,8 @@
<string name="min_playback_speed_description">Minimum Available Speed</string>
<string name="max_playback_speed">Maximum Playback Speed</string>
<string name="max_playback_speed_description">Maximum Available Speed</string>
<string name="hold_playback_speed">Hold playback speed</string>
<string name="hold_playback_speed_description">Playback speed when pressing down on the video</string>
<string name="step_playback_speed">Playback Speed Step Size</string>
<string name="step_playback_speed_description">The step size of playback speeds, may not affect higher playback speeds.</string>
<string name="seek_offset_description">Fast-Forward / Fast-Rewind duration</string>
@@ -466,6 +470,9 @@
<string name="playlist_delete_confirmation_description">Show confirmation dialog when deleting media from a playlist</string>
<string name="playlist_allow_dups">Allow duplicate playlist videos</string>
<string name="playlist_allow_dups_description">Allow adding duplicate videos to playlists</string>
<string name="add_to_beginning_of_watch_later">Add new videos to the beginning of Watch Later</string>
<string name="add_to_beginning_description">When adding videos to Watch Later add them to the beginning of the list instead of the end</string>
<string name="already_in_watch_later">Already in watch later</string>
<string name="enable_polycentric">Enable Polycentric</string>
<string name="polycentric_local_cache">Enable Polycentric Local Caching</string>
<string name="polycentric_local_cache_description">Caches polycentric results on-device to reduce load times, changing requires app reboot</string>
@@ -1106,6 +1113,16 @@
<item>4.0</item>
<item>5.0</item>
</string-array>
<string-array name="hold_playback_speeds">
<item>1.25</item>
<item>1.5</item>
<item>1.75</item>
<item>2.0</item>
<item>2.25</item>
<item>2.5</item>
<item>2.75</item>
<item>3.0</item>
</string-array>
<string-array name="min_playback_speed">
<item>0.25</item>
<item>0.5</item>