Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# LFS tracking removed to allow pushing to public fork
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |

</details>

Expand Down Expand Up @@ -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.

---

Expand Down
13 changes: 13 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Binary file modified app/libs/mpvlib.aar
Binary file not shown.
117 changes: 117 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
Expand All @@ -26,6 +28,7 @@
<application
android:name=".App"
android:appCategory="video"
android:extractNativeLibs="true"
android:enableOnBackInvokedCallback="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
Expand Down Expand Up @@ -214,6 +217,120 @@
<data android:pathPattern=".*\\..*\\..*\\..*\\..*\\..*\\.m3u8*" />
<data android:pathPattern=".*\\..*\\..*\\..*\\..*\\..*\\..*\\.m3u8*" />
</intent-filter>

<!-- various URL intent filters for popular youtube-dl extractors -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" />
<data android:scheme="https" />
<data android:host="*.youtube.com" />
<data android:pathPattern="/watch.*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" />
<data android:scheme="https" />
<data android:host="youtube.com" />
<data android:pathPattern="/watch.*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" />
<data android:scheme="https" />
<data android:host="youtu.be" />
<data android:pathPattern=".*" />
</intent-filter>

<intent-filter> <!-- FIXME: all following patterns are suboptimal because they also match the homepages -->
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" />
<data android:scheme="https" />
<data android:host="www.vimeo.com" />
<data android:pathPattern=".*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" />
<data android:scheme="https" />
<data android:host="vimeo.com" />
<data android:pathPattern=".*" />
</intent-filter>

<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" />
<data android:scheme="https" />
<data android:host="twitch.tv" />
<data android:pathPattern=".*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" />
<data android:scheme="https" />
<data android:host="m.twitch.tv" />
<data android:pathPattern=".*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" />
<data android:scheme="https" />
<data android:host="clips.twitch.tv" />
<data android:pathPattern=".*" />
</intent-filter>

<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" />
<data android:scheme="https" />
<data android:host="odysee.com" />
<data android:pathPattern="/@.*" />
</intent-filter>

<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" />
<data android:scheme="https" />
<data android:host="bilibili.com" />
<data android:pathPattern=".*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" />
<data android:scheme="https" />
<data android:host="*.bilibili.com" />
<data android:pathPattern=".*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" />
<data android:scheme="https" />
<data android:host="b23.tv" />
<data android:pathPattern=".*" />
</intent-filter>
</activity>
<activity android:name=".presentation.crash.CrashActivity" />

Expand Down
Binary file added app/src/main/assets/ytdl/python313.zip
Binary file not shown.
56 changes: 56 additions & 0 deletions app/src/main/assets/ytdl/setup.py
Original file line number Diff line number Diff line change
@@ -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")
9 changes: 9 additions & 0 deletions app/src/main/assets/ytdl/wrapper
Original file line number Diff line number Diff line change
@@ -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 "$@"
10 changes: 10 additions & 0 deletions app/src/main/cpp/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -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")
83 changes: 83 additions & 0 deletions app/src/main/cpp/ytdl_wrapper.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <dlfcn.h>
#include <errno.h>
#include <wchar.h>

/*
* 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;
}
2 changes: 2 additions & 0 deletions app/src/main/java/app/gyrolet/mpvrx/di/PreferencesModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -33,6 +34,7 @@ val PreferencesModule =
single { BrowserPreferences(get(), androidContext()) }
singleOf(::FoldersPreferences)
singleOf(::AiPreferences)
singleOf(::YtdlPreferences)
singleOf(::SettingsManager)
}

11 changes: 11 additions & 0 deletions app/src/main/java/app/gyrolet/mpvrx/preferences/YtdlPreferences.kt
Original file line number Diff line number Diff line change
@@ -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)
}
1 change: 1 addition & 0 deletions app/src/main/java/app/gyrolet/mpvrx/ui/icons/Icons.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading