1.8.3 pre

pull/744/head
deniscerri 1 year ago
parent e4c08c2c45
commit 3b3f79719d
No known key found for this signature in database
GPG Key ID: 95C43D517D830350

@ -1,5 +1,30 @@
# YTDLnis Changelog # YTDLnis Changelog
> # 1.8.3 (2025-03)
# What's Changed
## Automatically generate PO Tokens & Visitor Data with NewPipe Extractor
Thanks to NewPipe Extractor for allowing the use of Po Token Generators, and some references from LibreTube, the app now has a po token generator using webview. Now all NewPipe actions like data fetching and format fetching will work.
Also at the same time these records are stored in the apps preferences. In advanced settings now there is a toggle to enable the use of the tokens in yt-dlp.
These tokens need visitor data to work and since visitor data and cookies cant be used at the same time, cookies will be disabled if you enable this function.
With it, the app will apply these settings as youtube extractor arguments
youtube:player_client=web;po_token=web.gvs+GVS_TOKEN,web.player+PLAYER_TOKEN;player-skip=webpage,configs;visitor_data=VISITOR_DATA
If you have po tokens set up with web client with po token, there might be duplication happening, so check that out.
Other stuff
- Cut Section has been reworked,thanks to madmini. Now you can cut down to milliseconds.
- Added safeBrowsingEnabled in WebView for generating cookies for devices of API 26 and above. Thought to generate incognito cookies that last longer
- Added feature to reset all settings belonging to a certain preference page
- Added option to turn off the code color highlighter
- Fixed app not applying prefer small formats. It shouldve been last in format sorting not first.
- Added ability to enable automatic backup when the app checks for new update and finds one
- Added write-subs and write-auto-subs and --compat-options no-keep-subs when the user embeds subs but doesnt want to save them so he can get more subtitles to embed
- Turned the changelog dialog to a separate screen for better visibility
- Some small bug fix here and there
> # 1.8.2.2 (2025-02) > # 1.8.2.2 (2025-02)
# What's Changed # What's Changed

@ -11,8 +11,8 @@ plugins {
def properties = new Properties() def properties = new Properties()
def versionMajor = 1 def versionMajor = 1
def versionMinor = 8 def versionMinor = 8
def versionPatch = 2 def versionPatch = 3
def versionBuild = 2 // bump for dogfood builds, public betas, etc. def versionBuild = 0 // bump for dogfood builds, public betas, etc.
def isBeta = false def isBeta = false
def versionExt = "" def versionExt = ""
@ -215,5 +215,5 @@ dependencies {
implementation 'androidx.compose.material3:material3-android:1.3.1' implementation 'androidx.compose.material3:material3-android:1.3.1'
implementation "io.noties.markwon:core:4.6.2" implementation "io.noties.markwon:core:4.6.2"
implementation("org.greenrobot:eventbus:3.3.1") implementation("org.greenrobot:eventbus:3.3.1")
implementation("com.github.teamnewpipe:newpipeextractor:0.24.4") implementation("com.github.teamnewpipe:newpipeextractor:0.24.5")
} }

@ -0,0 +1,127 @@
<!DOCTYPE html>
<html lang="en"><head><title></title><script>
/**
* Factory method to create and load a BotGuardClient instance.
* @param options - Configuration options for the BotGuardClient.
* @returns A promise that resolves to a loaded BotGuardClient instance.
*/
function loadBotGuard(challengeData) {
this.vm = this[challengeData.globalName];
this.program = challengeData.program;
this.vmFunctions = {};
this.syncSnapshotFunction = null;
if (!this.vm)
throw new Error('[BotGuardClient]: VM not found in the global object');
if (!this.vm.a)
throw new Error('[BotGuardClient]: Could not load program');
const vmFunctionsCallback = function (
asyncSnapshotFunction,
shutdownFunction,
passEventFunction,
checkCameraFunction
) {
this.vmFunctions = {
asyncSnapshotFunction: asyncSnapshotFunction,
shutdownFunction: shutdownFunction,
passEventFunction: passEventFunction,
checkCameraFunction: checkCameraFunction
};
};
this.syncSnapshotFunction = this.vm.a(this.program, vmFunctionsCallback, true, this.userInteractionElement, function () {/** no-op */ }, [ [], [] ])[0]
// an asynchronous function runs in the background and it will eventually call
// `vmFunctionsCallback`, however we need to manually tell JavaScript to pass
// control to the things running in the background by interrupting this async
// function in any way, e.g. with a delay of 1ms. The loop is most probably not
// needed but is there just because.
return new Promise(function (resolve, reject) {
i = 0
refreshIntervalId = setInterval(function () {
if (!!this.vmFunctions.asyncSnapshotFunction) {
resolve(this)
clearInterval(refreshIntervalId);
}
if (i >= 10000) {
reject("asyncSnapshotFunction is null even after 10 seconds")
clearInterval(refreshIntervalId);
}
i += 1;
}, 1);
})
}
/**
* Takes a snapshot asynchronously.
* @returns The snapshot result.
* @example
* ```ts
* const result = await botguard.snapshot({
* contentBinding: {
* c: "a=6&a2=10&b=SZWDwKVIuixOp7Y4euGTgwckbJA&c=1729143849&d=1&t=7200&c1a=1&c6a=1&c6b=1&hh=HrMb5mRWTyxGJphDr0nW2Oxonh0_wl2BDqWuLHyeKLo",
* e: "ENGAGEMENT_TYPE_VIDEO_LIKE",
* encryptedVideoId: "P-vC09ZJcnM"
* }
* });
*
* console.log(result);
* ```
*/
function snapshot(args) {
return new Promise(function (resolve, reject) {
if (!this.vmFunctions.asyncSnapshotFunction)
return reject(new Error('[BotGuardClient]: Async snapshot function not found'));
this.vmFunctions.asyncSnapshotFunction(function (response) { resolve(response) }, [
args.contentBinding,
args.signedTimestamp,
args.webPoSignalOutput,
args.skipPrivacyBuffer
]);
});
}
function runBotGuard(challengeData) {
const interpreterJavascript = challengeData.interpreterJavascript.privateDoNotAccessOrElseSafeScriptWrappedValue;
if (interpreterJavascript) {
new Function(interpreterJavascript)();
} else throw new Error('Could not load VM');
const webPoSignalOutput = [];
return loadBotGuard({
globalName: challengeData.globalName,
globalObj: this,
program: challengeData.program
}).then(function (botguard) {
return botguard.snapshot({ webPoSignalOutput: webPoSignalOutput })
}).then(function (botguardResponse) {
return { webPoSignalOutput: webPoSignalOutput, botguardResponse: botguardResponse }
})
}
function obtainPoToken(webPoSignalOutput, integrityToken, identifier) {
const getMinter = webPoSignalOutput[0];
if (!getMinter)
throw new Error('PMD:Undefined');
const mintCallback = getMinter(integrityToken);
if (!(mintCallback instanceof Function))
throw new Error('APF:Failed');
const result = mintCallback(identifier);
if (!result)
throw new Error('YNJ:Undefined');
if (!(result instanceof Uint8Array))
throw new Error('ODM:Invalid');
return result;
}
</script></head><body></body></html>

