Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay

This commit is contained in:
Kelvin 2023-12-27 15:21:52 +01:00
commit e599729ba1
17 changed files with 182 additions and 133 deletions

View file

@ -685,7 +685,9 @@ class Settings : FragmentedStorageFileJson() {
fun manualCheck() { fun manualCheck() {
if (!BuildConfig.IS_PLAYSTORE_BUILD) { if (!BuildConfig.IS_PLAYSTORE_BUILD) {
SettingsActivity.getActivity()?.let { SettingsActivity.getActivity()?.let {
StateUpdate.instance.checkForUpdates(it, true); StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
StateUpdate.instance.checkForUpdates(it, true)
}
} }
} else { } else {
SettingsActivity.getActivity()?.let { SettingsActivity.getActivity()?.let {

View file

@ -216,8 +216,10 @@ class AddSourceActivity : AppCompatActivity() {
fun install(config: SourcePluginConfig, script: String) { fun install(config: SourcePluginConfig, script: String) {
StatePlugins.instance.installPlugin(this, lifecycleScope, config, script) { StatePlugins.instance.installPlugin(this, lifecycleScope, config, script) {
if(it) if(it) {
StatePlatform.instance.clearUpdateAvailable(config)
backToSources(); backToSources();
}
} }
} }

View file

@ -9,9 +9,11 @@ import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.* import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.R
import com.futo.platformplayer.logging.LogLevel import com.futo.platformplayer.logging.LogLevel
import com.futo.platformplayer.logging.Logging import com.futo.platformplayer.logging.Logging
import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch

View file

@ -454,6 +454,7 @@ class SourceDetailFragment : MainFragment() {
} }
}); });
} }
private fun checkForUpdatesSource() { private fun checkForUpdatesSource() {
val c = _config ?: return; val c = _config ?: return;
val sourceUrl = c.sourceUrl ?: return; val sourceUrl = c.sourceUrl ?: return;

View file

@ -460,7 +460,9 @@ class StateApp {
//Foreground download //Foreground download
autoUpdateEnabled -> { autoUpdateEnabled -> {
StateUpdate.instance.checkForUpdates(context, false); scopeOrNull?.launch(Dispatchers.IO) {
StateUpdate.instance.checkForUpdates(context, false)
}
} }
else -> { else -> {
@ -558,6 +560,23 @@ class StateApp {
if(StateHistory.instance.shouldMigrateLegacyHistory()) if(StateHistory.instance.shouldMigrateLegacyHistory())
StateHistory.instance.migrateLegacyHistory(); StateHistory.instance.migrateLegacyHistory();
StateAnnouncement.instance.deleteAnnouncement("plugin-update")
scopeOrNull?.launch(Dispatchers.IO) {
val updateAvailableCount = StatePlatform.instance.checkForUpdates()
withContext(Dispatchers.Main) {
if (updateAvailableCount > 0) {
StateAnnouncement.instance.registerAnnouncement(
"plugin-update",
"Plugin updates available",
"There are $updateAvailableCount plugin updates available.",
AnnouncementType.SESSION_RECURRING
)
}
}
}
} }
fun mainAppStartedWithExternalFiles(context: Context) { fun mainAppStartedWithExternalFiles(context: Context) {

View file

@ -5,6 +5,7 @@ import androidx.collection.LruCache
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.Settings import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.IPlatformClient import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.IPluginSourced import com.futo.platformplayer.api.media.IPluginSourced
import com.futo.platformplayer.api.media.PlatformMultiClientPool import com.futo.platformplayer.api.media.PlatformMultiClientPool
@ -78,6 +79,7 @@ class StatePlatform {
private val _clientsLock = Object(); private val _clientsLock = Object();
private val _availableClients : ArrayList<IPlatformClient> = ArrayList(); private val _availableClients : ArrayList<IPlatformClient> = ArrayList();
private val _enabledClients : ArrayList<IPlatformClient> = ArrayList(); private val _enabledClients : ArrayList<IPlatformClient> = ArrayList();
private var _updatesAvailableMap: HashSet<String> = hashSetOf();
//ClientPools are used to isolate plugin usage of certain components from others //ClientPools are used to isolate plugin usage of certain components from others
//This prevents for example a background task like subscriptions from blocking a user from opening a video //This prevents for example a background task like subscriptions from blocking a user from opening a video
@ -932,6 +934,67 @@ class StatePlatform {
} }
} }
fun hasUpdateAvailable(c: SourcePluginConfig): Boolean {
val updatesAvailableMap = _updatesAvailableMap
synchronized(updatesAvailableMap) {
return updatesAvailableMap.contains(c.id)
}
}
suspend fun checkForUpdates(): Int = withContext(Dispatchers.IO) {
var updateAvailableCount = 0
val updatesAvailableFor = hashSetOf<String>()
for (availableClient in getAvailableClients()) {
if (availableClient !is JSClient) {
continue
}
if (checkForUpdates(availableClient.config)) {
updateAvailableCount++
updatesAvailableFor.add(availableClient.config.id)
}
}
_updatesAvailableMap = updatesAvailableFor
return@withContext updateAvailableCount
}
fun clearUpdateAvailable(c: SourcePluginConfig) {
val updatesAvailableMap = _updatesAvailableMap
synchronized(updatesAvailableMap) {
updatesAvailableMap.remove(c.id)
}
}
private suspend fun checkForUpdates(c: SourcePluginConfig): Boolean = withContext(Dispatchers.IO) {
val sourceUrl = c.sourceUrl ?: return@withContext false;
Logger.i(TAG, "Check for source updates '${c.name}'.");
try {
val client = ManagedHttpClient();
val response = client.get(sourceUrl);
Logger.i(TAG, "Downloading source config '$sourceUrl'.");
if (!response.isOk || response.body == null) {
return@withContext false;
}
val configJson = response.body.string();
Logger.i(TAG, "Downloaded source config ($sourceUrl):\n${configJson}");
val config = SourcePluginConfig.fromJson(configJson);
if (config.version <= c.version) {
return@withContext false;
}
Logger.i(TAG, "Update is available (config.version=${config.version}, source.config.version=${c.version}).");
return@withContext true;
} catch (e: Throwable) {
Logger.e(TAG, "Failed to check for updates.", e);
return@withContext false;
}
}
companion object { companion object {
private var _instance : StatePlatform? = null; private var _instance : StatePlatform? = null;
val instance : StatePlatform val instance : StatePlatform

View file

@ -10,7 +10,6 @@ import com.futo.platformplayer.api.media.platforms.js.SourceAuth
import com.futo.platformplayer.api.media.platforms.js.SourceCaptchaData import com.futo.platformplayer.api.media.platforms.js.SourceCaptchaData
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.platforms.js.SourcePluginDescriptor import com.futo.platformplayer.api.media.platforms.js.SourcePluginDescriptor
import com.futo.platformplayer.developer.DeveloperEndpoints
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.ImageVariable import com.futo.platformplayer.models.ImageVariable
import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.FragmentedStorage
@ -467,7 +466,6 @@ class StatePlugins {
_plugins.save(descriptor); _plugins.save(descriptor);
} }
@Serializable @Serializable
private data class PluginConfig( private data class PluginConfig(
val SOURCES_EMBEDDED: Map<String, String>, val SOURCES_EMBEDDED: Map<String, String>,

View file

@ -2,15 +2,15 @@ package com.futo.platformplayer.states
import android.content.Context import android.content.Context
import android.os.Build import android.os.Build
import android.os.Environment import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.* import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.copyToOutputStream
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File import java.io.File
import java.io.FileNotFoundException
import java.io.InputStream import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
@ -155,47 +155,45 @@ class StateUpdate {
} }
} }
fun checkForUpdates(context: Context, showUpToDateToast: Boolean) { suspend fun checkForUpdates(context: Context, showUpToDateToast: Boolean) = withContext(Dispatchers.IO) {
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { try {
try { val client = ManagedHttpClient();
val client = ManagedHttpClient(); val latestVersion = downloadVersionCode(client);
val latestVersion = downloadVersionCode(client);
if (latestVersion != null) { if (latestVersion != null) {
val currentVersion = BuildConfig.VERSION_CODE; val currentVersion = BuildConfig.VERSION_CODE;
Logger.i(TAG, "Current version ${currentVersion} latest version ${latestVersion}."); Logger.i(TAG, "Current version ${currentVersion} latest version ${latestVersion}.");
if (latestVersion > currentVersion) { if (latestVersion > currentVersion) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
try { try {
UIDialogs.showUpdateAvailableDialog(context, latestVersion); UIDialogs.showUpdateAvailableDialog(context, latestVersion);
} catch (e: Throwable) { } catch (e: Throwable) {
UIDialogs.toast(context, "Failed to show update dialog"); UIDialogs.toast(context, "Failed to show update dialog");
Logger.w(TAG, "Error occurred in update dialog."); Logger.w(TAG, "Error occurred in update dialog.");
}
}
} else {
if (showUpToDateToast) {
withContext(Dispatchers.Main) {
UIDialogs.toast(context, "Already on latest version");
}
} }
} }
} else { } else {
Logger.w(TAG, "Failed to retrieve version from version URL."); if (showUpToDateToast) {
withContext(Dispatchers.Main) {
withContext(Dispatchers.Main) { UIDialogs.toast(context, "Already on latest version");
UIDialogs.toast(context, "Failed to retrieve version"); }
} }
} }
} catch (e: Throwable) { } else {
Logger.w(TAG, "Failed to check for updates.", e); Logger.w(TAG, "Failed to retrieve version from version URL.");
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
UIDialogs.toast(context, "Failed to check for updates"); UIDialogs.toast(context, "Failed to retrieve version");
} }
} }
}; } catch (e: Throwable) {
Logger.w(TAG, "Failed to check for updates.", e);
withContext(Dispatchers.Main) {
UIDialogs.toast(context, "Failed to check for updates");
}
}
} }
private fun downloadApkToFile(client: ManagedHttpClient, destinationFile: File, isCancelled: (() -> Boolean)? = null) { private fun downloadApkToFile(client: ManagedHttpClient, destinationFile: File, isCancelled: (() -> Boolean)? = null) {

View file

@ -1,42 +0,0 @@
package com.futo.platformplayer.views.adapters
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.R
import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.constructs.Event1
class DisabledSourceAdapter : RecyclerView.Adapter<DisabledSourceViewHolder> {
private val _sources: MutableList<IPlatformClient>;
var onClick = Event1<IPlatformClient>();
var onAdd = Event1<IPlatformClient>();
constructor(sources: MutableList<IPlatformClient>) : super() {
_sources = sources;
}
override fun getItemCount() = _sources.size
override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): DisabledSourceViewHolder {
val holder = DisabledSourceViewHolder(viewGroup);
holder.onAdd.subscribe {
val source = holder.source;
if (source != null) {
onAdd.emit(source);
}
}
holder.onClick.subscribe {
val source = holder.source;
if (source != null) {
onClick.emit(source);
}
};
return holder;
}
override fun onBindViewHolder(viewHolder: DisabledSourceViewHolder, position: Int) {
viewHolder.bind(_sources[position])
}
}

View file

@ -1,17 +1,15 @@
package com.futo.platformplayer.views.adapters package com.futo.platformplayer.views.adapters
import android.content.Context import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.ImageView import android.widget.ImageView
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.api.media.IPlatformClient import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.states.StatePlatform
class DisabledSourceView : LinearLayout { class DisabledSourceView : LinearLayout {
private val _root: LinearLayout; private val _root: LinearLayout;
@ -38,7 +36,16 @@ class DisabledSourceView : LinearLayout {
client.icon?.setImageView(_imageSource); client.icon?.setImageView(_imageSource);
_textSource.text = client.name; _textSource.text = client.name;
_textSourceSubtitle.text = context.getString(R.string.tap_to_open);
if (client is JSClient && StatePlatform.instance.hasUpdateAvailable(client.config)) {
_textSourceSubtitle.text = context.getString(R.string.update_available_exclamation)
_textSourceSubtitle.setTextColor(context.getColor(R.color.light_blue_400))
_textSourceSubtitle.typeface = resources.getFont(R.font.inter_regular)
} else {
_textSourceSubtitle.text = context.getString(R.string.tap_to_open)
_textSourceSubtitle.setTextColor(context.getColor(R.color.gray_ac))
_textSourceSubtitle.typeface = resources.getFont(R.font.inter_extra_light)
}
_buttonAdd.setOnClickListener { onAdd.emit(source) } _buttonAdd.setOnClickListener { onAdd.emit(source) }
_root.setOnClickListener { onClick.emit(); }; _root.setOnClickListener { onClick.emit(); };

View file

@ -1,44 +0,0 @@
package com.futo.platformplayer.views.adapters
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import com.futo.platformplayer.R
import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.constructs.Event0
class DisabledSourceViewHolder : ViewHolder {
private val _imageSource: ImageView;
private val _textSource: TextView;
private val _textSourceSubtitle: TextView;
private val _buttonAdd: LinearLayout;
var onClick = Event0();
var onAdd = Event0();
var source: IPlatformClient? = null
private set
constructor(viewGroup: ViewGroup) : super(LayoutInflater.from(viewGroup.context).inflate(R.layout.list_source_disabled, viewGroup, false)) {
_imageSource = itemView.findViewById(R.id.image_source);
_textSource = itemView.findViewById(R.id.text_source);
_textSourceSubtitle = itemView.findViewById(R.id.text_source_subtitle);
_buttonAdd = itemView.findViewById(R.id.button_add);
val root = itemView.findViewById<LinearLayout>(R.id.root);
_buttonAdd.setOnClickListener { onAdd.emit() }
root.setOnClickListener { onClick.emit(); };
}
fun bind(client: IPlatformClient) {
client.icon?.setImageView(_imageSource);
_textSource.text = client.name;
_textSourceSubtitle.text = itemView.context.getString(R.string.tap_to_open);
source = client;
}
}

View file

@ -10,7 +10,9 @@ import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView.ViewHolder import androidx.recyclerview.widget.RecyclerView.ViewHolder
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.api.media.IPlatformClient import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.states.StatePlatform
class EnabledSourceViewHolder : ViewHolder { class EnabledSourceViewHolder : ViewHolder {
private val _imageSource: ImageView; private val _imageSource: ImageView;
@ -57,8 +59,18 @@ class EnabledSourceViewHolder : ViewHolder {
fun bind(client: IPlatformClient) { fun bind(client: IPlatformClient) {
client.icon?.setImageView(_imageSource); client.icon?.setImageView(_imageSource);
_textSource.text = client.name; _textSource.text = client.name
_textSourceSubtitle.text = itemView.context.getString(R.string.tap_to_open);
if (client is JSClient && StatePlatform.instance.hasUpdateAvailable(client.config)) {
_textSourceSubtitle.text = itemView.context.getString(R.string.update_available_exclamation)
_textSourceSubtitle.setTextColor(itemView.context.getColor(R.color.light_blue_400))
_textSourceSubtitle.typeface = itemView.resources.getFont(R.font.inter_regular)
} else {
_textSourceSubtitle.text = itemView.context.getString(R.string.tap_to_open)
_textSourceSubtitle.setTextColor(itemView.context.getColor(R.color.gray_ac))
_textSourceSubtitle.typeface = itemView.resources.getFont(R.font.inter_extra_light)
}
source = client source = client
} }
} }

View file

@ -25,6 +25,7 @@ class SourceHeaderView : LinearLayout {
private val _sourcePlatformUrl: TextView; private val _sourcePlatformUrl: TextView;
private val _sourceRepositoryUrl: TextView; private val _sourceRepositoryUrl: TextView;
private val _sourceScriptUrl: TextView; private val _sourceScriptUrl: TextView;
private val _sourceScriptConfig: TextView;
private val _sourceSignature: TextView; private val _sourceSignature: TextView;
private val _sourcePlatformUrlContainer: LinearLayout; private val _sourcePlatformUrlContainer: LinearLayout;
@ -45,6 +46,7 @@ class SourceHeaderView : LinearLayout {
_sourcePlatformUrl = findViewById(R.id.source_platform); _sourcePlatformUrl = findViewById(R.id.source_platform);
_sourcePlatformUrlContainer = findViewById(R.id.source_platform_container); _sourcePlatformUrlContainer = findViewById(R.id.source_platform_container);
_sourceScriptUrl = findViewById(R.id.source_script); _sourceScriptUrl = findViewById(R.id.source_script);
_sourceScriptConfig = findViewById(R.id.source_config);
_sourceSignature = findViewById(R.id.source_signature); _sourceSignature = findViewById(R.id.source_signature);
_sourceBy.setOnClickListener { _sourceBy.setOnClickListener {
@ -59,6 +61,10 @@ class SourceHeaderView : LinearLayout {
if(!_config?.absoluteScriptUrl.isNullOrEmpty()) if(!_config?.absoluteScriptUrl.isNullOrEmpty())
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(_config?.absoluteScriptUrl))); context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(_config?.absoluteScriptUrl)));
}; };
_sourceScriptConfig.setOnClickListener {
if(!_config?.sourceUrl.isNullOrEmpty())
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(_config?.sourceUrl)));
}
_sourcePlatformUrl.setOnClickListener { _sourcePlatformUrl.setOnClickListener {
if(!_config?.platformUrl.isNullOrEmpty()) if(!_config?.platformUrl.isNullOrEmpty())
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(_config?.platformUrl))); context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(_config?.platformUrl)));
@ -82,6 +88,7 @@ class SourceHeaderView : LinearLayout {
_sourceVersion.text = config.version.toString(); _sourceVersion.text = config.version.toString();
_sourceScriptUrl.text = config.absoluteScriptUrl; _sourceScriptUrl.text = config.absoluteScriptUrl;
_sourceRepositoryUrl.text = config.repositoryUrl; _sourceRepositoryUrl.text = config.repositoryUrl;
_sourceScriptConfig.text = config.sourceUrl
_sourceAuthorID.text = ""; _sourceAuthorID.text = "";
_sourcePlatformUrl.text = config.platformUrl ?: ""; _sourcePlatformUrl.text = config.platformUrl ?: "";

View file

@ -170,6 +170,28 @@
tools:text="https://some.repository.url/whatever/someScript.js" /> tools:text="https://some.repository.url/whatever/someScript.js" />
</LinearLayout> </LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="14dp"
android:textColor="@color/white"
android:layout_marginTop="10dp"
android:fontFamily="@font/inter_light"
android:text="@string/config_url" />
<TextView
android:id="@+id/source_config"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="14dp"
android:textColor="@color/colorPrimary"
android:fontFamily="@font/inter_extra_light"
tools:text="https://some.repository.url/whatever/someScript.js" />
</LinearLayout>
<!--Script Url--> <!--Script Url-->
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"

View file

@ -113,6 +113,7 @@
<string name="platform_url">Platform URL</string> <string name="platform_url">Platform URL</string>
<string name="repository_url">Repository URL</string> <string name="repository_url">Repository URL</string>
<string name="script_url">Script URL</string> <string name="script_url">Script URL</string>
<string name="config_url">Config URL</string>
<string name="source_permissions_explanation">These are the permissions the plugin requires to function</string> <string name="source_permissions_explanation">These are the permissions the plugin requires to function</string>
<string name="source_explain_eval_access">The plugin will have access to eval capacity</string> <string name="source_explain_eval_access">The plugin will have access to eval capacity</string>
<string name="source_explain_script_url">The plugin will have access to the following domains</string> <string name="source_explain_script_url">The plugin will have access to the following domains</string>
@ -651,6 +652,7 @@
<string name="please_use_at_least_3_characters">Please use at least 3 characters</string> <string name="please_use_at_least_3_characters">Please use at least 3 characters</string>
<string name="are_you_sure_you_want_to_delete_this_video">Are you sure you want to delete this video?</string> <string name="are_you_sure_you_want_to_delete_this_video">Are you sure you want to delete this video?</string>
<string name="tap_to_open">Tap to open</string> <string name="tap_to_open">Tap to open</string>
<string name="update_available_exclamation">Update available!</string>
<string name="watching">watching</string> <string name="watching">watching</string>
<string name="available_in">available in</string> <string name="available_in">available in</string>
<string name="seconds">seconds</string> <string name="seconds">seconds</string>

@ -1 +1 @@
Subproject commit a21ad56829b0f0b45bbd677979f3d260e13abb34 Subproject commit 206d996801b9734cae13093859375c70be980a8d

@ -1 +1 @@
Subproject commit a21ad56829b0f0b45bbd677979f3d260e13abb34 Subproject commit 206d996801b9734cae13093859375c70be980a8d