diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 000000000..c9a7624be
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1 @@
+# LFS tracking removed to allow pushing to public fork
diff --git a/README.md b/README.md
index f90680386..b5ebf3c31 100644
--- a/README.md
+++ b/README.md
@@ -221,6 +221,7 @@ MpvRx pushes the mpv-android experience further with deep customization, thermal
| **Display Cutout Mode** | Full-bleed on notch devices |
| **Remember Brightness** | Persists brightness level set during playback |
| **M3U Playlist Support** | Parse and play local M3U playlists |
+| **yt-dlp Integration** | High-performance streaming support for YouTube, Twitch, Bilibili, and more via a native Python bridge (SDK 29+ bypass) |
@@ -353,6 +354,7 @@ git push origin v1.3.1-preview.1
- [Next Player](https://github.com/anilbeesetti/nextplayer)
- [Gramophone](https://github.com/FoedusProgramme/Gramophone)
- [hdr-toys](https://github.com/natural-harmonia-gropius/hdr-toys)
+- [**SunnyVishnu3**](https://github.com/SunnyVishnu3) for the `yt-dlp` native integration and SDK 29+ bypass logic.
---
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 672113e4f..6cb085e91 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -26,6 +26,19 @@ android {
buildConfigField("String", "GIT_SHA", "\"${getCommitSha()}\"")
buildConfigField("int", "GIT_COUNT", getCommitCount())
+
+ externalNativeBuild {
+ cmake {
+ abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64")
+ }
+ }
+ }
+
+ externalNativeBuild {
+ cmake {
+ path = file("src/main/cpp/CMakeLists.txt")
+ version = "3.22.1"
+ }
}
flavorDimensions += "distribution"
diff --git a/app/libs/mpvlib.aar b/app/libs/mpvlib.aar
index ed08e6423..38508beba 100644
Binary files a/app/libs/mpvlib.aar and b/app/libs/mpvlib.aar differ
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 2861c3f54..3959fcf22 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -15,6 +15,8 @@
android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" />
+
+
@@ -26,6 +28,7 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/assets/ytdl/python313.zip b/app/src/main/assets/ytdl/python313.zip
new file mode 100644
index 000000000..8a8650a19
Binary files /dev/null and b/app/src/main/assets/ytdl/python313.zip differ
diff --git a/app/src/main/assets/ytdl/setup.py b/app/src/main/assets/ytdl/setup.py
new file mode 100644
index 000000000..c43c098f7
--- /dev/null
+++ b/app/src/main/assets/ytdl/setup.py
@@ -0,0 +1,56 @@
+import sys, os, urllib.request
+
+# Argument 1: Native Library Directory (passed from Java)
+native_lib_dir = sys.argv[1] if len(sys.argv) > 1 else ""
+
+# scriptdest is the path for a legacy wrapper if needed
+scriptdest = "../youtube-dl.sh"
+name = "yt-dlp"
+url = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp"
+
+# Clean up old files first
+for path in (scriptdest, "youtube-dl", name):
+ try:
+ if os.path.exists(path): os.unlink(path)
+ except:
+ pass
+
+print("Downloading '{}' to '{}'...".format(url, name))
+try:
+ # Use a real browser user-agent for download
+ opener = urllib.request.build_opener()
+ opener.addheaders = [('User-Agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36')]
+ urllib.request.install_opener(opener)
+
+ urllib.request.urlretrieve(url, name)
+ print("Download successful.")
+except Exception as e:
+ print("Download failed: " + str(e))
+ sys.exit(1)
+
+# Ensure executable bit is NOT set on the data directory script
+# to avoid SELinux denials on Android 10+.
+# We run it via the native libytdl.so -> libpython.so bridge instead.
+try:
+ os.chmod(name, 0o600)
+except:
+ pass
+
+# Create a .sh wrapper as a reference
+try:
+ with open(scriptdest, "w") as f:
+ ytdl_dir = os.getcwd()
+ cacert = os.path.abspath(os.path.join(ytdl_dir, "../cacert.pem"))
+
+ f.write("#!/system/bin/sh\n")
+ f.write("NATIVE_LIB_DIR=\"{}\"\n".format(native_lib_dir))
+ f.write("export PYTHONHOME=\"{}\"\n".format(ytdl_dir))
+ f.write("export PYTHONPATH=\"{}/python313.zip\"\n".format(ytdl_dir))
+ f.write("export SSL_CERT_FILE=\"{}\"\n".format(cacert))
+ f.write("export LD_LIBRARY_PATH=\"$NATIVE_LIB_DIR\"\n")
+ f.write("exec \"$NATIVE_LIB_DIR/libytdl.so\" \"$@\"\n")
+
+ os.chmod(scriptdest, 0o700)
+ print("Created reference wrapper at {}".format(scriptdest))
+except:
+ print("Warning: Could not create wrapper")
diff --git a/app/src/main/assets/ytdl/wrapper b/app/src/main/assets/ytdl/wrapper
new file mode 100644
index 000000000..eeefebf03
--- /dev/null
+++ b/app/src/main/assets/ytdl/wrapper
@@ -0,0 +1,9 @@
+#!/system/bin/sh
+NATIVE_LIB_DIR=$1
+shift
+cd $(dirname "$0")
+export PYTHONHOME=.
+export PYTHONPATH=./python313.zip
+export SSL_CERT_FILE=$PWD/../cacert.pem
+export LD_LIBRARY_PATH=$NATIVE_LIB_DIR
+exec $NATIVE_LIB_DIR/libytdl.so "$@"
diff --git a/app/src/main/cpp/CMakeLists.txt b/app/src/main/cpp/CMakeLists.txt
new file mode 100644
index 000000000..7fd068ea8
--- /dev/null
+++ b/app/src/main/cpp/CMakeLists.txt
@@ -0,0 +1,10 @@
+cmake_minimum_required(VERSION 3.22.1)
+
+project("ytdl_bridge")
+
+# YTDLP execution wrapper for SDK 29+ bypass
+# This works on all architectures
+add_executable(ytdl_wrapper ytdl_wrapper.c)
+set_target_properties(ytdl_wrapper PROPERTIES OUTPUT_NAME "ytdl")
+# Rename to libytdl.so so it gets extracted by Android
+set_target_properties(ytdl_wrapper PROPERTIES PREFIX "lib" SUFFIX ".so")
diff --git a/app/src/main/cpp/ytdl_wrapper.c b/app/src/main/cpp/ytdl_wrapper.c
new file mode 100644
index 000000000..ddf7bb1ff
--- /dev/null
+++ b/app/src/main/cpp/ytdl_wrapper.c
@@ -0,0 +1,83 @@
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+/*
+ * libytdl: A native bridge for yt-dlp on Android 10+
+ * This executable hosts the Python interpreter by loading libpython.so dynamically.
+ * It bypasses the "no exec from data directory" restriction by living in lib/.
+ */
+
+typedef int (*Py_BytesMain_t)(int argc, char **argv);
+typedef void (*Py_SetProgramName_t)(const wchar_t *);
+
+int main(int argc, char *argv[]) {
+ char *python_lib = getenv("YTDL_PYTHON");
+ char *script_path = getenv("YTDL_SCRIPT");
+
+ // Use a hardcoded fallback if env var is missing
+ if (!python_lib || strlen(python_lib) == 0) {
+ python_lib = "libpython.so";
+ }
+
+ // Load the Python shared library
+ void *handle = dlopen(python_lib, RTLD_NOW | RTLD_GLOBAL);
+ if (!handle) {
+ handle = dlopen("libpython.so", RTLD_NOW | RTLD_GLOBAL);
+ }
+
+ if (!handle) {
+ fprintf(stderr, "libytdl: CRITICAL: Could not load libpython.so: %s\n", dlerror());
+ return 127;
+ }
+
+ // Optional: Set program name to the binary path to help Python find its libs
+ Py_SetProgramName_t Py_SetProgramName = (Py_SetProgramName_t)dlsym(handle, "Py_SetProgramName");
+ if (Py_SetProgramName) {
+ // Simple conversion for the program name
+ wchar_t wprog[512];
+ mbstowcs(wprog, argv[0], 511);
+ wprog[511] = L'\0';
+ Py_SetProgramName(wprog);
+ }
+
+ // Find Py_BytesMain (Standard entry point for Python 3.8+)
+ Py_BytesMain_t Py_BytesMain = (Py_BytesMain_t)dlsym(handle, "Py_BytesMain");
+ if (!Py_BytesMain) {
+ // Fallback for older Python 3 versions (Py_Main uses wchar_t**, which is harder to use here)
+ // But since we expect Python 3.8+, Py_BytesMain should be there.
+ fprintf(stderr, "libytdl: CRITICAL: Could not find Py_BytesMain in libpython.so\n");
+ dlclose(handle);
+ return 127;
+ }
+
+ int result;
+ if (script_path && strlen(script_path) > 0) {
+ // Correct way to run a script: python script.py arg1 arg2 ...
+ // We need original_argc + 1 slots for: "python", script_path, argv[1...], and NULL
+ int new_argc = argc + 1;
+ char **python_argv = malloc((new_argc + 1) * sizeof(char *));
+ if (!python_argv) return 1;
+
+ python_argv[0] = "python";
+ python_argv[1] = script_path;
+ for (int i = 1; i < argc; i++) {
+ python_argv[i + 1] = argv[i];
+ }
+ python_argv[new_argc] = NULL;
+
+ result = Py_BytesMain(new_argc, python_argv);
+ free(python_argv);
+ } else {
+ // If YTDL_SCRIPT is not set, we act as a transparent python interpreter.
+ result = Py_BytesMain(argc, argv);
+ }
+
+ // Note: We don't dlclose(handle) here as Py_BytesMain might have registered
+ // atexit handlers that need the library to remain loaded until process exit.
+ return result;
+}
diff --git a/app/src/main/java/app/gyrolet/mpvrx/di/PreferencesModule.kt b/app/src/main/java/app/gyrolet/mpvrx/di/PreferencesModule.kt
index 3597a0144..200408fa8 100644
--- a/app/src/main/java/app/gyrolet/mpvrx/di/PreferencesModule.kt
+++ b/app/src/main/java/app/gyrolet/mpvrx/di/PreferencesModule.kt
@@ -12,6 +12,7 @@ import app.gyrolet.mpvrx.preferences.GesturePreferences
import app.gyrolet.mpvrx.preferences.PlayerPreferences
import app.gyrolet.mpvrx.preferences.SettingsManager
import app.gyrolet.mpvrx.preferences.SubtitlesPreferences
+import app.gyrolet.mpvrx.preferences.YtdlPreferences
import app.gyrolet.mpvrx.preferences.preference.AndroidPreferenceStore
import app.gyrolet.mpvrx.preferences.preference.PreferenceStore
import org.koin.android.ext.koin.androidContext
@@ -33,6 +34,7 @@ val PreferencesModule =
single { BrowserPreferences(get(), androidContext()) }
singleOf(::FoldersPreferences)
singleOf(::AiPreferences)
+ singleOf(::YtdlPreferences)
singleOf(::SettingsManager)
}
diff --git a/app/src/main/java/app/gyrolet/mpvrx/preferences/YtdlPreferences.kt b/app/src/main/java/app/gyrolet/mpvrx/preferences/YtdlPreferences.kt
new file mode 100644
index 000000000..034989439
--- /dev/null
+++ b/app/src/main/java/app/gyrolet/mpvrx/preferences/YtdlPreferences.kt
@@ -0,0 +1,11 @@
+package app.gyrolet.mpvrx.preferences
+
+import app.gyrolet.mpvrx.preferences.preference.PreferenceStore
+
+class YtdlPreferences(
+ preferenceStore: PreferenceStore,
+) {
+ val ytdlFormat = preferenceStore.getString("video_ytdl_format", "")
+ val ytdlQuality = preferenceStore.getInt("ytdl_quality", -1) // -1 for any
+ val preferH264 = preferenceStore.getBoolean("ytdl_prefer_h264", false)
+}
diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/icons/Icons.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/icons/Icons.kt
index 4e3fedba3..e5aa95389 100644
--- a/app/src/main/java/app/gyrolet/mpvrx/ui/icons/Icons.kt
+++ b/app/src/main/java/app/gyrolet/mpvrx/ui/icons/Icons.kt
@@ -237,6 +237,7 @@ object Icons {
val ChevronRight = Shared.ChevronRight
val Clear = Shared.Clear
val Close = Shared.Close
+ val CloudDownload = Shared.CloudDownload
val Code = Shared.Code
val ContentCopy = Shared.ContentCopy
val CreateNewFolder = Shared.CreateNewFolder
diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/player/MPVView.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/player/MPVView.kt
index c4dda4f25..ba2b77527 100644
--- a/app/src/main/java/app/gyrolet/mpvrx/ui/player/MPVView.kt
+++ b/app/src/main/java/app/gyrolet/mpvrx/ui/player/MPVView.kt
@@ -13,9 +13,11 @@ import app.gyrolet.mpvrx.preferences.AudioPreferences
import app.gyrolet.mpvrx.preferences.DecoderPreferences
import app.gyrolet.mpvrx.preferences.PlayerPreferences
import app.gyrolet.mpvrx.preferences.SubtitlesPreferences
+import app.gyrolet.mpvrx.preferences.YtdlPreferences
import app.gyrolet.mpvrx.domain.anime4k.Anime4KManager
import app.gyrolet.mpvrx.domain.hdr.HdrToysManager
import app.gyrolet.mpvrx.ui.player.PlayerActivity.Companion.TAG
+import app.gyrolet.mpvrx.ui.player.ytdlp.YtdlpManager
import app.gyrolet.mpvrx.ui.player.controls.components.panels.toColorHexString
import app.gyrolet.mpvrx.ui.preferences.VulkanUtils
import `is`.xyz.mpv.BaseMPVView
@@ -35,6 +37,7 @@ class MPVView(
private val decoderPreferences: DecoderPreferences by inject()
private val advancedPreferences: AdvancedPreferences by inject()
private val subtitlesPreferences: SubtitlesPreferences by inject()
+ private val ytdlPreferences: YtdlPreferences by inject()
private val anime4kManager: Anime4KManager by inject()
private val hdrToysManager: HdrToysManager by inject()
@@ -189,6 +192,7 @@ class MPVView(
setupSubtitlesOptions()
setupAudioOptions()
+ YtdlpManager.setupMpvOptions(context, ytdlPreferences)
}
override fun observeProperties() {
diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/player/PlayerActivity.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/player/PlayerActivity.kt
index 0c5262401..f838d71dd 100644
--- a/app/src/main/java/app/gyrolet/mpvrx/ui/player/PlayerActivity.kt
+++ b/app/src/main/java/app/gyrolet/mpvrx/ui/player/PlayerActivity.kt
@@ -64,6 +64,7 @@ import app.gyrolet.mpvrx.preferences.PlayerPreferences
import app.gyrolet.mpvrx.preferences.SubtitlesPreferences
import app.gyrolet.mpvrx.preferences.VideoSortType
import app.gyrolet.mpvrx.ui.player.controls.PlayerControls
+import app.gyrolet.mpvrx.ui.player.ytdlp.YtdlpManager
import app.gyrolet.mpvrx.ui.theme.MpvrxTheme
import app.gyrolet.mpvrx.utils.history.RecentlyPlayedOps
import app.gyrolet.mpvrx.utils.media.HttpUtils
@@ -527,6 +528,14 @@ class PlayerActivity :
setHttpHeadersFromExtras(intent.extras)
getPlayableUri(intent)?.let { playableUri ->
+ // Remind user if they forgot to set up yt-dlp
+ if (playableUri.startsWith("http") && !playableUri.substringAfterLast('/').contains('.')) {
+ val ytdlDir = YtdlpManager.getYtdlDir(this)
+ if (!File(ytdlDir, "yt-dlp").exists()) {
+ viewModel.showToast(getString(R.string.toast_need_ytdl))
+ }
+ }
+
currentPlayableUri = playableUri
isReady = false
viewModel.onVideoLoadStarted()
@@ -3116,6 +3125,14 @@ class PlayerActivity :
// Load the new file
getPlayableUri(intent)?.let { uri ->
+ // Remind user if they forgot to set up yt-dlp
+ if (uri.startsWith("http") && !uri.substringAfterLast('/').contains('.')) {
+ val ytdlDir = YtdlpManager.getYtdlDir(this)
+ if (!File(ytdlDir, "yt-dlp").exists()) {
+ viewModel.showToast(getString(R.string.toast_need_ytdl))
+ }
+ }
+
currentPlayableUri = uri
isReady = false
viewModel.onVideoLoadStarted()
diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/PlayerPanels.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/PlayerPanels.kt
index d68022629..8b70ba407 100644
--- a/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/PlayerPanels.kt
+++ b/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/PlayerPanels.kt
@@ -82,4 +82,3 @@ val panelCardsColors: @Composable () -> CardColors = {
disabledContentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f),
)
}
-
diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/components/panels/YtdlpPanel.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/components/panels/YtdlpPanel.kt
new file mode 100644
index 000000000..456cf6bb1
--- /dev/null
+++ b/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/components/panels/YtdlpPanel.kt
@@ -0,0 +1,123 @@
+package app.gyrolet.mpvrx.ui.player.controls.components.panels
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.unit.dp
+import app.gyrolet.mpvrx.ui.player.ytdlp.YtdlpManager
+import app.gyrolet.mpvrx.ui.theme.spacing
+import kotlinx.coroutines.launch
+import java.io.File
+
+@Composable
+fun YtdlpPanel(
+ modifier: Modifier = Modifier
+) {
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
+ var logs by remember { mutableStateOf("") }
+ val scrollState = rememberScrollState()
+ var isRunning by remember { mutableStateOf(false) }
+
+ val ytdlDir = remember { YtdlpManager.getYtdlDir(context) }
+ var hasYtdlp by remember { mutableStateOf(File(ytdlDir, "yt-dlp").exists()) }
+
+ LaunchedEffect(isRunning) {
+ if (!isRunning) {
+ hasYtdlp = File(ytdlDir, "yt-dlp").exists()
+ }
+ }
+
+ LaunchedEffect(logs) {
+ scrollState.animateScrollTo(scrollState.maxValue)
+ }
+
+ DraggablePanel(
+ modifier = modifier
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(MaterialTheme.spacing.medium),
+ verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.medium)
+ ) {
+ Text(
+ text = "yt-dlp Manager",
+ style = MaterialTheme.typography.headlineSmall
+ )
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.small)
+ ) {
+ Button(
+ onClick = {
+ scope.launch {
+ isRunning = true
+ logs = ""
+ YtdlpManager.runInstall(context) { line ->
+ logs += line
+ }
+ isRunning = false
+ }
+ },
+ enabled = !isRunning,
+ modifier = Modifier.weight(1f)
+ ) {
+ Text("Install")
+ }
+
+ Button(
+ onClick = {
+ scope.launch {
+ isRunning = true
+ logs = ""
+ YtdlpManager.runUpdate(context) { line ->
+ logs += line
+ }
+ isRunning = false
+ }
+ },
+ enabled = !isRunning && hasYtdlp,
+ modifier = Modifier.weight(1f)
+ ) {
+ Text("Update")
+ }
+ }
+
+ Surface(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxWidth(),
+ color = MaterialTheme.colorScheme.surfaceVariant,
+ shape = MaterialTheme.shapes.medium
+ ) {
+ Column(
+ modifier = Modifier
+ .padding(MaterialTheme.spacing.small)
+ .verticalScroll(scrollState)
+ ) {
+ Text(
+ text = logs.ifEmpty { "Ready..." },
+ style = MaterialTheme.typography.bodySmall,
+ fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace
+ )
+ }
+ }
+
+ if (isRunning) {
+ LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
+ }
+
+ Text(
+ text = "Bypass SDK 29+ active (using libpython.so)",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.secondary
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/player/ytdlp/YtdlpManager.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/player/ytdlp/YtdlpManager.kt
new file mode 100644
index 000000000..a42b6e0cf
--- /dev/null
+++ b/app/src/main/java/app/gyrolet/mpvrx/ui/player/ytdlp/YtdlpManager.kt
@@ -0,0 +1,200 @@
+package app.gyrolet.mpvrx.ui.player.ytdlp
+
+import android.content.Context
+import android.system.Os
+import android.util.Log
+import app.gyrolet.mpvrx.preferences.YtdlPreferences
+import `is`.xyz.mpv.MPVLib
+import java.io.*
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+object YtdlpManager {
+ private const val TAG = "YtdlpManager"
+ private const val YTDL_DIR = "ytdl"
+
+ fun getYtdlDir(context: Context): File {
+ return File(context.filesDir, YTDL_DIR).apply { if (!exists()) mkdirs() }
+ }
+
+ fun getExecutablePath(context: Context): String {
+ return File(context.applicationInfo.nativeLibraryDir, "libytdl.so").absolutePath
+ }
+
+ suspend fun copyAssets(context: Context) = withContext(Dispatchers.IO) {
+ val ytdlDir = getYtdlDir(context)
+
+ // Clean up old potentially problematic scripts from multiple possible locations
+ listOf("youtube-dl", "youtube-dl.sh").forEach { name ->
+ File(context.filesDir, name).delete()
+ File(ytdlDir, name).delete()
+ }
+
+ // Files to copy from assets/ytdl/ to filesDir/ytdl/
+ val ytdlFiles = arrayOf("setup.py", "wrapper", "python313.zip")
+ for (name in ytdlFiles) {
+ copyAssetFile(context, "ytdl/$name", File(ytdlDir, name))
+ }
+
+ // cacert.pem goes to filesDir/
+ copyAssetFile(context, "cacert.pem", File(context.filesDir, "cacert.pem"))
+
+ // Set executable permission on wrapper (just in case it's used)
+ File(ytdlDir, "wrapper").setExecutable(true)
+ }
+
+ private fun copyAssetFile(context: Context, assetPath: String, outFile: File): Boolean {
+ return try {
+ context.assets.open(assetPath).use { input ->
+ val size = input.available().toLong()
+ if (outFile.exists() && outFile.length() == size) {
+ Log.v(TAG, "Skipping copy: $assetPath (exists same size)")
+ return true
+ }
+ FileOutputStream(outFile).use { output ->
+ input.copyTo(output)
+ }
+ Log.d(TAG, "Copied asset: $assetPath")
+ true
+ }
+ } catch (e: IOException) {
+ Log.e(TAG, "Failed to copy asset: $assetPath", e)
+ false
+ }
+ }
+
+ fun setupMpvOptions(context: Context, ytdlPreferences: YtdlPreferences) {
+ val nativeLibDir = context.applicationInfo.nativeLibraryDir
+ val ytdlBinaryPath = File(nativeLibDir, "libytdl.so").absolutePath
+ val ytdlDir = getYtdlDir(context).absolutePath
+ val ytDlpScriptPath = File(ytdlDir, "yt-dlp").absolutePath
+ val pythonPath = File(nativeLibDir, "libpython.so").absolutePath
+
+ // Set environment variables for the subprocesses started by libmpv
+ try {
+ Os.setenv("YTDL_PYTHON", pythonPath, true)
+ Os.setenv("YTDL_SCRIPT", ytDlpScriptPath, true)
+ Os.setenv("PYTHONHOME", ytdlDir, true)
+ // Include both the zip and the directory itself in PYTHONPATH
+ // Also include nativeLibDir for potential .so modules
+ Os.setenv("PYTHONPATH", "$ytdlDir/python313.zip:$ytdlDir:$nativeLibDir", true)
+ Os.setenv("SSL_CERT_FILE", File(context.filesDir, "cacert.pem").absolutePath, true)
+
+ // Add nativeLibDir to PATH so scripts can find our bridge if they search PATH
+ val currentPath = runCatching { Os.getenv("PATH") }.getOrNull()
+ val newPath = if (currentPath.isNullOrBlank()) nativeLibDir else "$nativeLibDir:$currentPath"
+ Os.setenv("PATH", newPath, true)
+
+ // Set LD_LIBRARY_PATH for the subprocess to find libpython.so's dependencies
+ val currentLd = runCatching { Os.getenv("LD_LIBRARY_PATH") }.getOrNull()
+ val newLd = if (currentLd.isNullOrBlank()) nativeLibDir else "$nativeLibDir:$currentLd"
+ Os.setenv("LD_LIBRARY_PATH", newLd, true)
+
+ Log.d(TAG, "Environment variables set for ytdl bridge")
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to set environment variables", e)
+ }
+
+ // Check if yt-dlp actually exists. If not, log a warning.
+ val ytDlpFile = File(ytdlDir, "yt-dlp")
+ if (!ytDlpFile.exists()) {
+ Log.w(TAG, "yt-dlp not found in ${ytDlpFile.absolutePath}. Subprocess will fail until installed.")
+ }
+
+ val ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
+
+ // Create script-opts/ytdl_hook.conf to ensure the script picks up our bridge
+ // This is the most reliable way to override ytdl_hook options
+ try {
+ val scriptOptsDir = File(context.filesDir, "script-opts")
+ if (!scriptOptsDir.exists()) scriptOptsDir.mkdirs()
+ val ytdlConf = File(scriptOptsDir, "ytdl_hook.conf")
+ val confContent = """
+ ytdl_path=$ytdlBinaryPath
+ all_formats=yes
+ """.trimIndent()
+ ytdlConf.writeText(confContent)
+ Log.d(TAG, "Created ytdl_hook.conf at ${ytdlConf.absolutePath}")
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to create ytdl_hook.conf", e)
+ }
+
+ // Apply options to MPV core
+ MPVLib.setOptionString("ytdl", "yes")
+ MPVLib.setOptionString("ytdl-path", ytdlBinaryPath)
+
+ // Use script-opts-append for runtime flexibility
+ MPVLib.setOptionString("script-opts-append", "ytdl_hook-path=$ytdlBinaryPath")
+ MPVLib.setOptionString("script-opts-append", "ytdl_hook-ytdl_path=$ytdlBinaryPath")
+
+ val ytdlFormat = ytdlPreferences.ytdlFormat.get()
+ if (!ytdlFormat.isNullOrBlank()) {
+ MPVLib.setOptionString("ytdl-format", ytdlFormat)
+ }
+
+ // Global User-Agent to avoid blocks at the network level
+ MPVLib.setOptionString("user-agent", ua)
+ MPVLib.setOptionString("ytdl-raw-options", "user-agent=\"$ua\"")
+ MPVLib.setOptionString("script-opts-append", "ytdl_hook-user_agent=\"$ua\"")
+
+ Log.d(TAG, "MPV ytdl options set. Binary: $ytdlBinaryPath")
+ }
+
+ suspend fun runInstall(context: Context, onLog: (String) -> Unit): Boolean = withContext(Dispatchers.IO) {
+ copyAssets(context)
+
+ val ytdlDir = getYtdlDir(context)
+ val nativeLibDir = context.applicationInfo.nativeLibraryDir
+ val pythonBinary = getExecutablePath(context)
+ val setupPy = File(ytdlDir, "setup.py").absolutePath
+
+ // We use the bridge to run setup.py
+ val command = mutableListOf(pythonBinary, setupPy, nativeLibDir)
+
+ runPythonProcess("Installing yt-dlp...", command, context, onLog)
+ }
+
+ suspend fun runUpdate(context: Context, onLog: (String) -> Unit): Boolean = withContext(Dispatchers.IO) {
+ val ytdlDir = getYtdlDir(context)
+ val pythonBinary = getExecutablePath(context)
+ val ytDlp = File(ytdlDir, "yt-dlp").absolutePath
+
+ val command = mutableListOf(pythonBinary, ytDlp, "--update")
+
+ runPythonProcess("Updating yt-dlp...", command, context, onLog)
+ }
+
+ private fun runPythonProcess(title: String, command: List, context: Context, onLog: (String) -> Unit): Boolean {
+ onLog("$title\n")
+ return try {
+ val processBuilder = ProcessBuilder(command)
+ .directory(getYtdlDir(context))
+ .redirectErrorStream(true)
+
+ val env = processBuilder.environment()
+ val ytdlDir = getYtdlDir(context).absolutePath
+ val nativeLibDir = context.applicationInfo.nativeLibraryDir
+
+ // Clear YTDL_SCRIPT so the bridge doesn't try to wrap yt-dlp during setup/update
+ env.remove("YTDL_SCRIPT")
+
+ env["YTDL_PYTHON"] = File(nativeLibDir, "libpython.so").absolutePath
+ env["PYTHONHOME"] = ytdlDir
+ env["PYTHONPATH"] = "$ytdlDir/python313.zip"
+ env["SSL_CERT_FILE"] = File(context.filesDir, "cacert.pem").absolutePath
+ env["LD_LIBRARY_PATH"] = nativeLibDir
+
+ val process = processBuilder.start()
+
+ val reader = BufferedReader(InputStreamReader(process.inputStream))
+ var line: String?
+ while (reader.readLine().also { line = it } != null) {
+ onLog(line + "\n")
+ }
+ process.waitFor() == 0
+ } catch (e: Exception) {
+ onLog("Error: ${e.message}\n")
+ false
+ }
+ }
+}
diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/AdvancedPreferencesScreen.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/AdvancedPreferencesScreen.kt
index 976d2a526..6d9b0dc0b 100644
--- a/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/AdvancedPreferencesScreen.kt
+++ b/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/AdvancedPreferencesScreen.kt
@@ -485,6 +485,28 @@ object AdvancedPreferencesScreen : Screen {
backStack.add(app.gyrolet.mpvrx.ui.preferences.CustomButtonScreen)
},
)
+
+ PreferenceDivider()
+
+ Preference(
+ title = { Text("yt-dlp Manager") },
+ summary = {
+ Text(
+ "Install and update yt-dlp for streaming support",
+ color = MaterialTheme.colorScheme.outline
+ )
+ },
+ icon = {
+ Icon(
+ Icons.Default.CloudDownload,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary
+ )
+ },
+ onClick = {
+ backStack.add(YtdlpSettingsScreen)
+ },
+ )
}
}
diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/YtdlpSettingsScreen.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/YtdlpSettingsScreen.kt
new file mode 100644
index 000000000..bf62beffc
--- /dev/null
+++ b/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/YtdlpSettingsScreen.kt
@@ -0,0 +1,234 @@
+package app.gyrolet.mpvrx.ui.preferences
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import app.gyrolet.mpvrx.R
+import app.gyrolet.mpvrx.preferences.YtdlPreferences
+import app.gyrolet.mpvrx.preferences.preference.collectAsState
+import app.gyrolet.mpvrx.presentation.Screen
+import app.gyrolet.mpvrx.ui.icons.Icon
+import app.gyrolet.mpvrx.ui.icons.Icons
+import app.gyrolet.mpvrx.ui.player.ytdlp.YtdlpManager
+import app.gyrolet.mpvrx.ui.theme.spacing
+import app.gyrolet.mpvrx.ui.utils.LocalBackStack
+import app.gyrolet.mpvrx.ui.utils.popSafely
+import kotlinx.coroutines.launch
+import kotlinx.serialization.Serializable
+import me.zhanghai.compose.preference.ProvidePreferenceLocals
+import me.zhanghai.compose.preference.SwitchPreference
+import org.koin.compose.koinInject
+import java.io.File
+
+@Serializable
+object YtdlpSettingsScreen : Screen {
+ @OptIn(ExperimentalMaterial3Api::class)
+ @Composable
+ override fun Content() {
+ val context = LocalContext.current
+ val backStack = LocalBackStack.current
+ val scope = rememberCoroutineScope()
+ var logs by remember { mutableStateOf("") }
+ val scrollState = rememberScrollState()
+ var isRunning by remember { mutableStateOf(false) }
+
+ val ytdlPreferences = koinInject()
+ val ytdlQuality by ytdlPreferences.ytdlQuality.collectAsState()
+ val preferH264 by ytdlPreferences.preferH264.collectAsState()
+
+ val ytdlDir = remember { YtdlpManager.getYtdlDir(context) }
+ var hasYtdlp by remember { mutableStateOf(File(ytdlDir, "yt-dlp").exists()) }
+
+ LaunchedEffect(isRunning) {
+ if (!isRunning) {
+ hasYtdlp = File(ytdlDir, "yt-dlp").exists()
+ }
+ }
+
+ LaunchedEffect(logs) {
+ scrollState.animateScrollTo(scrollState.maxValue)
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(
+ text = "yt-dlp Manager",
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.ExtraBold,
+ )
+ },
+ navigationIcon = {
+ IconButton(onClick = { backStack.popSafely() }) {
+ Icon(Icons.Outlined.ArrowBack, contentDescription = null)
+ }
+ }
+ )
+ }
+ ) { padding ->
+ ProvidePreferenceLocals {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding)
+ .padding(MaterialTheme.spacing.medium),
+ verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.medium)
+ ) {
+ Text(
+ text = "Manage yt-dlp for streaming support. This uses a bypass for SDK 29+ restrictions.",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ OutlinedCard(
+ modifier = Modifier.fillMaxWidth(),
+ shape = MaterialTheme.shapes.medium
+ ) {
+ Column(modifier = Modifier.padding(MaterialTheme.spacing.small)) {
+ Text(
+ text = "Streaming Quality",
+ style = MaterialTheme.typography.titleSmall,
+ modifier = Modifier.padding(bottom = MaterialTheme.spacing.extraSmall)
+ )
+
+ var expanded by remember { mutableStateOf(false) }
+ val qualityLevels = remember { arrayOf(-1, 2160, 1440, 1080, 720, 480, 360, 240, 144) }
+
+ ExposedDropdownMenuBox(
+ expanded = expanded,
+ onExpandedChange = { expanded = it }
+ ) {
+ OutlinedTextField(
+ value = if (ytdlQuality == -1) "Any" else "${ytdlQuality}p",
+ onValueChange = {},
+ readOnly = true,
+ trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
+ modifier = Modifier.menuAnchor().fillMaxWidth(),
+ textStyle = MaterialTheme.typography.bodyMedium
+ )
+ ExposedDropdownMenu(
+ expanded = expanded,
+ onDismissRequest = { expanded = false }
+ ) {
+ qualityLevels.forEach { level ->
+ DropdownMenuItem(
+ text = { Text(if (level == -1) "Any" else "${level}p") },
+ onClick = {
+ ytdlPreferences.ytdlQuality.set(level)
+ updateFormatString(ytdlPreferences, level, preferH264)
+ expanded = false
+ }
+ )
+ }
+ }
+ }
+
+ SwitchPreference(
+ value = preferH264,
+ onValueChange = { newValue ->
+ ytdlPreferences.preferH264.set(newValue)
+ updateFormatString(ytdlPreferences, ytdlQuality, newValue)
+ },
+ title = { Text("Prefer H.264 (AVC)") },
+ summary = { Text("Better compatibility, but may limit quality") }
+ )
+ }
+ }
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.small)
+ ) {
+ Button(
+ onClick = {
+ scope.launch {
+ isRunning = true
+ logs = ""
+ YtdlpManager.runInstall(context) { line ->
+ logs += line
+ }
+ isRunning = false
+ }
+ },
+ enabled = !isRunning,
+ modifier = Modifier.weight(1f)
+ ) {
+ Text("Install")
+ }
+
+ Button(
+ onClick = {
+ scope.launch {
+ isRunning = true
+ logs = ""
+ YtdlpManager.runUpdate(context) { line ->
+ logs += line
+ }
+ isRunning = false
+ }
+ },
+ enabled = !isRunning && hasYtdlp,
+ modifier = Modifier.weight(1f)
+ ) {
+ Text("Update")
+ }
+ }
+
+ Surface(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxWidth(),
+ color = MaterialTheme.colorScheme.surfaceVariant,
+ shape = MaterialTheme.shapes.medium
+ ) {
+ Column(
+ modifier = Modifier
+ .padding(MaterialTheme.spacing.small)
+ .verticalScroll(scrollState)
+ ) {
+ Text(
+ text = logs.ifEmpty { "Ready..." },
+ style = MaterialTheme.typography.bodySmall,
+ fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace
+ )
+ }
+ }
+
+ if (isRunning) {
+ LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
+ }
+
+ Text(
+ text = "Bypass active: Using libpython.so from native library directory.",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.secondary
+ )
+ }
+ }
+ }
+ }
+
+ private fun updateFormatString(prefs: YtdlPreferences, quality: Int, preferH264: Boolean) {
+ var qstr = ""
+ /* bv = bestvideo, ba = bestaudio, b = best */
+ if (quality != -1 && preferH264) {
+ qstr = "(bv*[vcodec^=?avc]/bv*[vcodec^=?mp4])[height<=?${quality}]+ba/" +
+ "(b[vcodec^=?avc]/b[vcodec^=?mp4])[height<=?${quality}]"
+ } else if (quality != -1) {
+ qstr = "bv[height<=?${quality}]+ba/b[height<=?${quality}]"
+ } else if (preferH264) {
+ qstr = "(bv*[vcodec^=?avc]/bv*[vcodec^=?mp4])+ba/(b[vcodec^=?avc]/b[vcodec^=?mp4])"
+ }
+ if (qstr.isNotEmpty())
+ qstr += "/bv*+ba/b"
+
+ prefs.ytdlFormat.set(qstr)
+ }
+}
diff --git a/app/src/main/jniLibs/arm64-v8a/libpython.so b/app/src/main/jniLibs/arm64-v8a/libpython.so
new file mode 100644
index 000000000..48cc7e0aa
Binary files /dev/null and b/app/src/main/jniLibs/arm64-v8a/libpython.so differ
diff --git a/app/src/main/jniLibs/arm64-v8a/libpython_bin.so b/app/src/main/jniLibs/arm64-v8a/libpython_bin.so
new file mode 100644
index 000000000..48cc7e0aa
Binary files /dev/null and b/app/src/main/jniLibs/arm64-v8a/libpython_bin.so differ
diff --git a/app/src/main/jniLibs/armeabi-v7a/libpython.so b/app/src/main/jniLibs/armeabi-v7a/libpython.so
new file mode 100644
index 000000000..b694473fa
Binary files /dev/null and b/app/src/main/jniLibs/armeabi-v7a/libpython.so differ
diff --git a/app/src/main/jniLibs/armeabi-v7a/libpython_bin.so b/app/src/main/jniLibs/armeabi-v7a/libpython_bin.so
new file mode 100644
index 000000000..b694473fa
Binary files /dev/null and b/app/src/main/jniLibs/armeabi-v7a/libpython_bin.so differ
diff --git a/app/src/main/jniLibs/x86/libpython.so b/app/src/main/jniLibs/x86/libpython.so
new file mode 100644
index 000000000..7465a136f
Binary files /dev/null and b/app/src/main/jniLibs/x86/libpython.so differ
diff --git a/app/src/main/jniLibs/x86/libpython_bin.so b/app/src/main/jniLibs/x86/libpython_bin.so
new file mode 100644
index 000000000..7465a136f
Binary files /dev/null and b/app/src/main/jniLibs/x86/libpython_bin.so differ
diff --git a/app/src/main/jniLibs/x86_64/libpython.so b/app/src/main/jniLibs/x86_64/libpython.so
new file mode 100644
index 000000000..83f5df1e7
Binary files /dev/null and b/app/src/main/jniLibs/x86_64/libpython.so differ
diff --git a/app/src/main/jniLibs/x86_64/libpython_bin.so b/app/src/main/jniLibs/x86_64/libpython_bin.so
new file mode 100644
index 000000000..83f5df1e7
Binary files /dev/null and b/app/src/main/jniLibs/x86_64/libpython_bin.so differ
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 5bb3e363e..261cd94a6 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -809,4 +809,9 @@
%1$d B
%.1f KB
%.1f MB
+ You need to install yt-dlp to play this link (Settings > Advanced > yt-dlp Manager)
+ Any
+ Prefer H.264 (AVC)
+ Increases compatibility on older devices but may limit quality
+ Preferred Quality