@ -5,7 +5,8 @@ data class YoutubePlayerClientItem(
var poTokens: MutableList<YoutubePoTokenItem>, var poTokens: MutableList<YoutubePoTokenItem>,
var enabled: Boolean = true, var enabled: Boolean = true,
var useOnlyPoToken: Boolean = false, var useOnlyPoToken: Boolean = false,
var urlRegex: MutableList<String> = mutableListOf() var urlRegex: MutableList<String> = mutableListOf(),
var autoGenerated: Boolean = false
) )
data class YoutubePoTokenItem( data class YoutubePoTokenItem(

@ -77,7 +77,7 @@ class YoutubePlayerClientAdapter(onItemClickListener: OnItemClickListener, activ
} }
for (chip in chips.reversed()) { for (chip in chips.reversed()) {
content.addView(chip, 0) content.addView(chip)
} }
if (item.urlRegex.isNotEmpty()) { if (item.urlRegex.isNotEmpty()) {
@ -91,6 +91,8 @@ class YoutubePlayerClientAdapter(onItemClickListener: OnItemClickListener, activ
title.alpha = if (item.enabled) 1f else 0.3f title.alpha = if (item.enabled) 1f else 0.3f
content.alpha = if (item.enabled) 1f else 0.3f content.alpha = if (item.enabled) 1f else 0.3f
card.findViewById<MaterialSwitch>(R.id.autoGeneratedNewPipe).isVisible = item.autoGenerated
val switch = card.findViewById<MaterialSwitch>(R.id.materialSwitch) val switch = card.findViewById<MaterialSwitch>(R.id.materialSwitch)
switch.isChecked = item.enabled switch.isChecked = item.enabled
switch.setOnClickListener { switch.setOnClickListener {

@ -2,6 +2,7 @@ package com.deniscerri.ytdl.ui.more
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.SharedPreferences import android.content.SharedPreferences
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.webkit.CookieManager import android.webkit.CookieManager
import android.webkit.WebView import android.webkit.WebView
@ -132,6 +133,9 @@ class WebViewActivity : BaseActivity() {
javaScriptEnabled = true javaScriptEnabled = true
domStorageEnabled = true domStorageEnabled = true
javaScriptCanOpenWindowsAutomatically = true javaScriptCanOpenWindowsAutomatically = true
if (Build.VERSION.SDK_INT >= 26) {
safeBrowsingEnabled = true
}
preferences.edit().putString("useragent_header", userAgentString).apply() preferences.edit().putString("useragent_header", userAgentString).apply()
} }
cookieManager.setAcceptThirdPartyCookies(this, true) cookieManager.setAcceptThirdPartyCookies(this, true)

@ -6,9 +6,11 @@ import android.graphics.Typeface
import android.os.Bundle import android.os.Bundle
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.preference.Preference import androidx.preference.Preference
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import androidx.preference.SwitchPreferenceCompat
import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
@ -17,7 +19,11 @@ import com.deniscerri.ytdl.R
import com.deniscerri.ytdl.ui.adapter.SortableTextItemAdapter import com.deniscerri.ytdl.ui.adapter.SortableTextItemAdapter
import com.deniscerri.ytdl.ui.more.settings.BaseSettingsFragment import com.deniscerri.ytdl.ui.more.settings.BaseSettingsFragment
import com.deniscerri.ytdl.util.UiUtil import com.deniscerri.ytdl.util.UiUtil
import com.deniscerri.ytdl.util.extractors.newpipe.NewPipeUtil
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class AdvancedSettingsFragment : BaseSettingsFragment() { class AdvancedSettingsFragment : BaseSettingsFragment() {
@ -33,6 +39,53 @@ class AdvancedSettingsFragment : BaseSettingsFragment() {
false false
} }
val newPipeUtil = NewPipeUtil(requireContext())
findPreference<SwitchPreferenceCompat>("use_newpipe_potoken")?.apply {
fun getValues() : Triple<String, String, String> {
val gvs = prefs.getString("newpipe_gvs_potoken", "")!!
val player = prefs.getString("newpipe_player_potoken", "")!!
val visitorData = prefs.getString("newpipe_visitordata", "")!!
return Triple(gvs, player, visitorData)
}
fun updateSummary() : String {
val values = getValues()
val gvs = values.first
val player = values.second
val visitorData = values.third
return if (visitorData.isNotBlank()) {
"PO Token (GVS): ${gvs.take(10)}...\nPO Token (Player): ${player.take(10)}...\nVisitor Data: ${visitorData.take(10)}..."
}else {
""
}
}
summary = updateSummary()
setOnPreferenceClickListener {
if (this.isChecked) {
this.isChecked = false
UiUtil.showGenericConfirmDialog(requireContext(), getString(R.string.use_newpipe_potoken), getString(R.string.use_newpipe_potoken_warning)) {
editor.putBoolean("use_cookies", false)
editor.putBoolean("use_newpipe_token", true)
editor.apply()
this.isChecked = true
lifecycleScope.launch {
summary = getString(R.string.loading)
withContext(Dispatchers.IO) {
newPipeUtil.testRun()
}
summary = updateSummary()
}
}
}
false
}
}
val formatImportanceAudio: Preference? = findPreference("format_importance_audio") val formatImportanceAudio: Preference? = findPreference("format_importance_audio")
val formatImportanceVideo: Preference? = findPreference("format_importance_video") val formatImportanceVideo: Preference? = findPreference("format_importance_video")

@ -2,27 +2,44 @@ package com.deniscerri.ytdl.ui.more.settings.advanced
import android.animation.AnimatorSet import android.animation.AnimatorSet
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.Activity
import android.content.ClipboardManager
import android.content.SharedPreferences import android.content.SharedPreferences
import android.os.Bundle import android.os.Bundle
import android.text.Editable
import android.text.TextWatcher
import android.util.Log import android.util.Log
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.Window
import android.view.animation.AccelerateDecelerateInterpolator import android.view.animation.AccelerateDecelerateInterpolator
import android.widget.Button
import android.widget.LinearLayout
import android.widget.RelativeLayout import android.widget.RelativeLayout
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.children
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.widget.doOnTextChanged
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.afollestad.materialdialogs.utils.MDUtil.getStringArray
import com.deniscerri.ytdl.R import com.deniscerri.ytdl.R
import com.deniscerri.ytdl.database.models.YoutubePlayerClientItem import com.deniscerri.ytdl.database.models.YoutubePlayerClientItem
import com.deniscerri.ytdl.database.models.YoutubePoTokenItem
import com.deniscerri.ytdl.ui.adapter.YoutubePlayerClientAdapter import com.deniscerri.ytdl.ui.adapter.YoutubePlayerClientAdapter
import com.deniscerri.ytdl.ui.more.settings.SettingsActivity import com.deniscerri.ytdl.ui.more.settings.SettingsActivity
import com.deniscerri.ytdl.util.UiUtil import com.deniscerri.ytdl.util.UiUtil
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.card.MaterialCardView import com.google.android.material.card.MaterialCardView
import com.google.android.material.chip.Chip import com.google.android.material.chip.Chip
import com.google.android.material.chip.ChipGroup
import com.google.android.material.materialswitch.MaterialSwitch
import com.google.android.material.textfield.TextInputLayout
import com.google.gson.Gson import com.google.gson.Gson
@ -64,8 +81,8 @@ class YoutubePlayerClientFragment : Fragment(), YoutubePlayerClientAdapter.OnIte
val newClient = view.findViewById<Chip>(R.id.newClient) val newClient = view.findViewById<Chip>(R.id.newClient)
newClient.setOnClickListener { newClient.setOnClickListener {
UiUtil.showYoutubePlayerClientSheet( showYoutubePlayerClientSheet(
settingsActivity, preferences, null, null,
newValue = { newItem -> newValue = { newItem ->
currentList.add(newItem) currentList.add(newItem)
currentListRaw = Gson().toJson(currentList).toString() currentListRaw = Gson().toJson(currentList).toString()
@ -97,9 +114,147 @@ class YoutubePlayerClientFragment : Fragment(), YoutubePlayerClientAdapter.OnIte
} }
private fun showYoutubePlayerClientSheet(currentValue: YoutubePlayerClientItem?, newValue: (item: YoutubePlayerClientItem) -> Unit, deleted: () -> Unit){
val bottomSheet = BottomSheetDialog(requireContext())
bottomSheet.requestWindowFeature(Window.FEATURE_NO_TITLE)
bottomSheet.setContentView(R.layout.youtube_player_client_create_bottom_sheet)
val title : TextInputLayout = bottomSheet.findViewById(R.id.title)!!
val chipGroup : ChipGroup = bottomSheet.findViewById(R.id.chipGroup)!!
val suggestedLabel : View = bottomSheet.findViewById(R.id.suggestedLabel)!!
val okBtn : Button = bottomSheet.findViewById(R.id.client_create)!!
val deleteBtn : Button = bottomSheet.findViewById(R.id.client_delete)!!
deleteBtn.isVisible = currentValue != null
val useOnlyPOToken : MaterialSwitch = bottomSheet.findViewById(R.id.use_only_po_token)!!
useOnlyPOToken.isChecked = currentValue?.useOnlyPoToken ?: false
val contentLinear : LinearLayout = bottomSheet.findViewById(R.id.contentLinear)!!
val defaultChips = requireContext().getStringArray(R.array.youtube_player_clients).toMutableSet()
title.isEndIconVisible = false
title.editText!!.addTextChangedListener(object : TextWatcher {
override fun beforeTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {}
override fun onTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {}
override fun afterTextChanged(p0: Editable?) {
chipGroup.children.forEach { (it as Chip).isChecked = false }
chipGroup.children.firstOrNull { (it as Chip).text == p0.toString() }?.apply {
(this as Chip).isChecked = true
}
}
})
val existingConfigsRaw = preferences.getString("youtube_player_clients", "[]")
val existingConfigs = Gson().fromJson(existingConfigsRaw, Array<YoutubePlayerClientItem>::class.java).toMutableList()
defaultChips.filter { it.isNotBlank() }.forEach {
if (!existingConfigs.any { it2 -> it2.playerClient == it }) {
val tmp = requireActivity().layoutInflater.inflate(R.layout.filter_chip, chipGroup, false) as Chip
tmp.text = it
tmp.setOnClickListener {
title.editText!!.setText(tmp.text.toString())
}
chipGroup.addView(tmp)
}
}
if (chipGroup.children.count() == 0) {
suggestedLabel.isVisible = false
}
val poTokenInputs = contentLinear.children.filter { it is TextInputLayout }.map { it as TextInputLayout }.toList()
for(p in poTokenInputs) {
p.setEndIconOnClickListener {
val clipboard: ClipboardManager = requireActivity().getSystemService(AppCompatActivity.CLIPBOARD_SERVICE) as ClipboardManager
p.editText!!.setText(clipboard.primaryClip?.getItemAt(0)?.text)
}
}
currentValue?.apply {
title.editText!!.setText(this.playerClient)
for (t in this.poTokens) {
poTokenInputs.firstOrNull { it.tag == "potoken_${t.context}" }?.editText?.setText(t.token)
}
okBtn.text = requireContext().getString(R.string.edit)
}
val urlRegexInput = contentLinear.findViewById<TextInputLayout>(R.id.url_regex)
urlRegexInput.isEndIconVisible = false
urlRegexInput.editText!!.doOnTextChanged { text, start, before, count ->
urlRegexInput.isEndIconVisible = urlRegexInput.editText!!.text.isNotBlank()
}
val urlRegexChips = contentLinear.findViewById<ChipGroup>(R.id.urlRegexChipGroup)
currentValue?.apply {
for(chip in this.urlRegex) {
val tmp = requireActivity().layoutInflater.inflate(R.layout.input_chip, urlRegexChips, false) as Chip
tmp.text = chip
tmp.setOnClickListener {
urlRegexChips.removeView(tmp)
}
urlRegexChips.addView(tmp)
}
}
urlRegexInput.setEndIconOnClickListener {
val text = urlRegexInput.editText!!.text
urlRegexInput.editText!!.setText("")
val tmp = requireActivity().layoutInflater.inflate(R.layout.input_chip, urlRegexChips, false) as Chip
tmp.text = text
tmp.setOnClickListener {
urlRegexChips.removeView(tmp)
}
urlRegexChips.addView(tmp)
}
okBtn.setOnClickListener {
val titleVal = title.editText!!.text.toString()
if (titleVal.isBlank()) {
title.error = "Player Client tag shouldn't be empty"
return@setOnClickListener
}
if(existingConfigs.any { it2 -> it2.playerClient == titleVal && it2.enabled } && ( currentValue == null || currentValue.playerClient != titleVal )) {
title.error = "Player Client is already created"
return@setOnClickListener
}
if (useOnlyPOToken.isChecked && (poTokenInputs.filter { it.tag.toString().startsWith("potoken") }.all { it.editText!!.text.isBlank() })) {
poTokenInputs.first().error = "You need to write at least one PO Token"
return@setOnClickListener
}
val obj = YoutubePlayerClientItem(titleVal, mutableListOf(), true, useOnlyPOToken.isChecked)
poTokenInputs.filter { it.editText!!.text.isNotBlank() && it.tag.toString().startsWith("potoken") }.forEach {
obj.poTokens.add(YoutubePoTokenItem(it.tag.toString().split("potoken_")[1], it.editText!!.text.toString()))
}
val urlRegexes = urlRegexChips.children.map { (it as Chip).text.toString() }
obj.urlRegex.addAll(urlRegexes)
bottomSheet.cancel()
newValue(obj)
}
deleteBtn.setOnClickListener {
bottomSheet.cancel()
deleted()
}
bottomSheet.show()
bottomSheet.behavior.state = BottomSheetBehavior.STATE_EXPANDED
bottomSheet.window!!.setLayout(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
}
override fun onItemClick(item: YoutubePlayerClientItem, index: Int) { override fun onItemClick(item: YoutubePlayerClientItem, index: Int) {
UiUtil.showYoutubePlayerClientSheet( showYoutubePlayerClientSheet(
settingsActivity, preferences, item, item,
newValue = { newItem -> newValue = { newItem ->
currentList.remove(item) currentList.remove(item)
currentList.add(newItem) currentList.add(newItem)

@ -2367,143 +2367,6 @@ object UiUtil {
) )
} }
fun showYoutubePlayerClientSheet(context: Activity, preferences: SharedPreferences, currentValue: YoutubePlayerClientItem?, newValue: (item: YoutubePlayerClientItem) -> Unit, deleted: () -> Unit){
val bottomSheet = BottomSheetDialog(context)
bottomSheet.requestWindowFeature(Window.FEATURE_NO_TITLE)
bottomSheet.setContentView(R.layout.youtube_player_client_create_bottom_sheet)
val title : TextInputLayout = bottomSheet.findViewById(R.id.title)!!
val chipGroup : ChipGroup = bottomSheet.findViewById(R.id.chipGroup)!!
val suggestedLabel : View = bottomSheet.findViewById(R.id.suggestedLabel)!!
val okBtn : Button = bottomSheet.findViewById(R.id.client_create)!!
val deleteBtn : Button = bottomSheet.findViewById(R.id.client_delete)!!
deleteBtn.isVisible = currentValue != null
val useOnlyPOToken : MaterialSwitch = bottomSheet.findViewById(R.id.use_only_po_token)!!
useOnlyPOToken.isChecked = currentValue?.useOnlyPoToken ?: false
val contentLinear : LinearLayout = bottomSheet.findViewById(R.id.contentLinear)!!
val defaultChips = context.getStringArray(R.array.youtube_player_clients).toMutableSet()
title.isEndIconVisible = false
title.editText!!.addTextChangedListener(object : TextWatcher {
override fun beforeTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {}
override fun onTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {}
override fun afterTextChanged(p0: Editable?) {
chipGroup.children.forEach { (it as Chip).isChecked = false }
chipGroup.children.firstOrNull { (it as Chip).text == p0.toString() }?.apply {
(this as Chip).isChecked = true
}
}
})
val existingConfigsRaw = preferences.getString("youtube_player_clients", "[]")
val existingConfigs = Gson().fromJson(existingConfigsRaw, Array<YoutubePlayerClientItem>::class.java).toMutableList()
defaultChips.filter { it.isNotBlank() }.forEach {
if (!existingConfigs.any { it2 -> it2.playerClient == it }) {
val tmp = context.layoutInflater.inflate(R.layout.filter_chip, chipGroup, false) as Chip
tmp.text = it
tmp.setOnClickListener {
title.editText!!.setText(tmp.text.toString())
}
chipGroup.addView(tmp)
}
}
if (chipGroup.children.count() == 0) {
suggestedLabel.isVisible = false
}
val poTokenInputs = contentLinear.children.filter { it is TextInputLayout }.map { it as TextInputLayout }.toList()
for(p in poTokenInputs) {
p.setEndIconOnClickListener {
val clipboard: ClipboardManager = context.getSystemService(AppCompatActivity.CLIPBOARD_SERVICE) as ClipboardManager
p.editText!!.setText(clipboard.primaryClip?.getItemAt(0)?.text)
}
}
currentValue?.apply {
title.editText!!.setText(this.playerClient)
for (t in this.poTokens) {
poTokenInputs.firstOrNull { it.tag == "potoken_${t.context}" }?.editText?.setText(t.token)
}
okBtn.text = context.getString(R.string.edit)
}
val urlRegexInput = contentLinear.findViewById<TextInputLayout>(R.id.url_regex)
urlRegexInput.isEndIconVisible = false
urlRegexInput.editText!!.doOnTextChanged { text, start, before, count ->
urlRegexInput.isEndIconVisible = urlRegexInput.editText!!.text.isNotBlank()
}
val urlRegexChips = contentLinear.findViewById<ChipGroup>(R.id.urlRegexChipGroup)
currentValue?.apply {
for(chip in this.urlRegex) {
val tmp = context.layoutInflater.inflate(R.layout.input_chip, urlRegexChips, false) as Chip
tmp.text = chip
tmp.setOnClickListener {
urlRegexChips.removeView(tmp)
}
urlRegexChips.addView(tmp)
}
}
urlRegexInput.setEndIconOnClickListener {
val text = urlRegexInput.editText!!.text
urlRegexInput.editText!!.setText("")
val tmp = context.layoutInflater.inflate(R.layout.input_chip, urlRegexChips, false) as Chip
tmp.text = text
tmp.setOnClickListener {
urlRegexChips.removeView(tmp)
}
urlRegexChips.addView(tmp)
}
okBtn.setOnClickListener {
val titleVal = title.editText!!.text.toString()
if (titleVal.isBlank()) {
title.error = "Player Client tag shouldn't be empty"
return@setOnClickListener
}
if(existingConfigs.any { it2 -> it2.playerClient == titleVal } && ( currentValue == null || currentValue.playerClient != titleVal )) {
title.error = "Player Client is already created"
return@setOnClickListener
}
if (useOnlyPOToken.isChecked && (poTokenInputs.filter { it.tag.toString().startsWith("potoken") }.all { it.editText!!.text.isBlank() })) {
poTokenInputs.first().error = "You need to write at least one PO Token"
return@setOnClickListener
}
val obj = YoutubePlayerClientItem(titleVal, mutableListOf(), true, useOnlyPOToken.isChecked)
poTokenInputs.filter { it.editText!!.text.isNotBlank() && it.tag.toString().startsWith("potoken") }.forEach {
obj.poTokens.add(YoutubePoTokenItem(it.tag.toString().split("potoken_")[1], it.editText!!.text.toString()))
}
val urlRegexes = urlRegexChips.children.map { (it as Chip).text.toString() }
obj.urlRegex.addAll(urlRegexes)
bottomSheet.cancel()
newValue(obj)
}
deleteBtn.setOnClickListener {
bottomSheet.cancel()
deleted()
}
bottomSheet.show()
bottomSheet.behavior.state = BottomSheetBehavior.STATE_EXPANDED
bottomSheet.window!!.setLayout(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
}
@SuppressLint("UnspecifiedRegisterReceiverFlag") @SuppressLint("UnspecifiedRegisterReceiverFlag")
fun showNewAppUpdateDialog(v: GithubRelease, context: Activity, preferences: SharedPreferences) { fun showNewAppUpdateDialog(v: GithubRelease, context: Activity, preferences: SharedPreferences) {
val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager

@ -1271,7 +1271,7 @@ class YTDLPUtil(private val context: Context, private val commandTemplateDao: Co
private fun YoutubeDLRequest.setYoutubeExtractorArgs(url: String?) { private fun YoutubeDLRequest.setYoutubeExtractorArgs(url: String?) {
val extractorArgs = mutableListOf<String>() val extractorArgs = mutableListOf<String>()
val playerClients = mutableListOf<String>() val playerClients = mutableSetOf<String>()
val poTokens = mutableListOf<String>() val poTokens = mutableListOf<String>()
val configuredPlayerClientsRaw = sharedPreferences.getString("youtube_player_clients", "[]")!! val configuredPlayerClientsRaw = sharedPreferences.getString("youtube_player_clients", "[]")!!
@ -1296,6 +1296,21 @@ class YTDLPUtil(private val context: Context, private val commandTemplateDao: Co
} }
} }
if (sharedPreferences.getBoolean("use_newpipe_potoken", false)) {
val visitorData = sharedPreferences.getString("newpipe_visitordata", "")!!
if (visitorData.isNotBlank()) {
playerClients.add("web")
sharedPreferences.getString("newpipe_gvs_potoken", "")?.apply {
if (this.isNotBlank()) poTokens.add("web.gvs+$this")
}
sharedPreferences.getString("newpipe_player_potoken", "")?.apply {
if (this.isNotBlank()) poTokens.add("web.player+$this")
}
extractorArgs.add("player-skip=webpage,configs")
extractorArgs.add("visitor_data=$visitorData")
}
}
if (playerClients.isNotEmpty()){ if (playerClients.isNotEmpty()){
extractorArgs.add("player_client=${playerClients.joinToString(",")}") extractorArgs.add("player_client=${playerClients.joinToString(",")}")
} }

@ -9,6 +9,7 @@ import com.deniscerri.ytdl.database.models.Format
import com.deniscerri.ytdl.database.models.ResultItem import com.deniscerri.ytdl.database.models.ResultItem
import com.deniscerri.ytdl.database.viewmodel.ResultViewModel import com.deniscerri.ytdl.database.viewmodel.ResultViewModel
import com.deniscerri.ytdl.util.Extensions.toStringDuration import com.deniscerri.ytdl.util.Extensions.toStringDuration
import com.deniscerri.ytdl.util.extractors.newpipe.potoken.NewPipePoTokenGenerator
import com.google.gson.Gson import com.google.gson.Gson
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.json.JSONException import org.json.JSONException
@ -22,6 +23,8 @@ import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler
import org.schabi.newpipe.extractor.localization.Localization import org.schabi.newpipe.extractor.localization.Localization
import org.schabi.newpipe.extractor.playlist.PlaylistInfo import org.schabi.newpipe.extractor.playlist.PlaylistInfo
import org.schabi.newpipe.extractor.search.SearchInfo import org.schabi.newpipe.extractor.search.SearchInfo
import org.schabi.newpipe.extractor.services.youtube.PoTokenResult
import org.schabi.newpipe.extractor.services.youtube.extractors.YoutubeStreamExtractor
import org.schabi.newpipe.extractor.services.youtube.linkHandler.YoutubeSearchQueryHandlerFactory import org.schabi.newpipe.extractor.services.youtube.linkHandler.YoutubeSearchQueryHandlerFactory
import org.schabi.newpipe.extractor.stream.StreamInfo import org.schabi.newpipe.extractor.stream.StreamInfo
import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.extractor.stream.StreamInfoItem
@ -32,13 +35,21 @@ class NewPipeUtil(context: Context) {
private var sharedPreferences: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(context) private var sharedPreferences: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
private val countryCode = sharedPreferences.getString("locale", "")!!.ifEmpty { "US" } private val countryCode = sharedPreferences.getString("locale", "")!!.ifEmpty { "US" }
private val language = sharedPreferences.getString("app_language", "")!!.ifEmpty { "en" } private val language = sharedPreferences.getString("app_language", "")!!.ifEmpty { "en" }
private val testURL = "https://www.youtube.com/watch?v=aqz-KE-bpKQ" //bbb
init { init {
NewPipe.init(NewPipeDownloaderImpl(OkHttpClient.Builder()), Localization(language, countryCode)) NewPipe.init(NewPipeDownloaderImpl(OkHttpClient.Builder()), Localization(language, countryCode))
YoutubeStreamExtractor.setPoTokenProvider(NewPipePoTokenGenerator())
}
fun testRun() {
getVideoData(testURL)
return
} }
fun getVideoData(url : String) : Result<List<ResultItem>> { fun getVideoData(url : String) : Result<List<ResultItem>> {
try { try {
val streamInfo = StreamInfo.getInfo(NewPipe.getService(ServiceList.YouTube.serviceId), url) val streamInfo = StreamInfo.getInfo(url)
val vid = createVideoFromStream(streamInfo, url) ?: return Result.failure(Throwable()) val vid = createVideoFromStream(streamInfo, url) ?: return Result.failure(Throwable())
return Result.success(listOf(vid)) return Result.success(listOf(vid))
}catch (e: Exception) { }catch (e: Exception) {
@ -48,7 +59,7 @@ class NewPipeUtil(context: Context) {
fun getFormats(url: String) : Result<List<Format> > { fun getFormats(url: String) : Result<List<Format> > {
try { try {
val streamInfo = StreamInfo.getInfo(NewPipe.getService(ServiceList.YouTube.serviceId), url) val streamInfo = StreamInfo.getInfo(url)
val vid = createVideoFromStream(streamInfo, url, true) val vid = createVideoFromStream(streamInfo, url, true)
return Result.success(vid!!.formats) return Result.success(vid!!.formats)
}catch(e: Exception) { }catch(e: Exception) {
@ -62,7 +73,7 @@ class NewPipeUtil(context: Context) {
return kotlin.runCatching { return kotlin.runCatching {
val formatCollection = mutableListOf<MutableList<Format>>() val formatCollection = mutableListOf<MutableList<Format>>()
urls.forEach { url -> urls.forEach { url ->
val streamInfo = StreamInfo.getInfo(NewPipe.getService(ServiceList.YouTube.serviceId), url) val streamInfo = StreamInfo.getInfo(url)
createVideoFromStream(streamInfo, url, true).apply { createVideoFromStream(streamInfo, url, true).apply {
if (this!!.formats.isEmpty()) return Result.failure(Throwable()) if (this!!.formats.isEmpty()) return Result.failure(Throwable())
formatCollection.add(this.formats) formatCollection.add(this.formats)
@ -128,7 +139,7 @@ class NewPipeUtil(context: Context) {
fun getStreamingUrlAndChapters(url: String) : Result<Pair<List<String>, List<ChapterItem>?>> { fun getStreamingUrlAndChapters(url: String) : Result<Pair<List<String>, List<ChapterItem>?>> {
try { try {
val streamInfo = StreamInfo.getInfo(NewPipe.getService(ServiceList.YouTube.serviceId), url) val streamInfo = StreamInfo.getInfo(url)
val item = createVideoFromStream(streamInfo, url) val item = createVideoFromStream(streamInfo, url)
if (item!!.urls.isBlank()) return Result.failure(Throwable()) if (item!!.urls.isBlank()) return Result.failure(Throwable())
val urls = item.urls.split(",") val urls = item.urls.split(",")
@ -426,4 +437,10 @@ class NewPipeUtil(context: Context) {
query = el[0] query = el[0]
return query!! return query!!
} }
private fun generatePoToken(url: String) : PoTokenResult? {
val generator = NewPipePoTokenGenerator()
val id = getIDFromYoutubeURL(url)
return generator.getWebClientPoToken(id)
}
} }

@ -0,0 +1,131 @@
package com.deniscerri.ytdl.util.extractors.newpipe.potoken
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.long
import okio.ByteString.Companion.decodeBase64
import okio.ByteString.Companion.toByteString
object JavascriptUtil {
/**
* Parses the raw challenge data obtained from the Create endpoint and returns an object that can be
* embedded in a JavaScript snippet.
*/
fun parseChallengeData(rawChallengeData: String): String {
val scrambled = Json.parseToJsonElement(rawChallengeData).jsonArray
val challengeData = if (scrambled.size > 1 && scrambled[1].jsonPrimitive.isString) {
val descrambled = descramble(scrambled[1].jsonPrimitive.content)
Json.parseToJsonElement(descrambled).jsonArray
} else {
scrambled[1].jsonArray
}
val messageId = challengeData[0].jsonPrimitive.content
val interpreterHash = challengeData[3].jsonPrimitive.content
val program = challengeData[4].jsonPrimitive.content
val globalName = challengeData[5].jsonPrimitive.content
val clientExperimentsStateBlob = challengeData[7].jsonPrimitive.content
val privateDoNotAccessOrElseSafeScriptWrappedValue = challengeData[1]
.takeIf { it !is JsonNull }
?.jsonArray
?.find { it.jsonPrimitive.isString }
val privateDoNotAccessOrElseTrustedResourceUrlWrappedValue = challengeData[2]
.takeIf { it !is JsonNull }
?.jsonArray
?.find { it.jsonPrimitive.isString }
return Json.encodeToString(
JsonObject.serializer(), JsonObject(
mapOf(
"messageId" to JsonPrimitive(messageId),
"interpreterJavascript" to JsonObject(
mapOf(
"privateDoNotAccessOrElseSafeScriptWrappedValue" to (privateDoNotAccessOrElseSafeScriptWrappedValue
?: JsonNull),
"privateDoNotAccessOrElseTrustedResourceUrlWrappedValue" to (privateDoNotAccessOrElseTrustedResourceUrlWrappedValue
?: JsonNull)
)
),
"interpreterHash" to JsonPrimitive(interpreterHash),
"program" to JsonPrimitive(program),
"globalName" to JsonPrimitive(globalName),
"clientExperimentsStateBlob" to JsonPrimitive(clientExperimentsStateBlob)
)
)
)
}
/**
* Parses the raw integrity token data obtained from the GenerateIT endpoint to a JavaScript
* `Uint8Array` that can be embedded directly in JavaScript code, and an [Int] representing the
* duration of this token in seconds.
*/
fun parseIntegrityTokenData(rawIntegrityTokenData: String): Pair<String, Long> {
val integrityTokenData = Json.parseToJsonElement(rawIntegrityTokenData).jsonArray
return base64ToU8(integrityTokenData[0].jsonPrimitive.content) to integrityTokenData[1].jsonPrimitive.long
}
/**
* Converts a string (usually the identifier used as input to `obtainPoToken`) to a JavaScript
* `Uint8Array` that can be embedded directly in JavaScript code.
*/
fun stringToU8(identifier: String): String {
return newUint8Array(identifier.toByteArray())
}
/**
* Takes a poToken encoded as a sequence of bytes represented as integers separated by commas
* (e.g. "97,98,99" would be "abc"), which is the output of `Uint8Array::toString()` in JavaScript,
* and converts it to the specific base64 representation for poTokens.
*/
fun u8ToBase64(poToken: String): String {
return poToken.split(",")
.map { it.toUByte().toByte() }
.toByteArray()
.toByteString()
.base64()
.replace("+", "-")
.replace("/", "_")
}
/**
* Takes the scrambled challenge, decodes it from base64, adds 97 to each byte.
*/
private fun descramble(scrambledChallenge: String): String {
return base64ToByteString(scrambledChallenge)
.map { (it + 97).toByte() }
.toByteArray()
.decodeToString()
}
/**
* Decodes a base64 string encoded in the specific base64 representation used by YouTube, and
* returns a JavaScript `Uint8Array` that can be embedded directly in JavaScript code.
*/
private fun base64ToU8(base64: String): String {
return newUint8Array(base64ToByteString(base64))
}
private fun newUint8Array(contents: ByteArray): String {
return "new Uint8Array([" + contents.joinToString(separator = ",") { it.toUByte().toString() } + "])"
}
/**
* Decodes a base64 string encoded in the specific base64 representation used by YouTube.
*/
private fun base64ToByteString(base64: String): ByteArray {
val base64Mod = base64
.replace('-', '+')
.replace('_', '/')
.replace('.', '=')
return (base64Mod.decodeBase64() ?: throw PoTokenException("Cannot base64 decode"))
.toByteArray()
}
}

@ -0,0 +1,142 @@
package com.deniscerri.ytdl.util.extractors.newpipe.potoken
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.webkit.CookieManager
import androidx.preference.PreferenceManager
import com.deniscerri.ytdl.App
import com.deniscerri.ytdl.BuildConfig
import com.deniscerri.ytdl.database.models.YoutubePlayerClientItem
import com.deniscerri.ytdl.database.models.YoutubePoTokenItem
import com.google.gson.Gson
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.schabi.newpipe.extractor.NewPipe
import org.schabi.newpipe.extractor.services.youtube.InnertubeClientRequestInfo
import org.schabi.newpipe.extractor.services.youtube.PoTokenProvider
import org.schabi.newpipe.extractor.services.youtube.PoTokenResult
import org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper
class NewPipePoTokenGenerator : PoTokenProvider {
val TAG = NewPipePoTokenGenerator::class.simpleName
private val supportsWebView by lazy { runCatching { CookieManager.getInstance() }.isSuccess }
private object WebPoTokenGenLock
private var webPoTokenVisitorData: String? = null
private var webPoTokenStreamingPot: String? = null
private var webPoTokenGenerator: PoTokenWebView? = null
override fun getWebClientPoToken(videoId: String): PoTokenResult? {
if (!supportsWebView) {
return null
}
val result = kotlin.runCatching {
getWebClientPoToken(videoId, false)
}
result.getOrNull()?.apply {
val preferences = PreferenceManager.getDefaultSharedPreferences(App.instance)
val editor = preferences.edit()
editor.putString("newpipe_gvs_potoken", this.streamingDataPoToken ?: "")
editor.putString("newpipe_player_potoken", this.playerRequestPoToken)
editor.putString("newpipe_visitordata", this.visitorData)
editor.apply()
}
return result.getOrNull()
}
/**
* @param forceRecreate whether to force the recreation of [webPoTokenGenerator], to be used in
* case the current [webPoTokenGenerator] threw an error last time
* [PoTokenGenerator.generatePoToken] was called
*/
private fun getWebClientPoToken(videoId: String, forceRecreate: Boolean): PoTokenResult {
// just a helper class since Kotlin does not have builtin support for 4-tuples
data class Quadruple<T1, T2, T3, T4>(val t1: T1, val t2: T2, val t3: T3, val t4: T4)
val (poTokenGenerator, visitorData, streamingPot, hasBeenRecreated) =
synchronized(WebPoTokenGenLock) {
val shouldRecreate = webPoTokenGenerator == null || forceRecreate || webPoTokenGenerator!!.isExpired
if (shouldRecreate) {
val innertubeClientRequestInfo = InnertubeClientRequestInfo.ofWebClient()
innertubeClientRequestInfo.clientInfo.clientVersion =
YoutubeParsingHelper.getClientVersion()
webPoTokenVisitorData = YoutubeParsingHelper.getVisitorDataFromInnertube(
innertubeClientRequestInfo,
NewPipe.getPreferredLocalization(),
NewPipe.getPreferredContentCountry(),
YoutubeParsingHelper.getYouTubeHeaders(),
YoutubeParsingHelper.YOUTUBEI_V1_URL,
null,
false
)
runBlocking {
// close the current webPoTokenGenerator on the main thread
webPoTokenGenerator?.let { Handler(Looper.getMainLooper()).post { it.close() } }
// create a new webPoTokenGenerator
webPoTokenGenerator = PoTokenWebView
.getNewPoTokenGenerator(App.instance)
// The streaming poToken needs to be generated exactly once before generating
// any other (player) tokens.
webPoTokenStreamingPot = webPoTokenGenerator!!.generatePoToken(webPoTokenVisitorData!!)
}
}
return@synchronized Quadruple(
webPoTokenGenerator!!,
webPoTokenVisitorData!!,
webPoTokenStreamingPot!!,
shouldRecreate
)
}
val playerPot = try {
// Not using synchronized here, since poTokenGenerator would be able to generate
// multiple poTokens in parallel if needed. The only important thing is for exactly one
// visitorData/streaming poToken to be generated before anything else.
runBlocking {
poTokenGenerator.generatePoToken(videoId)
}
} catch (throwable: Throwable) {
if (hasBeenRecreated) {
// the poTokenGenerator has just been recreated (and possibly this is already the
// second time we try), so there is likely nothing we can do
throw throwable
} else {
// retry, this time recreating the [webPoTokenGenerator] from scratch;
// this might happen for example if NewPipe goes in the background and the WebView
// content is lost
Log.e(TAG, "Failed to obtain poToken, retrying", throwable)
return getWebClientPoToken(videoId = videoId, forceRecreate = true)
}
}
if (BuildConfig.DEBUG) {
Log.d(
TAG,
"poToken for $videoId: playerPot=$playerPot, " +
"streamingPot=$streamingPot, visitor_data=$visitorData"
)
}
return PoTokenResult(visitorData, playerPot, streamingPot)
}
override fun getWebEmbedClientPoToken(videoId: String?): PoTokenResult? = null
override fun getAndroidClientPoToken(videoId: String?): PoTokenResult? = null
override fun getIosClientPoToken(videoId: String?): PoTokenResult? = null
}

@ -0,0 +1,13 @@
package com.deniscerri.ytdl.util.extractors.newpipe.potoken
class PoTokenException(message: String) : Exception(message)
// to be thrown if the WebView provided by the system is broken
class BadWebViewException(message: String) : Exception(message)
fun buildExceptionForJsError(error: String): Exception {
return if (error.contains("SyntaxError"))
BadWebViewException(error)
else
PoTokenException(error)
}

@ -0,0 +1,353 @@
package com.deniscerri.ytdl.util.extractors.newpipe.potoken
import android.content.Context
import android.os.Build
import android.util.Log
import android.webkit.ConsoleMessage
import android.webkit.JavascriptInterface
import android.webkit.WebChromeClient
import android.webkit.WebView
import androidx.annotation.MainThread
import androidx.collection.ArrayMap
import androidx.preference.PreferenceManager
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import com.deniscerri.ytdl.BuildConfig
import com.deniscerri.ytdl.util.extractors.newpipe.potoken.JavascriptUtil.parseChallengeData
import com.deniscerri.ytdl.util.extractors.newpipe.potoken.JavascriptUtil.parseIntegrityTokenData
import com.deniscerri.ytdl.util.extractors.newpipe.potoken.JavascriptUtil.stringToU8
import com.deniscerri.ytdl.util.extractors.newpipe.potoken.JavascriptUtil.u8ToBase64
import okhttp3.Headers.Companion.toHeaders
import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody
import java.time.Instant
import java.time.temporal.ChronoUnit
import java.util.Collections
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
class PoTokenWebView private constructor(
context: Context,
// to be used exactly once only during initialization!
private val continuation: Continuation<PoTokenWebView>,
) {
private val webView = WebView(context)
private val scope = MainScope()
private val poTokenContinuations =
Collections.synchronizedMap(ArrayMap<String, Continuation<String>>())
private val exceptionHandler = CoroutineExceptionHandler { _, t ->
onInitializationErrorCloseAndCancel(t)
}
private lateinit var expirationInstant: Instant
//region Initialization
init {
val webViewSettings = webView.settings
//noinspection SetJavaScriptEnabled we want to use JavaScript!
webViewSettings.javaScriptEnabled = true
if (Build.VERSION.SDK_INT >= 26) {
webViewSettings.safeBrowsingEnabled = false
}
webViewSettings.userAgentString = USER_AGENT
webViewSettings.blockNetworkLoads = true // the WebView does not need internet access
// so that we can run async functions and get back the result
webView.addJavascriptInterface(this, JS_INTERFACE)
val preferences = PreferenceManager.getDefaultSharedPreferences(context)
preferences.getString("api_key", "")?.apply {
if (this.isNotBlank()) {
GOOGLE_API_KEY = this
}
}
webView.webChromeClient = object : WebChromeClient() {
override fun onConsoleMessage(m: ConsoleMessage): Boolean {
if (m.message().contains("Uncaught")) {
// There should not be any uncaught errors while executing the code, because
// everything that can fail is guarded by try-catch. Therefore, this likely
// indicates that there was a syntax error in the code, i.e. the WebView only
// supports a really old version of JS.
val fmt = "\"${m.message()}\", source: ${m.sourceId()} (${m.lineNumber()})"
val exception = BadWebViewException(fmt)
Log.e(TAG, "This WebView implementation is broken: $fmt")
onInitializationErrorCloseAndCancel(exception)
popAllPoTokenContinuations().forEach { (_, cont) -> cont.resumeWithException(exception) }
}
return super.onConsoleMessage(m)
}
}
}
/**
* Must be called right after instantiating [PoTokenWebView] to perform the actual
* initialization. This will asynchronously go through all the steps needed to load BotGuard,
* run it, and obtain an `integrityToken`.
*/
private fun loadHtmlAndObtainBotguard() {
Log.d(TAG, "loadHtmlAndObtainBotguard() called")
scope.launch(exceptionHandler) {
val html = withContext(Dispatchers.IO) {
webView.context.assets.open("po_token.html").bufferedReader().use { it.readText() }
}
// calls downloadAndRunBotguard() when the page has finished loading
val data = html.replaceFirst("</script>", "\n$JS_INTERFACE.downloadAndRunBotguard()</script>")
webView.loadDataWithBaseURL("https://www.youtube.com", data, "text/html", "utf-8", null)
}
}
/**
* Called during initialization by the JavaScript snippet appended to the HTML page content in
* [loadHtmlAndObtainBotguard] after the WebView content has been loaded.
*/
@JavascriptInterface
fun downloadAndRunBotguard() {
Log.d(TAG, "downloadAndRunBotguard() called")
makeBotguardServiceRequest(
"https://www.youtube.com/api/jnn/v1/Create",
"[ \"$REQUEST_KEY\" ]",
) { responseBody ->
val parsedChallengeData = parseChallengeData(responseBody)
webView.evaluateJavascript(
"""try {
data = $parsedChallengeData
runBotGuard(data).then(function (result) {
this.webPoSignalOutput = result.webPoSignalOutput
$JS_INTERFACE.onRunBotguardResult(result.botguardResponse)
}, function (error) {
$JS_INTERFACE.onJsInitializationError(error + "\n" + error.stack)
})
} catch (error) {
$JS_INTERFACE.onJsInitializationError(error + "\n" + error.stack)
}""",
null
)
}
}
/**
* Called during initialization by the JavaScript snippets from either
* [downloadAndRunBotguard] or [onRunBotguardResult].
*/
@JavascriptInterface
fun onJsInitializationError(error: String) {
if (BuildConfig.DEBUG) {
Log.e(TAG, "Initialization error from JavaScript: $error")
}
onInitializationErrorCloseAndCancel(buildExceptionForJsError(error))
}
/**
* Called during initialization by the JavaScript snippet from [downloadAndRunBotguard] after
* obtaining the BotGuard execution output [botguardResponse].
*/
@JavascriptInterface
fun onRunBotguardResult(botguardResponse: String) {
Log.d(TAG, "botguardResponse: $botguardResponse")
makeBotguardServiceRequest(
"https://www.youtube.com/api/jnn/v1/GenerateIT",
"[ \"$REQUEST_KEY\", \"$botguardResponse\" ]",
) { responseBody ->
Log.d(TAG, "GenerateIT response: $responseBody")
val (integrityToken, expirationTimeInSeconds) = parseIntegrityTokenData(responseBody)
// leave 10 minutes of margin just to be sure
expirationInstant = Instant.now().plusSeconds(expirationTimeInSeconds).minus(10, ChronoUnit.MINUTES)
webView.evaluateJavascript("this.integrityToken = $integrityToken") {
Log.d(TAG, "initialization finished, expiration=${expirationTimeInSeconds}s")
continuation.resume(this)
}
}
}
//endregion
//region Obtaining poTokens
suspend fun generatePoToken(identifier: String): String {
return withContext(Dispatchers.Main) {
suspendCancellableCoroutine { cont ->
Log.d(TAG, "generatePoToken() called with identifier $identifier")
addPoTokenEmitter(identifier, cont)
webView.evaluateJavascript(
"""try {
identifier = "$identifier"
u8Identifier = ${stringToU8(identifier)}
poTokenU8 = obtainPoToken(webPoSignalOutput, integrityToken, u8Identifier)
poTokenU8String = poTokenU8.join(",")
$JS_INTERFACE.onObtainPoTokenResult(identifier, poTokenU8String)
} catch (error) {
$JS_INTERFACE.onObtainPoTokenError(identifier, error + "\n" + error.stack)
}""",
null
)
}
}
}
/**
* Called by the JavaScript snippet from [generatePoToken] when an error occurs in calling the
* JavaScript `obtainPoToken()` function.
*/
@JavascriptInterface
fun onObtainPoTokenError(identifier: String, error: String) {
if (BuildConfig.DEBUG) {
Log.e(TAG, "obtainPoToken error from JavaScript: $error")
}
popPoTokenContinuation(identifier)?.resumeWithException(buildExceptionForJsError(error))
}
/**
* Called by the JavaScript snippet from [generatePoToken] with the original identifier and the
* result of the JavaScript `obtainPoToken()` function.
*/
@JavascriptInterface
fun onObtainPoTokenResult(identifier: String, poTokenU8: String) {
Log.d(TAG, "Generated poToken (before decoding): identifier=$identifier poTokenU8=$poTokenU8")
val poToken = try {
u8ToBase64(poTokenU8)
} catch (t: Throwable) {
popPoTokenContinuation(identifier)?.resumeWithException(t)
return
}
Log.d(TAG, "Generated poToken: identifier=$identifier poToken=$poToken")
popPoTokenContinuation(identifier)?.resume(poToken)
}
val isExpired: Boolean
get() = Instant.now().isAfter(expirationInstant)
//endregion
//region Handling multiple emitters
/**
* Adds the ([identifier], [continuation]) pair to the [poTokenContinuations] list. This makes
* it so that multiple poToken requests can be generated in parallel, and the results will be
* notified to the right continuations.
*/
private fun addPoTokenEmitter(identifier: String, continuation: Continuation<String>) {
poTokenContinuations[identifier] = continuation
}
/**
* Extracts and removes from the [poTokenContinuations] list a [Continuation] based on its
* [identifier]. The continuation is supposed to be used immediately after to either signal a
* success or an error.
*/
private fun popPoTokenContinuation(identifier: String): Continuation<String>? {
return poTokenContinuations.remove(identifier)
}
/**
* Clears [poTokenContinuations] and returns its previous contents. The continuations are supposed
* to be used immediately after to either signal a success or an error.
*/
private fun popAllPoTokenContinuations(): Map<String, Continuation<String>> {
val result = poTokenContinuations.toMap()
poTokenContinuations.clear()
return result
}
//endregion
//region Utils
/**
* Makes a POST request to [url] with the given [data] by setting the correct headers. Calls
* [onInitializationErrorCloseAndCancel] in case of any network errors and also if the response
* does not have HTTP code 200, therefore this is supposed to be used only during
* initialization. Calls [handleResponseBody] with the response body if the response is
* successful. The request is performed in the background and a disposable is added to
* [disposables].
*/
private fun makeBotguardServiceRequest(
url: String,
data: String,
handleResponseBody: (String) -> Unit,
) {
scope.launch(exceptionHandler) {
val requestBuilder = okhttp3.Request.Builder()
.post(data.toRequestBody())
.headers(mapOf(
"User-Agent" to USER_AGENT,
"Accept" to "application/json",
"Content-Type" to "application/json+protobuf",
"x-goog-api-key" to GOOGLE_API_KEY,
"x-user-agent" to "grpc-web-javascript/0.1",
).toHeaders())
.url(url)
val response = withContext(Dispatchers.IO) {
httpClient.newCall(requestBuilder.build()).execute()
}
val httpCode = response.code
if (httpCode != 200) {
onInitializationErrorCloseAndCancel(PoTokenException("Invalid response code: $httpCode"))
} else {
val body = withContext(Dispatchers.IO) {
response.body!!.string()
}
handleResponseBody(body)
}
}
}
/**
* Handles any error happening during initialization, releasing resources and sending the error
* to [continuation].
*/
private fun onInitializationErrorCloseAndCancel(error: Throwable) {
close()
continuation.resumeWithException(error)
}
/**
* Releases all [webView] resources.
*/
@MainThread
fun close() {
scope.cancel()
webView.clearHistory()
// clears RAM cache and disk cache (globally for all WebViews)
webView.clearCache(true)
// ensures that the WebView isn't doing anything when destroying it
webView.loadUrl("about:blank")
webView.onPause()
webView.removeAllViews()
webView.destroy()
}
//endregion
companion object {
private const val TAG = "PoTokenWebView"
//libretube api key, if user has his own api key his will be used
private var GOOGLE_API_KEY = "AIzaSyDyT5W0Jh49F30Pqqtyfdf7pDLFKLJoAnw"
private const val REQUEST_KEY = "O43z0dpjhgX20SCx4KAo"
private const val USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " +
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.3"
private const val JS_INTERFACE = "PoTokenWebView"
private val httpClient = OkHttpClient.Builder()
//.proxy(YouTube.proxy)
.build()
suspend fun getNewPoTokenGenerator(context: Context): PoTokenWebView {
return withContext(Dispatchers.Main) {
suspendCancellableCoroutine { cont ->
val potWv = PoTokenWebView(context, cont)
potWv.loadHtmlAndObtainBotguard()
}
}
}
}
}

@ -50,9 +50,11 @@
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:labelVisibilityMode="selected" app:labelVisibilityMode="selected"
android:paddingTop="8dp"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
app:layout_constraintHorizontal_bias="0.0" app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:headerLayout="@layout/app_logo_fab"
app:menu="@menu/bottom_nav_menu" /> app:menu="@menu/bottom_nav_menu" />

@ -39,21 +39,22 @@
app:navGraph="@navigation/nav_graph" app:navGraph="@navigation/nav_graph"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/bottomNavigationView" app:layout_constraintStart_toEndOf="@+id/navigationView"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
/> />
<com.google.android.material.navigationrail.NavigationRailView <com.google.android.material.navigation.NavigationView
android:id="@+id/bottomNavigationView" android:id="@+id/navigationView"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="match_parent" android:layout_height="match_parent"
app:labelVisibilityMode="unlabeled"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:labelVisibilityMode="selected"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
app:headerLayout="@layout/header_navigation_drawer"
app:layout_constraintHorizontal_bias="0.0" app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:menu="@menu/bottom_nav_menu" /> app:menu="@menu/nav_menu_largescreen" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

@ -91,18 +91,9 @@
android:layout_height="wrap_content"> android:layout_height="wrap_content">
<androidx.constraintlayout.widget.ConstraintLayout <androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
app:layout_scrollFlags="scroll"
android:layout_height="wrap_content">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/home_toolbar"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_gravity="end"
app:layout_constraintBottom_toBottomOf="parent" android:layout_height="wrap_content">
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:title="@string/app_name" />
<com.facebook.shimmer.ShimmerFrameLayout <com.facebook.shimmer.ShimmerFrameLayout
android:id="@+id/progress" android:id="@+id/progress"
@ -126,17 +117,17 @@
</com.facebook.shimmer.ShimmerFrameLayout> </com.facebook.shimmer.ShimmerFrameLayout>
<com.google.android.material.search.SearchBar <com.google.android.material.search.SearchBar
android:id="@+id/search_bar" android:id="@+id/search_bar"
android:layout_width="0dp" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:gravity="center_horizontal" android:gravity="center_horizontal"
android:hint="@string/search_hint" android:hint="@string/search_hint"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="1.0" app:layout_constraintHorizontal_bias="1.0"
android:layout_marginStart="60dp" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintStart_toEndOf="@+id/home_toolbar"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
app:menu="@menu/main_menu"> app:menu="@menu/main_menu">
@ -156,7 +147,6 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:scrollbars="none" android:scrollbars="none"
android:visibility="gone" android:visibility="gone"
app:layout_scrollFlags="scroll|enterAlways"
android:layout_height="wrap_content"> android:layout_height="wrap_content">
<LinearLayout <LinearLayout
@ -201,14 +191,14 @@
android:layout_below="@id/playlist_selection_chips_scrollview" android:layout_below="@id/playlist_selection_chips_scrollview"
android:clipToPadding="false" android:clipToPadding="false"
android:paddingBottom="200dp" android:paddingBottom="200dp"
app:spanCount="2" app:spanCount="3"
app:layoutManager="androidx.recyclerview.widget.GridLayoutManager" app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
/> />
<com.facebook.shimmer.ShimmerFrameLayout <com.facebook.shimmer.ShimmerFrameLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_below="@id/recyclerViewHome" android:layout_below="@id/recyclerViewHome"
android:layout_height="match_parent"
android:id="@+id/shimmer_results_framelayout" android:id="@+id/shimmer_results_framelayout"
android:orientation="vertical"> android:orientation="vertical">
@ -220,7 +210,30 @@
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:weightSum="2" android:weightSum="3"
android:layout_height="wrap_content">
<include
android:layout_weight="1"
layout="@layout/result_card_shimmer"
android:layout_height="wrap_content"
android:layout_width="0dp" />
<include
android:layout_weight="1"
layout="@layout/result_card_shimmer"
android:layout_height="wrap_content"
android:layout_width="0dp" />
<include
android:layout_weight="1"
layout="@layout/result_card_shimmer"
android:layout_height="wrap_content"
android:layout_width="0dp" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:weightSum="3"
android:layout_height="wrap_content"> android:layout_height="wrap_content">
<include <include
@ -243,7 +256,7 @@
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:weightSum="2" android:weightSum="3"
android:layout_height="wrap_content"> android:layout_height="wrap_content">
<include <include
@ -266,7 +279,7 @@
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:weightSum="2" android:weightSum="3"
android:layout_height="wrap_content"> android:layout_height="wrap_content">
<include <include
@ -290,9 +303,11 @@
</LinearLayout> </LinearLayout>
</com.facebook.shimmer.ShimmerFrameLayout> </com.facebook.shimmer.ShimmerFrameLayout>
</RelativeLayout> </RelativeLayout>
<LinearLayout <LinearLayout
@ -309,8 +324,8 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="bottom|end" android:layout_gravity="bottom|end"
android:text="@string/link_you_copied" android:text="@string/link_you_copied"
android:layout_marginBottom="10dp"
android:layout_marginHorizontal="16dp" android:layout_marginHorizontal="16dp"
android:layout_marginBottom="10dp"
android:contentDescription="@string/link_you_copied" android:contentDescription="@string/link_you_copied"
app:icon="@drawable/ic_clipboard" app:icon="@drawable/ic_clipboard"
app:useCompatPadding="true" /> app:useCompatPadding="true" />

@ -39,22 +39,23 @@
app:navGraph="@navigation/nav_graph" app:navGraph="@navigation/nav_graph"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/navigationView" app:layout_constraintStart_toEndOf="@+id/bottomNavigationView"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
/> />
<com.google.android.material.navigation.NavigationView <com.google.android.material.navigationrail.NavigationRailView
android:id="@+id/navigationView" android:id="@+id/bottomNavigationView"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="match_parent" android:layout_height="match_parent"
app:labelVisibilityMode="unlabeled"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:labelVisibilityMode="selected"
android:paddingTop="8dp"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
app:headerLayout="@layout/header_navigation_drawer"
app:layout_constraintHorizontal_bias="0.0" app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:menu="@menu/nav_menu_largescreen" /> app:headerLayout="@layout/app_logo_fab"
app:menu="@menu/bottom_nav_menu" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

@ -91,10 +91,19 @@
android:layout_height="wrap_content"> android:layout_height="wrap_content">
<androidx.constraintlayout.widget.ConstraintLayout <androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="wrap_content" android:layout_width="match_parent"
android:layout_gravity="end" app:layout_scrollFlags="scroll"
android:layout_height="wrap_content"> android:layout_height="wrap_content">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/home_toolbar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:title="@string/app_name" />
<com.facebook.shimmer.ShimmerFrameLayout <com.facebook.shimmer.ShimmerFrameLayout
android:id="@+id/progress" android:id="@+id/progress"
android:layout_width="0dp" android:layout_width="0dp"
@ -117,7 +126,6 @@
</com.facebook.shimmer.ShimmerFrameLayout> </com.facebook.shimmer.ShimmerFrameLayout>
<com.google.android.material.search.SearchBar <com.google.android.material.search.SearchBar
android:id="@+id/search_bar" android:id="@+id/search_bar"
android:layout_width="wrap_content" android:layout_width="wrap_content"
@ -127,7 +135,8 @@
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="1.0" app:layout_constraintHorizontal_bias="1.0"
app:layout_constraintStart_toStartOf="parent" android:layout_marginStart="60dp"
app:layout_constraintStart_toEndOf="@+id/home_toolbar"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
app:menu="@menu/main_menu"> app:menu="@menu/main_menu">
@ -147,6 +156,7 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:scrollbars="none" android:scrollbars="none"
android:visibility="gone" android:visibility="gone"
app:layout_scrollFlags="scroll|enterAlways"
android:layout_height="wrap_content"> android:layout_height="wrap_content">
<LinearLayout <LinearLayout
@ -191,14 +201,14 @@
android:layout_below="@id/playlist_selection_chips_scrollview" android:layout_below="@id/playlist_selection_chips_scrollview"
android:clipToPadding="false" android:clipToPadding="false"
android:paddingBottom="200dp" android:paddingBottom="200dp"
app:spanCount="3" app:spanCount="2"
app:layoutManager="androidx.recyclerview.widget.GridLayoutManager" app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
/> />
<com.facebook.shimmer.ShimmerFrameLayout <com.facebook.shimmer.ShimmerFrameLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_below="@id/recyclerViewHome"
android:layout_height="match_parent" android:layout_height="match_parent"
android:layout_below="@id/recyclerViewHome"
android:id="@+id/shimmer_results_framelayout" android:id="@+id/shimmer_results_framelayout"
android:orientation="vertical"> android:orientation="vertical">
@ -210,30 +220,7 @@
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:weightSum="3" android:weightSum="2"
android:layout_height="wrap_content">
<include
android:layout_weight="1"
layout="@layout/result_card_shimmer"
android:layout_height="wrap_content"
android:layout_width="0dp" />
<include
android:layout_weight="1"
layout="@layout/result_card_shimmer"
android:layout_height="wrap_content"
android:layout_width="0dp" />
<include
android:layout_weight="1"
layout="@layout/result_card_shimmer"
android:layout_height="wrap_content"
android:layout_width="0dp" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:weightSum="3"
android:layout_height="wrap_content"> android:layout_height="wrap_content">
<include <include
@ -256,7 +243,7 @@
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:weightSum="3" android:weightSum="2"
android:layout_height="wrap_content"> android:layout_height="wrap_content">
<include <include
@ -279,7 +266,7 @@
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:weightSum="3" android:weightSum="2"
android:layout_height="wrap_content"> android:layout_height="wrap_content">
<include <include
@ -303,11 +290,9 @@
</LinearLayout> </LinearLayout>
</com.facebook.shimmer.ShimmerFrameLayout> </com.facebook.shimmer.ShimmerFrameLayout>
</RelativeLayout> </RelativeLayout>
<LinearLayout <LinearLayout
@ -324,8 +309,8 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="bottom|end" android:layout_gravity="bottom|end"
android:text="@string/link_you_copied" android:text="@string/link_you_copied"
android:layout_marginHorizontal="16dp"
android:layout_marginBottom="10dp" android:layout_marginBottom="10dp"
android:layout_marginHorizontal="16dp"
android:contentDescription="@string/link_you_copied" android:contentDescription="@string/link_you_copied"
app:icon="@drawable/ic_clipboard" app:icon="@drawable/ic_clipboard"
app:useCompatPadding="true" /> app:useCompatPadding="true" />

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.floatingactionbutton.FloatingActionButton android:layout_width="wrap_content"
style="@style/Widget.MaterialComponents.Button.UnelevatedButton"
android:layout_height="wrap_content"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:contentDescription="@string/more"
android:src="@drawable/ic_app_icon"
app:maxImageSize="70dp"
app:tint="@color/icon_fg"
android:backgroundTint="#00000000"
app:borderWidth="0dp"
xmlns:android="http://schemas.android.com/apk/res/android" />

@ -3,6 +3,7 @@
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
android:orientation="horizontal" android:orientation="horizontal"
android:layout_marginStart="15dp" android:layout_marginStart="15dp"
android:layout_marginTop="10dp"
xmlns:android="http://schemas.android.com/apk/res/android"> xmlns:android="http://schemas.android.com/apk/res/android">
<com.google.android.material.button.MaterialButton <com.google.android.material.button.MaterialButton

@ -61,6 +61,27 @@
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content"> android:layout_height="wrap_content">
<TextView
android:id="@+id/autoGeneratedNewPipe"
style="@style/Widget.Material3.FloatingActionButton.Large.Tertiary"
android:layout_width="wrap_content"
android:visibility="gone"
android:layout_height="wrap_content"
android:text="NewPipe Autogenerated"
android:background="@drawable/rounded_corner"
android:backgroundTint="?attr/colorAccent"
android:clickable="false"
android:gravity="center"
android:minWidth="30dp"
android:paddingHorizontal="5dp"
android:textSize="12sp"
android:textStyle="bold"
app:cornerRadius="10dp"
app:layout_constraintTop_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
tools:ignore="HardcodedText" />
<TextView <TextView
android:id="@+id/urlRegex" android:id="@+id/urlRegex"
style="@style/Widget.Material3.FloatingActionButton.Large.Secondary" style="@style/Widget.Material3.FloatingActionButton.Large.Secondary"

@ -90,7 +90,7 @@
style="@style/Widget.Material3.TextInputLayout.FilledBox" style="@style/Widget.Material3.TextInputLayout.FilledBox"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:hint="@string/title" android:hint="@string/player_client"
android:tag="title" android:tag="title"
android:paddingBottom="10dp"> android:paddingBottom="10dp">

@ -1,5 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<integer name="grid_size">3</integer> <integer name="grid_size">2</integer>
<bool name="uses_side_nav">true</bool> <bool name="isTablet">true</bool>
</resources> </resources>

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<integer name="grid_size">3</integer>
<bool name="uses_side_nav">true</bool>
</resources>

@ -472,4 +472,6 @@
<string name="automatic_backup_summary">Automatically make a backup of everything when a new version the application is found</string> <string name="automatic_backup_summary">Automatically make a backup of everything when a new version the application is found</string>
<string name="write_subs_when_embed_subs">Write subtitles when embedding</string> <string name="write_subs_when_embed_subs">Write subtitles when embedding</string>
<string name="write_subs_when_embed_subs_summary">This will apply Save Subtitles and Save Automatic Subtitles to download the sub files and then delete them after embedding</string> <string name="write_subs_when_embed_subs_summary">This will apply Save Subtitles and Save Automatic Subtitles to download the sub files and then delete them after embedding</string>
<string name="use_newpipe_potoken">Use Autogenerated PO Tokens from NewPipe Extractor</string>
<string name="use_newpipe_potoken_warning">By enabling this, the app will disable cookies and use the generated po tokens and visitor data as YouTube Extractor Arguments</string>
</resources> </resources>

@ -10,6 +10,13 @@
android:summary="@string/player_client_summary" android:summary="@string/player_client_summary"
android:title="@string/player_client" /> android:title="@string/player_client" />
<SwitchPreferenceCompat
android:widgetLayout="@layout/preferece_material_switch"
android:icon="@drawable/ic_language"
android:defaultValue="false"
android:key="use_newpipe_potoken"
android:title="@string/use_newpipe_potoken" />
<SwitchPreferenceCompat <SwitchPreferenceCompat
android:widgetLayout="@layout/preferece_material_switch" android:widgetLayout="@layout/preferece_material_switch"
android:icon="@drawable/ic_language" android:icon="@drawable/ic_language"

@ -44,7 +44,7 @@
<SwitchPreferenceCompat <SwitchPreferenceCompat
android:widgetLayout="@layout/preferece_material_switch" android:widgetLayout="@layout/preferece_material_switch"
app:defaultValue="false" app:defaultValue="true"
app:icon="@drawable/ic_clock" app:icon="@drawable/ic_clock"
app:key="automatic_backup" app:key="automatic_backup"
android:summary="@string/automatic_backup_summary" android:summary="@string/automatic_backup_summary"

@ -0,0 +1,8 @@
# What's Changed
- Added ability to automatically generate Po Tokens and Visitor Data through NewPipe Extractor
- Fixed auto resume not working #727
- Applied "Prefer Container over Codec" preference on default format sorting
- Added automatic Backup
- Added milliseconds in the cut section
and more
Loading…
Cancel
Save