1.8.3 pre
This commit is contained in:
parent
e4c08c2c45
commit
3b3f79719d
30 changed files with 1184 additions and 224 deletions
25
CHANGELOG.md
25
CHANGELOG.md
|
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
127
app/src/main/assets/po_token.html
Normal file
127
app/src/main/assets/po_token.html
Normal file
|
|
@ -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,8 +5,9 @@ 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(
|
||||||
var context: String,
|
var context: String,
|
||||||
|
|
|
||||||
|
|
@ -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,19 +91,10 @@
|
||||||
android:layout_height="wrap_content">
|
android:layout_height="wrap_content">
|
||||||
|
|
||||||
<androidx.constraintlayout.widget.ConstraintLayout
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="wrap_content"
|
||||||
app:layout_scrollFlags="scroll"
|
android:layout_gravity="end"
|
||||||
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"
|
||||||
|
|
@ -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,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
|
||||||
|
|
@ -243,7 +233,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 +256,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
|
||||||
|
|
@ -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,7 +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">
|
android:layout_height="wrap_content">
|
||||||
|
|
||||||
<include
|
<include
|
||||||
|
|
@ -233,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
|
||||||
|
|
@ -256,30 +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">
|
|
||||||
|
|
||||||
<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
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
12
app/src/main/res/layout/app_logo_fab.xml
Normal file
12
app/src/main/res/layout/app_logo_fab.xml
Normal file
|
|
@ -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>
|
||||||
5
app/src/main/res/values-sw800dp-land/attrs.xml
Normal file
5
app/src/main/res/values-sw800dp-land/attrs.xml
Normal file
|
|
@ -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"
|
||||||
|
|
|
||||||
8
fastlane/metadata/android/en-US/changelogs/108030000.txt
Normal file
8
fastlane/metadata/android/en-US/changelogs/108030000.txt
Normal file
|
|
@ -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…
Reference in a new issue