Add experimental MPV player backend (#161)

Experimental MPV player backend

Related to #14, #22, & #85

You can switch to MPV in advanced settings and toggle using hardware
decoding or not.

This uses code and buildscripts from
https://github.com/mpv-android/mpv-android, plus other third party
libraries to build MPV
This commit is contained in:
damontecres 2025-11-16 18:46:25 -05:00 committed by GitHub
parent d7b333e519
commit 6be2662d4e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
84 changed files with 3987 additions and 289 deletions

79
.github/actions/native-build/action.yml vendored Normal file
View file

@ -0,0 +1,79 @@
name: "Native build"
description: "Performs native builds"
inputs:
cache:
description: "Whether to use the cache or not"
required: true
runs:
using: "composite"
steps:
- name: Load ffmpeg module cache
id: cache_ffmpeg_module
if: ${{ inputs.cache }}
uses: actions/cache/restore@v4
with:
path: |
app/libs
key: ${{ runner.os }}-ffmpeg-${{ hashFiles('**/libs.versions.toml', '**/scripts/ffmpeg/build_ffmpeg_decoder.sh') }}
- name: Build ffmpeg decoder
id: ffmpeg-decoder
if: steps.cache_ffmpeg_module.outputs.cache-hit != 'true'
shell: bash
run: |
cd scripts/ffmpeg
./build_ffmpeg_decoder.sh "${{ env.ANDROID_SDK_ROOT }}/ndk/${{ env.NDK_VERSION }}"
- name: Save ffmpeg module cache
id: cache_ffmpeg_module_save
uses: actions/cache/save@v4
with:
path: |
app/libs
key: ${{ steps.cache_ffmpeg_module.outputs.cache-primary-key }}
- name: Load libmpv module cache
id: cache_libmpv_module
if: ${{ inputs.cache }}
uses: actions/cache/restore@v4
with:
path: |
app/src/main/libs
key: ${{ runner.os }}-libmpv-${{ hashFiles('**/scripts/mpv/*.sh', '**/scripts/mpv/include/*', '**/scripts/mpv/scripts/*', 'app/src/main/jni/*') }}
- name: Install dependencies
if: steps.cache_libmpv_module.outputs.cache-hit != 'true'
shell: bash
run: |
python -m pip install --upgrade pip
pip install jsonschema jinja2
sudo apt update
sudo apt install -y build-essential autoconf pkg-config libtool ninja-build unzip wget meson
- name: Get libmpv dependencies
if: steps.cache_libmpv_module.outputs.cache-hit != 'true'
shell: bash
run: |
cd scripts/mpv
./get_dependencies.sh
- name: Build libmpv dependencies
if: steps.cache_libmpv_module.outputs.cache-hit != 'true'
shell: bash
run: |
cd scripts/mpv
./buildall.sh --clean --arch arm64 mpv
./buildall.sh mpv
cd ../..
env PREFIX32="$(realpath scripts/mpv/prefix/armv7l)" PREFIX64="$(realpath scripts/mpv/prefix/arm64)" ndk-build -C app/src/main -j
- name: Setup jniLibs
shell: bash
run: |
cd app/src/main
mkdir -p jniLibs
cp -r libs/* jniLibs
#ln -s libs jniLibs
- name: Save libmpv module cache
id: cache_libmpv_module_save
uses: actions/cache/save@v4
with:
path: |
app/src/main/libs
key: ${{ steps.cache_libmpv_module.outputs.cache-primary-key }}

31
.github/actions/setup/action.yml vendored Normal file
View file

@ -0,0 +1,31 @@
name: Setup
description: "Setup the environment"
runs:
using: "composite"
steps:
# Setup the SDKs
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Setup JDK
uses: actions/setup-java@v5
with:
distribution: zulu
java-version: '21'
cache: 'gradle'
- name: Set versions
shell: bash
run: |
echo "BUILD_TOOLS_VERSION=36.0.0" >> $GITHUB_ENV
echo "NDK_VERSION=29.0.14206865" >> $GITHUB_ENV
- name: Setup Android SDK
uses: android-actions/setup-android@v3
with:
packages: "tools platform-tools build-tools;${{ env.BUILD_TOOLS_VERSION }} ndk;${{ env.NDK_VERSION }}"
- name: Add NDK to path
shell: bash
run: |
echo "Adding to path: ${{ env.ANDROID_SDK_ROOT }}/ndk/${{ env.NDK_VERSION }}:${{ env.ANDROID_SDK_ROOT }}/ndk/${{ env.NDK_VERSION }}/toolchains/llvm/prebuilt/linux-x86_64/bin"
echo "${{ env.ANDROID_SDK_ROOT }}/ndk/${{ env.NDK_VERSION }}:${{ env.ANDROID_SDK_ROOT }}/ndk/${{ env.NDK_VERSION }}/toolchains/llvm/prebuilt/linux-x86_64/bin" >> $GITHUB_PATH

View file

@ -15,11 +15,7 @@ concurrency:
cancel-in-progress: true cancel-in-progress: true
env: env:
APK_SHORT_NAME: Wholphin-release.apk
APK_DEBUG_SHORT_NAME: Wholphin-debug.apk
BRANCH_NAME: ${{ github.ref_name }} BRANCH_NAME: ${{ github.ref_name }}
BUILD_TOOLS_VERSION: 36.0.0
NDK_VERSION: "29.0.14206865"
jobs: jobs:
build: build:
@ -31,17 +27,12 @@ jobs:
uses: actions/checkout@v5 uses: actions/checkout@v5
with: with:
fetch-depth: 0 # Need the tags to build fetch-depth: 0 # Need the tags to build
# Setup the SDKs - name: Setup
- name: Setup JDK uses: ./.github/actions/setup
uses: actions/setup-java@v5 - name: Native build
uses: ./.github/actions/native-build
with: with:
distribution: zulu cache: true
java-version: '21'
cache: 'gradle'
- name: Setup Android SDK
uses: android-actions/setup-android@v3
with:
packages: "tools platform-tools build-tools;${{ env.BUILD_TOOLS_VERSION }} ndk;${{ env.NDK_VERSION }}"
- name: Get version names - name: Get version names
run: | run: |
@ -56,25 +47,6 @@ jobs:
echo "LATEST_TAG=$LATEST_TAG" >> "$GITHUB_ENV" echo "LATEST_TAG=$LATEST_TAG" >> "$GITHUB_ENV"
echo "VERSION_NAME=$VERSION_NAME" >> "$GITHUB_ENV" echo "VERSION_NAME=$VERSION_NAME" >> "$GITHUB_ENV"
echo "TAG_NAME=$TAG_NAME" >> "$GITHUB_ENV" echo "TAG_NAME=$TAG_NAME" >> "$GITHUB_ENV"
- name: Load ffmpeg module cache
id: cache_ffmpeg_module
uses: actions/cache/restore@v4
with:
path: |
app/libs
key: ${{ runner.os }}-ffmpeg-${{ hashFiles('**/libs.versions.toml', '**/build_ffmpeg_decoder.sh') }}
- name: Build ffmpeg decoder
id: ffmpeg-decoder
if: steps.cache_ffmpeg_module.outputs.cache-hit != 'true'
run: |
./build_ffmpeg_decoder.sh "${{ env.ANDROID_SDK_ROOT }}/ndk/${{ env.NDK_VERSION }}"
- name: Save ffmpeg module cache
id: cache_ffmpeg_module_save
uses: actions/cache/save@v4
with:
path: |
app/libs
key: ${{ steps.cache_ffmpeg_module.outputs.cache-primary-key }}
- name: Build release app - name: Build release app
id: build id: build
env: env:
@ -84,23 +56,27 @@ jobs:
SIGNING_KEY: "${{ secrets.SIGNING_KEY }}" SIGNING_KEY: "${{ secrets.SIGNING_KEY }}"
run: | run: |
./gradlew clean assembleRelease assembleDebug ./gradlew clean assembleRelease assembleDebug
echo "apk=$(ls app/build/outputs/apk/release/Wholphin-release*.apk)" >> "$GITHUB_OUTPUT" - name: Verify signatures
echo "apk_debug=$(ls app/build/outputs/apk/debug/Wholphin-debug*.apk)" >> "$GITHUB_OUTPUT"
- name: Verify signature
run: | run: |
echo "Verify release APK" echo "Verify APK signatures"
${{env.ANDROID_SDK_ROOT}}/build-tools/${{ env.BUILD_TOOLS_VERSION }}/apksigner verify --verbose --print-certs "${{ steps.build.outputs.apk }}" find app/build/outputs/apk -name '*.apk'
echo "Verify debug APK" find app/build/outputs/apk -name '*.apk' -print0 | xargs -t -0 -n1 ${{env.ANDROID_SDK_ROOT}}/build-tools/${{ env.BUILD_TOOLS_VERSION }}/apksigner verify --verbose --print-certs
${{env.ANDROID_SDK_ROOT}}/build-tools/${{ env.BUILD_TOOLS_VERSION }}/apksigner verify --verbose --print-certs "${{ steps.build.outputs.apk_debug }}" - name: Copy APK to shorter names
- name: Copy APK to ${{ env.APK_SHORT_NAME }} id: apks
run: | run: |
cp "${{ steps.build.outputs.apk }}" "${{ env.APK_SHORT_NAME }}" find app/build/outputs/apk -name '*.apk' -print0 |
cp "${{ steps.build.outputs.apk_debug }}" "${{ env.APK_DEBUG_SHORT_NAME }}" while IFS= read -r -d '' line; do
# Wholphin-release-0.2.9-62-g5c12e03-16-arm64-v8a.apk => Wholphin-arm64-v8a.apk
short_name="$(echo $line | sed -E 's/-[0-9]+\.[0-9]+\.[0-9]+-[0-9]+-g[a-fA-F0-9]+-[0-9]+//')"
echo "$line => $short_name"
cp "$line" "$short_name"
done
apks=$(find app/build/outputs/apk -name '*.apk' -print0 | tr '\0' ',' | sed 's/,$//')
echo "apks=$apks" >> "$GITHUB_OUTPUT"
- name: Checksums - name: Checksums
run: | run: |
echo "SHA256 checksums:" echo "SHA256 checksums:"
sha256sum "${{ steps.build.outputs.apk }}" "${{ env.APK_SHORT_NAME }}" find app/build/outputs/apk -name '*.apk' -print0 | xargs -0 sha256sum
sha256sum "${{ steps.build.outputs.apk_debug }}" "${{ env.APK_DEBUG_SHORT_NAME }}"
- name: Delete ${{ env.TAG_NAME }} tag and release - name: Delete ${{ env.TAG_NAME }} tag and release
uses: dev-drprasad/delete-tag-and-release@v1.1 uses: dev-drprasad/delete-tag-and-release@v1.1
with: with:
@ -125,7 +101,7 @@ jobs:
with: with:
allowUpdates: true allowUpdates: true
artifactErrorsFailBuild: true artifactErrorsFailBuild: true
artifacts: "${{ steps.build.outputs.apk_debug }},${{ steps.build.outputs.apk }},${{ env.APK_DEBUG_SHORT_NAME }},${{ env.APK_SHORT_NAME }}" artifacts: "${{ steps.apks.outputs.apks }}"
draft: false draft: false
generateReleaseNotes: false generateReleaseNotes: false
makeLatest: false makeLatest: false

View file

@ -8,10 +8,7 @@ defaults:
shell: bash shell: bash
env: env:
BUILD_TOOLS_VERSION: 36.0.0
BUILD_DIRS_ARTIFACT: build-dirs BUILD_DIRS_ARTIFACT: build-dirs
NDK_VERSION: "29.0.14206865"
jobs: jobs:
pre-commit: pre-commit:
@ -36,45 +33,21 @@ jobs:
uses: actions/checkout@v5 uses: actions/checkout@v5
with: with:
fetch-depth: 0 # Need the tags to build fetch-depth: 0 # Need the tags to build
- name: Setup JDK - name: Setup
uses: actions/setup-java@v5 uses: ./.github/actions/setup
- name: Native build
uses: ./.github/actions/native-build
with: with:
distribution: zulu cache: true
java-version: '21'
cache: 'gradle'
- name: Setup Android SDK
uses: android-actions/setup-android@v3
with:
packages: "tools platform-tools build-tools;${{ env.BUILD_TOOLS_VERSION }} ndk;${{ env.NDK_VERSION }}"
- name: Load ffmpeg module cache
id: cache_ffmpeg_module
uses: actions/cache/restore@v4
with:
path: |
app/libs
key: ${{ runner.os }}-ffmpeg-${{ hashFiles('**/libs.versions.toml', '**/build_ffmpeg_decoder.sh') }}
- name: Build ffmpeg decoder
id: ffmpeg-decoder
if: steps.cache_ffmpeg_module.outputs.cache-hit != 'true'
run: |
./build_ffmpeg_decoder.sh "${{ env.ANDROID_SDK_ROOT }}/ndk/${{ env.NDK_VERSION }}"
- name: Save ffmpeg module cache
id: cache_ffmpeg_module_save
uses: actions/cache/save@v4
with:
path: |
app/libs
key: ${{ steps.cache_ffmpeg_module.outputs.cache-primary-key }}
- name: Build app - name: Build app
id: buildapp id: buildapp
run: | run: |
./gradlew clean build ./gradlew clean assembleDebug
echo "apk=$(ls app/build/outputs/apk/debug/Wholphin-debug*.apk)" >> "$GITHUB_OUTPUT" apks=$(find app/build/outputs/apk -name '*.apk' -print0 | tr '\0' ',' | sed 's/,$//')
echo "apks=$apks" >> "$GITHUB_OUTPUT"
- name: Tar build dirs - name: Tar build dirs
run: | run: |
tar -czf build.tgz */build/. tar -czf build.tgz ./app/.
- uses: actions/upload-artifact@v5 - uses: actions/upload-artifact@v5
id: upload-build-dirs id: upload-build-dirs
with: with:
@ -83,6 +56,6 @@ jobs:
if-no-files-found: error if-no-files-found: error
- uses: actions/upload-artifact@v5 - uses: actions/upload-artifact@v5
with: with:
name: Wholphin-debug.apk name: APKs
path: "${{ steps.buildapp.outputs.apk }}" path: "${{ steps.buildapp.outputs.apks }}"
compression-level: 0 compression-level: 0

View file

@ -11,8 +11,6 @@ defaults:
env: env:
APK_SHORT_NAME: Wholphin.apk APK_SHORT_NAME: Wholphin.apk
BUILD_TOOLS_VERSION: 36.0.0
NDK_VERSION: "29.0.14206865"
jobs: jobs:
publish: publish:
@ -24,24 +22,12 @@ jobs:
uses: actions/checkout@v5 uses: actions/checkout@v5
with: with:
fetch-depth: 0 # Need the tags to build fetch-depth: 0 # Need the tags to build
- name: Setup Python - name: Setup
uses: actions/setup-python@v5 uses: ./.github/actions/setup
- name: Native build
uses: ./.github/actions/native-build
with: with:
python-version: '3.12' cache: false
- name: Setup JDK
uses: actions/setup-java@v5
with:
distribution: zulu
java-version: '21'
cache: 'gradle'
- name: Setup Android SDK
uses: android-actions/setup-android@v3
with:
packages: "tools platform-tools build-tools;${{ env.BUILD_TOOLS_VERSION }} ndk;${{ env.NDK_VERSION }}"
- name: Build ffmpeg decoder
id: ffmpeg-decoder
run: |
./build_ffmpeg_decoder.sh "${{ env.ANDROID_SDK_ROOT }}/ndk/${{ env.NDK_VERSION }}"
- name: Build app - name: Build app
id: buildapp id: buildapp
env: env:
@ -51,21 +37,31 @@ jobs:
SIGNING_KEY: "${{ secrets.SIGNING_KEY }}" SIGNING_KEY: "${{ secrets.SIGNING_KEY }}"
run: | run: |
./gradlew clean assembleRelease ./gradlew clean assembleRelease
echo "apk=$(ls app/build/outputs/apk/release/Wholphin-release*.apk)" >> "$GITHUB_OUTPUT" - name: Verify signatures
- name: Verify signature
run: | run: |
${{env.ANDROID_SDK_ROOT}}/build-tools/${{ env.BUILD_TOOLS_VERSION }}/apksigner verify --verbose --print-certs "${{ steps.buildapp.outputs.apk }}" echo "Verify APK signatures"
- name: Copy APK to ${{ env.APK_SHORT_NAME }} find app/build/outputs/apk -name '*.apk'
find app/build/outputs/apk -name '*.apk' -print0 | xargs -0 -n1 ${{env.ANDROID_SDK_ROOT}}/build-tools/${{ env.BUILD_TOOLS_VERSION }}/apksigner verify --verbose --print-certs
- name: Copy APK to shorter names
id: apks
run: | run: |
cp "${{ steps.buildapp.outputs.apk }}" "${{ env.APK_SHORT_NAME }}" find app/build/outputs/apk -name '*.apk' -print0 |
while IFS= read -r -d '' line; do
# Wholphin-release-0.2.9-62-g5c12e03-16-arm64-v8a.apk => Wholphin-arm64-v8a.apk
short_name="$(echo $line | sed -E 's/-release-[0-9]+\.[0-9]+\.[0-9]+-[0-9]+-g[a-fA-F0-9]+-[0-9]+//')"
echo "$line => $short_name"
#cp "$line" "$short_name"
done
apks=$(find app/build/outputs/apk -name '*.apk' -print0 | tr '\0' ',' | sed 's/,$//')
echo "apks=$apks" >> "$GITHUB_OUTPUT"
- name: Checksums - name: Checksums
run: | run: |
echo "SHA256 checksums:" echo "SHA256 checksums:"
sha256sum "${{ steps.buildapp.outputs.apk }}" "${{ env.APK_SHORT_NAME }}" find app/build/outputs/apk -name '*.apk' -print0 | xargs -0 sha256sum
- name: Create GitHub release - name: Create GitHub release
uses: ncipollo/release-action@v1 uses: ncipollo/release-action@v1
with: with:
artifacts: "${{ steps.buildapp.outputs.apk }},${{ env.APK_SHORT_NAME }}" artifacts: "${{ steps.apks.outputs.apks }}"
makeLatest: true makeLatest: true
prerelease: false prerelease: false
generateReleaseNotes: false generateReleaseNotes: false

9
.gitignore vendored
View file

@ -53,7 +53,12 @@ app/libs/
ffmpeg_decoder/ ffmpeg_decoder/
app/release/ app/release/
app/debug/ app/debug/
<<<<<<< HEAD
app/src/main/jnilibs/ # mpv
app/src/main/libs/
app/src/main/obj/ app/src/main/obj/
app/src/main/libs/
app/src/main/jnilibs
app/src/main/jnilibs/
scripts/mpv/deps/
scripts/mpv/prefix/

View file

@ -34,10 +34,18 @@ Code is split into several packages:
- `ui` - User interface code and ViewModels - `ui` - User interface code and ViewModels
- `util` - Utility classes and functions - `util` - Utility classes and functions
### FFmpeg decoder module ### Native components
The app ships with [media3 ffmpeg decoder module](https://github.com/androidx/media/blob/release/libraries/decoder_ffmpeg/README.md). #### FFmpeg decoder module
Wholphin ships with [media3 ffmpeg decoder module](https://github.com/androidx/media/blob/release/libraries/decoder_ffmpeg/README.md).
It is not required to build the extension in order to build the app locally. It is not required to build the extension in order to build the app locally.
You can build the module on MacOS or Linux with the [`./build_ffmpeg_decoder.sh`](./build_ffmpeg_decoder.sh) script. You must have the [Android NDK](https://developer.android.com/ndk) installed. You can build the module on MacOS or Linux with the [`build_ffmpeg_decoder.sh`](./scripts/ffmpeg/build_ffmpeg_decoder.sh) script.
#### MPV player backend
Wholphin has a playback engine that uses [`libmpv`](https://github.com/mpv-player/mpv). The app uses JNI code from [`mpv-android`](https://github.com/mpv-android/mpv-android) and has an implementation of `androidx.media3.common.Player` to swap out for `ExoPlayer`.
See the [build scripts](scripts/mpv/) for details on building this component.

View file

@ -20,6 +20,9 @@ This is not a fork of the [official client](https://github.com/jellyfin/jellyfin
- Other (subjective) enhancements: - Other (subjective) enhancements:
- Subtly show playback position along the bottom of the screen while seeking w/ D-Pad - Subtly show playback position along the bottom of the screen while seeking w/ D-Pad
- Force Continue Watching & Next Up TV episodes to use their Series posters - Force Continue Watching & Next Up TV episodes to use their Series posters
- Different media playback engines, including:
- Default ExoPlayer/Media3
- Experimental MPV
### Roadmap ### Roadmap

View file

@ -139,12 +139,22 @@ android {
variant.outputs variant.outputs
.map { it as com.android.build.gradle.internal.api.BaseVariantOutputImpl } .map { it as com.android.build.gradle.internal.api.BaseVariantOutputImpl }
.forEach { output -> .forEach { output ->
val abi = output.getFilter("ABI").let { if (it != null) "-$it" else "" }
val outputFileName = val outputFileName =
"Wholphin-${variant.baseName}-${variant.versionName}-${variant.versionCode}.apk" "Wholphin-${variant.baseName}-${variant.versionName}-${variant.versionCode}$abi.apk"
output.outputFileName = outputFileName output.outputFileName = outputFileName
} }
} }
} }
splits {
abi {
isEnable = true
reset()
include("armeabi-v7a", "arm64-v8a")
isUniversalApk = true
}
}
} }
protobuf { protobuf {
@ -175,6 +185,7 @@ aboutLibraries {
duplicationRule = DuplicateRule.SIMPLE duplicationRule = DuplicateRule.SIMPLE
} }
} }
dependencies { dependencies {
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat) implementation(libs.androidx.appcompat)

View file

@ -0,0 +1,11 @@
{
"uniqueId": "com.github.videolan.dav1d",
"developers": [],
"artifactVersion": "master",
"description": "dav1d is an AV1 cross-platform decoder, open-source, and focused on speed and correctness",
"name": "dav1d",
"licenses": [
"dav1d"
],
"website": "https://github.com/videolan/dav1d"
}

View file

@ -0,0 +1,11 @@
{
"uniqueId": "com.github.fribidi.fribidi",
"developers": [],
"artifactVersion": "1.0.16",
"description": "The Free Implementation of the Unicode Bidirectional Algorithm",
"name": "fribidi",
"licenses": [
"lgpl-2.1"
],
"website": "https://github.com/fribidi/fribidi"
}

View file

@ -0,0 +1,11 @@
{
"uniqueId": "com.github.libass.libass",
"developers": [],
"artifactVersion": "master",
"description": "libass is a portable subtitle renderer for the ASS/SSA (Advanced Substation Alpha/Substation Alpha) subtitle format",
"name": "libass",
"licenses": [
"libass"
],
"website": "https://github.com/libass/libass"
}

View file

@ -0,0 +1,11 @@
{
"uniqueId": "com.github.haasn.libplacebo",
"developers": [],
"artifactVersion": "master",
"description": "libplacebo is, in a nutshell, the core rendering algorithms and ideas of mpv rewritten as an independent library",
"name": "libplacebo",
"licenses": [
"lgpl-2.1"
],
"website": "https://github.com/haasn/libplacebo"
}

View file

@ -0,0 +1,11 @@
{
"uniqueId": "com.github.adah1972.libunibreak",
"developers": [],
"artifactVersion": "6.1",
"description": "an implementation of the line breaking and word/grapheme breaking algorithms as described in Unicode Standard Annex 14 (UAX #14) and Unicode Standard Annex 29 (UAX #29)",
"name": "libunibreak",
"licenses": [
"libunibreak"
],
"website": "https://github.com/adah1972/libunibreak"
}

View file

@ -0,0 +1,11 @@
{
"uniqueId": "com.github.Mbed-TLS.mbedtls",
"developers": [],
"artifactVersion": "v3.6.4",
"description": "Mbed TLS is a C library that implements X.509 certificate manipulation and the TLS and DTLS protocols",
"name": "mbedtls",
"licenses": [
"gpl-2.0"
],
"website": "https://github.com/Mbed-TLS/mbedtls"
}

View file

@ -0,0 +1,11 @@
{
"uniqueId": "com.github.mpv-player.mpv",
"developers": [],
"artifactVersion": "master",
"description": "mpv is a free (as in freedom) media player for the command line",
"name": "mpv",
"licenses": [
"gpl-2.0"
],
"website": "https://github.com/mpv-player/mpv"
}

View file

@ -0,0 +1,11 @@
{
"uniqueId": "com.github.mpv-android.mpv-android",
"developers": [],
"artifactVersion": "master",
"description": "mpv-android is a video player for Android based on libmpv",
"name": "mpv for Android",
"licenses": [
"mpv-android"
],
"website": "https://github.com/mpv-android/mpv-android"
}

View file

@ -0,0 +1,11 @@
{
"uniqueId": "org.freetype",
"developers": [],
"artifactVersion": "2.14.1",
"description": "FreeType is a freely available software library to render fonts",
"name": "freetype",
"licenses": [
"gpl-2.0"
],
"website": "https://freetype.org"
}

View file

@ -0,0 +1,28 @@
{
"content": "Copyright © 2018-2025, VideoLAN and dav1d authors
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.",
"hash": "dav1d",
"url": "https://github.com/videolan/dav1d?tab=BSD-2-Clause-1-ov-file",
"name": "BSD-2-Clause"
}

View file

@ -0,0 +1,20 @@
{
"content": "ISC License
Copyright (C) 2006-2016 libass contributors
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED \"AS IS\" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.",
"hash": "libass",
"url": "https://github.com/libass/libass?tab=ISC-1-ov-file",
"name": "ISC"
}

View file

@ -0,0 +1,26 @@
{
"content": "Copyright (C) Wu Yongwei <wuyongwei at gmail dot com>
Copyright (C) Tom Hacohen <tom at stosb dot com>
Copyright (C) Petr Filipsky <philodej at gmail dot com>
Copyright (C) Andreas Röver <roever at users dot sf dot net>
This software is provided 'as-is', without any express or implied
warranty. In no event will the author be held liable for any damages
arising from the use of this software.
Permission is granted to anyone to use this software for any purpose,
including commercial applications, and to alter it and redistribute it
freely, subject to the following restrictions:
1. The origin of this software must not be misrepresented; you must not
claim that you wrote the original software. If you use this software
in a product, an acknowledgement in the product documentation would
be appreciated but is not required.
2. Altered source versions must be plainly marked as such, and must not
be misrepresented as being the original software.
3. This notice may not be removed or altered from any source
distribution.",
"hash": "libunibreak",
"url": "https://github.com/adah1972/libunibreak?tab=Zlib-1-ov-file",
"name": "Zlib"
}

View file

@ -0,0 +1,13 @@
{
"content": "Copyright (c) 2016 Ilya Zhuravlev
Copyright (c) 2016 sfan5 <sfan5@live.de>
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the \"Software\"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.",
"hash": "mpv-android",
"url": "https://github.com/mpv-android/mpv-android?tab=MIT-1-ov-file",
"name": "MIT"
}

View file

@ -0,0 +1,93 @@
Copyright 2011 The Roboto Project Authors (https://github.com/googlefonts/roboto-classic)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View file

@ -0,0 +1,5 @@
# Roboto font
Sourced from https://fonts.google.com/specimen/Roboto
Licensed under SIL OPEN FONT LICENSE Version 1.1, see [OFL.txt](./OFL.txt).

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -77,9 +77,12 @@ class MainActivity : AppCompatActivity() {
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
Timber.i("MainActivity.onCreate")
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
Timber.i("MainActivity.onCreate")
lifecycle.addObserver(playbackLifecycleObserver) lifecycle.addObserver(playbackLifecycleObserver)
if (savedInstanceState == null) {
appUpgradeHandler.copySubfont(false)
}
setContent { setContent {
CoilConfig(okHttpClient, false) CoilConfig(okHttpClient, false)
val appPreferences by userPreferencesDataStore.data.collectAsState(null) val appPreferences by userPreferencesDataStore.data.collectAsState(null)

View file

@ -8,6 +8,7 @@ import androidx.preference.PreferenceManager
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.WholphinApplication import com.github.damontecres.wholphin.WholphinApplication
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.preferences.ConditionalPreferences
import com.github.damontecres.wholphin.ui.preferences.PreferenceGroup import com.github.damontecres.wholphin.ui.preferences.PreferenceGroup
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
import com.github.damontecres.wholphin.ui.preferences.PreferenceValidation import com.github.damontecres.wholphin.ui.preferences.PreferenceValidation
@ -672,6 +673,30 @@ sealed interface AppPreference<T> {
title = R.string.subtitle_style, title = R.string.subtitle_style,
destination = Destination.Settings(PreferenceScreenOption.SUBTITLES), destination = Destination.Settings(PreferenceScreenOption.SUBTITLES),
) )
val PlayerBackendPref =
AppChoicePreference<PlayerBackend>(
title = R.string.player_backend,
defaultValue = PlayerBackend.EXO_PLAYER,
getter = { it.playbackPreferences.playerBackend },
setter = { prefs, value ->
prefs.updatePlaybackPreferences { playerBackend = value }
},
displayValues = R.array.player_backend_options,
indexToValue = { PlayerBackend.forNumber(it) },
valueToIndex = { it.number },
)
val MpvHardwareDecoding =
AppSwitchPreference(
title = R.string.mpv_hardware_decoding,
defaultValue = true,
getter = { it.playbackPreferences.mpvOptions.enableHardwareDecoding },
setter = { prefs, value ->
prefs.updateMpvOptions { enableHardwareDecoding = value }
},
summary = R.string.disable_if_crash,
)
} }
} }
@ -752,6 +777,7 @@ val advancedPreferences =
preferences = preferences =
listOf( listOf(
AppPreference.OneClickPause, AppPreference.OneClickPause,
AppPreference.GlobalContentScale,
AppPreference.SkipIntros, AppPreference.SkipIntros,
AppPreference.SkipOutros, AppPreference.SkipOutros,
AppPreference.SkipCommercials, AppPreference.SkipCommercials,
@ -762,15 +788,24 @@ val advancedPreferences =
), ),
), ),
PreferenceGroup( PreferenceGroup(
title = R.string.playback_overrides, title = R.string.player_backend,
preferences = preferences = listOf(AppPreference.PlayerBackendPref),
conditionalPreferences =
listOf( listOf(
AppPreference.GlobalContentScale, ConditionalPreferences(
{ it.playbackPreferences.playerBackend == PlayerBackend.EXO_PLAYER },
listOf(
AppPreference.FfmpegPreference,
AppPreference.DownMixStereo, AppPreference.DownMixStereo,
AppPreference.Ac3Supported, AppPreference.Ac3Supported,
AppPreference.DirectPlayAss, AppPreference.DirectPlayAss,
AppPreference.DirectPlayPgs, AppPreference.DirectPlayPgs,
AppPreference.FfmpegPreference, ),
),
ConditionalPreferences(
{ it.playbackPreferences.playerBackend == PlayerBackend.MPV },
listOf(AppPreference.MpvHardwareDecoding),
),
), ),
), ),
PreferenceGroup( PreferenceGroup(

View file

@ -46,6 +46,7 @@ class AppPreferencesSerializer
passOutProtectionMs = passOutProtectionMs =
AppPreference.PassOutProtection.defaultValue.hours.inWholeMilliseconds AppPreference.PassOutProtection.defaultValue.hours.inWholeMilliseconds
showNextUpWhen = AppPreference.ShowNextUpTiming.defaultValue showNextUpWhen = AppPreference.ShowNextUpTiming.defaultValue
playerBackend = AppPreference.PlayerBackendPref.defaultValue
overrides = overrides =
PlaybackOverrides PlaybackOverrides
@ -58,6 +59,14 @@ class AppPreferencesSerializer
mediaExtensionsEnabled = mediaExtensionsEnabled =
AppPreference.FfmpegPreference.defaultValue AppPreference.FfmpegPreference.defaultValue
}.build() }.build()
mpvOptions =
MpvOptions
.newBuilder()
.apply {
enableHardwareDecoding =
AppPreference.MpvHardwareDecoding.defaultValue
}.build()
}.build() }.build()
homePagePreferences = homePagePreferences =
HomePagePreferences HomePagePreferences
@ -112,6 +121,11 @@ inline fun AppPreferences.updatePlaybackOverrides(block: PlaybackOverrides.Build
overrides = overrides.toBuilder().apply(block).build() overrides = overrides.toBuilder().apply(block).build()
} }
inline fun AppPreferences.updateMpvOptions(block: MpvOptions.Builder.() -> Unit): AppPreferences =
updatePlaybackPreferences {
mpvOptions = mpvOptions.toBuilder().apply(block).build()
}
inline fun AppPreferences.updateHomePagePreferences(block: HomePagePreferences.Builder.() -> Unit): AppPreferences = inline fun AppPreferences.updateHomePagePreferences(block: HomePagePreferences.Builder.() -> Unit): AppPreferences =
update { update {
homePagePreferences = homePagePreferences.toBuilder().apply(block).build() homePagePreferences = homePagePreferences.toBuilder().apply(block).build()

View file

@ -14,6 +14,7 @@ import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
import com.github.damontecres.wholphin.util.Version import com.github.damontecres.wholphin.util.Version
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import timber.log.Timber import timber.log.Timber
import java.io.File
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@ -48,6 +49,7 @@ class AppUpgradeHandler
putLong(VERSION_CODE_CURRENT_KEY, newVersionCode) putLong(VERSION_CODE_CURRENT_KEY, newVersionCode)
} }
try { try {
copySubfont(true)
upgradeApp( upgradeApp(
context, context,
Version.Companion.fromString(previousVersion ?: "0.0.0"), Version.Companion.fromString(previousVersion ?: "0.0.0"),
@ -60,6 +62,27 @@ class AppUpgradeHandler
} }
} }
fun copySubfont(overwrite: Boolean) {
try {
val fontFileName = "subfont.ttf"
val outputFile = File(context.filesDir, fontFileName)
if (!outputFile.exists() || overwrite) {
context.assets.open(fontFileName).use { input ->
outputFile.outputStream().use { output ->
input.copyTo(output)
}
}
Timber.i("Wrote font %s to local", fontFileName)
}
// val oldFontDir = File(context.filesDir, "fonts")
// if (oldFontDir.exists()) {
// oldFontDir.deleteRecursively()
// }
} catch (ex: Exception) {
Timber.e(ex, "Exception copying subfont.tff")
}
}
companion object { companion object {
const val VERSION_NAME_PREVIOUS_KEY = "version.previous.name" const val VERSION_NAME_PREVIOUS_KEY = "version.previous.name"
const val VERSION_CODE_PREVIOUS_KEY = "version.previous.code" const val VERSION_CODE_PREVIOUS_KEY = "version.previous.code"

View file

@ -9,8 +9,11 @@ import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.MediaExtensionStatus import com.github.damontecres.wholphin.preferences.MediaExtensionStatus
import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.util.mpv.MpvPlayer
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
@ -38,6 +41,23 @@ class PlayerFactory
currentPlayer?.release() currentPlayer?.release()
} }
val prefs = runBlocking { appPreferences.data.firstOrNull()?.playbackPreferences }
val backend = prefs?.playerBackend ?: AppPreference.PlayerBackendPref.defaultValue
val newPlayer =
when (backend) {
PlayerBackend.MPV -> {
val enableHardwareDecoding =
prefs?.mpvOptions?.enableHardwareDecoding
?: AppPreference.MpvHardwareDecoding.defaultValue
MpvPlayer(context, enableHardwareDecoding)
.apply {
playWhenReady = true
}
}
PlayerBackend.EXO_PLAYER,
PlayerBackend.UNRECOGNIZED,
-> {
val extensions = val extensions =
runBlocking { appPreferences.data.firstOrNull() }?.playbackPreferences?.overrides?.mediaExtensionsEnabled runBlocking { appPreferences.data.firstOrNull() }?.playbackPreferences?.overrides?.mediaExtensionsEnabled
Timber.v("extensions=$extensions") Timber.v("extensions=$extensions")
@ -48,7 +68,6 @@ class PlayerFactory
MediaExtensionStatus.MES_DISABLED -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF MediaExtensionStatus.MES_DISABLED -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF
else -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON else -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
} }
val newPlayer =
ExoPlayer ExoPlayer
.Builder(context) .Builder(context)
.setRenderersFactory( .setRenderersFactory(
@ -59,6 +78,8 @@ class PlayerFactory
.apply { .apply {
playWhenReady = true playWhenReady = true
} }
}
}
currentPlayer = newPlayer currentPlayer = newPlayer
return newPlayer return newPlayer
} }
@ -68,6 +89,7 @@ val Player.isReleased: Boolean
get() { get() {
return when (this) { return when (this) {
is ExoPlayer -> isReleased is ExoPlayer -> isReleased
is MpvPlayer -> isReleased
else -> throw IllegalStateException("Unknown Player type: ${this::class.qualifiedName}") else -> throw IllegalStateException("Unknown Player type: ${this::class.qualifiedName}")
} }
} }

View file

@ -24,6 +24,8 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonObject
@ -51,9 +53,8 @@ class UpdateChecker
) { ) {
companion object { companion object {
// TODO apk names // TODO apk names
private const val ASSET_NAME = "Wholphin.apk" private const val ASSET_NAME = "Wholphin"
private const val DEBUG_ASSET_NAME = "Wholphin-debug.apk" private const val APK_NAME = "$ASSET_NAME.apk"
private const val RELEASE_ASSET_NAME = "Wholphin-release.apk"
private const val APK_MIME_TYPE = "application/vnd.android.package-archive" private const val APK_MIME_TYPE = "application/vnd.android.package-archive"
@ -116,15 +117,10 @@ class UpdateChecker
suspend fun getLatestRelease(updateUrl: String): Release? { suspend fun getLatestRelease(updateUrl: String): Release? {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
val preferredAsset = val preferRelease =
if (PreferenceManager PreferenceManager
.getDefaultSharedPreferences(context) .getDefaultSharedPreferences(context)
.getBoolean("updatePreferRelease", true) .getBoolean("updatePreferRelease", true)
) {
RELEASE_ASSET_NAME
} else {
DEBUG_ASSET_NAME
}
val request = val request =
Request Request
@ -136,21 +132,15 @@ class UpdateChecker
if (it.isSuccessful && it.body != null) { if (it.isSuccessful && it.body != null) {
val result = Json.parseToJsonElement(it.body!!.string()) val result = Json.parseToJsonElement(it.body!!.string())
val name = result.jsonObject["name"]?.jsonPrimitive?.contentOrNull val name = result.jsonObject["name"]?.jsonPrimitive?.contentOrNull
val version = Version.Companion.tryFromString(name) val version = Version.tryFromString(name)
val publishedAt = val publishedAt =
result.jsonObject["published_at"]?.jsonPrimitive?.contentOrNull result.jsonObject["published_at"]?.jsonPrimitive?.contentOrNull
val body = result.jsonObject["body"]?.jsonPrimitive?.contentOrNull val body = result.jsonObject["body"]?.jsonPrimitive?.contentOrNull
val downloadUrl = val downloadUrl =
result.jsonObject["assets"] result.jsonObject["assets"]
?.jsonArray ?.jsonArray
?.firstOrNull { asset -> ?.let { assets -> getDownloadUrl(assets, preferRelease) }
val assetName = Timber.v("version=$version, downloadUrl=$downloadUrl")
asset.jsonObject["name"]?.jsonPrimitive?.contentOrNull
assetName == ASSET_NAME || assetName == preferredAsset
}?.jsonObject
?.get("browser_download_url")
?.jsonPrimitive
?.contentOrNull
if (version != null) { if (version != null) {
val notes = val notes =
if (body.isNotNullOrBlank()) { if (body.isNotNullOrBlank()) {
@ -174,6 +164,35 @@ class UpdateChecker
} }
} }
private fun getDownloadUrl(
assets: JsonArray,
preferRelease: Boolean,
): String? {
val abiSuffix = Build.SUPPORTED_ABIS.firstOrNull().let { if (it != null) "-$it" else "" }
val releaseSuffix = if (preferRelease) "-release" else "-debug"
val preferredNames =
listOf(
"$ASSET_NAME${releaseSuffix}$abiSuffix.apk",
"$ASSET_NAME$releaseSuffix.apk",
"$ASSET_NAME.apk",
)
var preferredAsset: JsonObject? = null
outer@ for (name in preferredNames) {
for (asset in assets) {
val assetName =
asset.jsonObject["name"]?.jsonPrimitive?.contentOrNull
if (name == assetName) {
preferredAsset = asset.jsonObject
break@outer
}
}
}
return preferredAsset
?.get("browser_download_url")
?.jsonPrimitive
?.contentOrNull
}
suspend fun installRelease( suspend fun installRelease(
release: Release, release: Release,
callback: DownloadCallback, callback: DownloadCallback,
@ -195,7 +214,7 @@ class UpdateChecker
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val contentValues = val contentValues =
ContentValues().apply { ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, ASSET_NAME) put(MediaStore.MediaColumns.DISPLAY_NAME, APK_NAME)
put(MediaStore.MediaColumns.MIME_TYPE, APK_MIME_TYPE) put(MediaStore.MediaColumns.MIME_TYPE, APK_MIME_TYPE)
put( put(
MediaStore.MediaColumns.RELATIVE_PATH, MediaStore.MediaColumns.RELATIVE_PATH,
@ -267,7 +286,7 @@ class UpdateChecker
val downloadDir = val downloadDir =
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
downloadDir.mkdirs() downloadDir.mkdirs()
val targetFile = File(downloadDir, ASSET_NAME) val targetFile = File(downloadDir, APK_NAME)
targetFile.outputStream().use { output -> targetFile.outputStream().use { output ->
response.body!!.byteStream().use { input -> response.body!!.byteStream().use { input ->
copyTo(input, output, callback = callback) copyTo(input, output, callback = callback)
@ -320,7 +339,7 @@ class UpdateChecker
} else { } else {
val downloadDir = val downloadDir =
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
val targetFile = File(downloadDir, ASSET_NAME) val targetFile = File(downloadDir, APK_NAME)
if (targetFile.exists()) { if (targetFile.exists()) {
targetFile.delete() targetFile.delete()
} }

View file

@ -30,7 +30,10 @@ fun CircularProgress(modifier: Modifier = Modifier) {
* Fill the space with a loading indicator and take focus * Fill the space with a loading indicator and take focus
*/ */
@Composable @Composable
fun LoadingPage(modifier: Modifier = Modifier) { fun LoadingPage(
modifier: Modifier = Modifier,
focusEnabled: Boolean = true,
) {
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
Box( Box(
@ -39,7 +42,7 @@ fun LoadingPage(modifier: Modifier = Modifier) {
modifier modifier
.fillMaxSize() .fillMaxSize()
.focusRequester(focusRequester) .focusRequester(focusRequester)
.focusable(), .focusable(focusEnabled),
) { ) {
CircularProgressIndicator( CircularProgressIndicator(
color = MaterialTheme.colorScheme.border, color = MaterialTheme.colorScheme.border,

View file

@ -1,6 +1,7 @@
package com.github.damontecres.wholphin.ui.detail package com.github.damontecres.wholphin.ui.detail
import android.content.Context import android.content.Context
import android.os.Build
import android.util.Log import android.util.Log
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.focusable import androidx.compose.foundation.focusable
@ -224,6 +225,11 @@ fun DebugPage(
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
) )
Text(
text = "ABIs: ${Build.SUPPORTED_ABIS.toList()}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
)
} }
} }
item { item {
@ -237,12 +243,12 @@ fun DebugPage(
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
) )
Text( Text(
text = "Current server: ${viewModel.serverRepository.currentServer}", text = "Current server: ${viewModel.serverRepository.currentServer.value}",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
) )
Text( Text(
text = "Current user: ${viewModel.serverRepository.currentUser}", text = "Current user: ${viewModel.serverRepository.currentUser.value}",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
) )

View file

@ -274,7 +274,7 @@ fun SeekBar(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
interactionSource = interactionSource, interactionSource = interactionSource,
enabled = isEnabled, enabled = isEnabled,
durationMs = player.contentDuration, durationMs = player.duration,
seekBack = seekBack, seekBack = seekBack,
seekForward = seekForward, seekForward = seekForward,
) )

View file

@ -407,6 +407,12 @@ fun PlaybackOverlay(
.padding(16.dp) .padding(16.dp)
.background(AppColors.TransparentBlack50), .background(AppColors.TransparentBlack50),
) { ) {
Text(
text = "Backend: ${currentPlayback?.backend}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(start = 8.dp),
)
Text( Text(
text = "Play method: ${currentPlayback?.playMethod?.serialName}", text = "Play method: ${currentPlayback?.playMethod?.serialName}",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,

View file

@ -40,6 +40,7 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.intl.Locale import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -65,6 +66,7 @@ import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.Playlist import com.github.damontecres.wholphin.data.model.Playlist
import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.preferences.skipBackOnResume import com.github.damontecres.wholphin.preferences.skipBackOnResume
import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.AspectRatios
@ -72,12 +74,14 @@ import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.applyToMpv
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.toSubtitleStyle import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.toSubtitleStyle
import com.github.damontecres.wholphin.ui.seasonEpisode import com.github.damontecres.wholphin.ui.seasonEpisode
import com.github.damontecres.wholphin.ui.stringRes import com.github.damontecres.wholphin.ui.stringRes
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.DeviceProfile import org.jellyfin.sdk.model.api.DeviceProfile
@ -117,6 +121,7 @@ fun PlaybackPage(
LoadingState.Success -> { LoadingState.Success -> {
val prefs = preferences.appPreferences.playbackPreferences val prefs = preferences.appPreferences.playbackPreferences
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val density = LocalDensity.current
val player = viewModel.player val player = viewModel.player
val title by viewModel.title.observeAsState(null) val title by viewModel.title.observeAsState(null)
@ -157,6 +162,13 @@ fun PlaybackPage(
OneTimeLaunchedEffect { OneTimeLaunchedEffect {
player.addListener(cueListener) player.addListener(cueListener)
if (prefs.playerBackend == PlayerBackend.MPV) {
scope.launch(Dispatchers.Main + ExceptionHandler()) {
preferences.appPreferences.interfacePreferences.subtitlesPreferences.applyToMpv(
density,
)
}
}
} }
DisposableEffect(Unit) { DisposableEffect(Unit) {
onDispose { player.removeListener(cueListener) } onDispose { player.removeListener(cueListener) }
@ -166,7 +178,7 @@ fun PlaybackPage(
var playbackSpeed by remember { mutableFloatStateOf(1.0f) } var playbackSpeed by remember { mutableFloatStateOf(1.0f) }
LaunchedEffect(playbackSpeed) { player.setPlaybackSpeed(playbackSpeed) } LaunchedEffect(playbackSpeed) { player.setPlaybackSpeed(playbackSpeed) }
val presentationState = rememberPresentationState(player) val presentationState = rememberPresentationState(player, false)
val scaledModifier = val scaledModifier =
Modifier.resizeWithContentScale(contentScale, presentationState.videoSizeDp) Modifier.resizeWithContentScale(contentScale, presentationState.videoSizeDp)
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
@ -241,12 +253,15 @@ fun PlaybackPage(
modifier = scaledModifier, modifier = scaledModifier,
) )
if (presentationState.coverSurface) { if (presentationState.coverSurface) {
val isLoading by rememberPlayerLoadingState(player)
Box( Box(
Modifier Modifier
.matchParentSize() .matchParentSize()
.background(Color.Black), .background(Color.Black),
) { ) {
LoadingPage() if (isLoading) {
LoadingPage(focusEnabled = false)
}
} }
} }
@ -365,7 +380,7 @@ fun PlaybackPage(
PlaybackAction.Next -> { PlaybackAction.Next -> {
// TODO focus is lost // TODO focus is lost
viewModel.playUpNextUp() viewModel.playNextUp()
} }
PlaybackAction.Previous -> { PlaybackAction.Previous -> {
@ -458,14 +473,14 @@ fun PlaybackPage(
if (autoPlayEnabled) { if (autoPlayEnabled) {
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
if (timeLeft == 0L) { if (timeLeft == 0L) {
viewModel.playUpNextUp() viewModel.playNextUp()
} else { } else {
while (timeLeft > 0) { while (timeLeft > 0) {
delay(1.seconds) delay(1.seconds)
timeLeft-- timeLeft--
} }
if (timeLeft == 0L && autoPlayEnabled) { if (timeLeft == 0L && autoPlayEnabled) {
viewModel.playUpNextUp() viewModel.playNextUp()
} }
} }
} }
@ -483,7 +498,7 @@ fun PlaybackPage(
?: AspectRatios.WIDE, ?: AspectRatios.WIDE,
onClick = { onClick = {
viewModel.reportInteraction() viewModel.reportInteraction()
viewModel.playUpNextUp() viewModel.playNextUp()
}, },
timeLeft = if (autoPlayEnabled) timeLeft.seconds else null, timeLeft = if (autoPlayEnabled) timeLeft.seconds else null,
modifier = modifier =

View file

@ -30,6 +30,7 @@ import com.github.damontecres.wholphin.data.model.chooseSource
import com.github.damontecres.wholphin.data.model.chooseStream import com.github.damontecres.wholphin.data.model.chooseStream
import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.preferences.ShowNextUpWhen import com.github.damontecres.wholphin.preferences.ShowNextUpWhen
import com.github.damontecres.wholphin.preferences.SkipSegmentBehavior import com.github.damontecres.wholphin.preferences.SkipSegmentBehavior
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
@ -52,6 +53,7 @@ import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.TrackActivityPlaybackListener import com.github.damontecres.wholphin.util.TrackActivityPlaybackListener
import com.github.damontecres.wholphin.util.TrackSupport import com.github.damontecres.wholphin.util.TrackSupport
import com.github.damontecres.wholphin.util.checkForSupport import com.github.damontecres.wholphin.util.checkForSupport
import com.github.damontecres.wholphin.util.mpv.mpvDeviceProfile
import com.github.damontecres.wholphin.util.subtitleMimeTypes import com.github.damontecres.wholphin.util.subtitleMimeTypes
import com.github.damontecres.wholphin.util.supportItemKinds import com.github.damontecres.wholphin.util.supportItemKinds
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
@ -77,6 +79,8 @@ import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.DeviceProfile import org.jellyfin.sdk.model.api.DeviceProfile
import org.jellyfin.sdk.model.api.MediaSegmentDto import org.jellyfin.sdk.model.api.MediaSegmentDto
import org.jellyfin.sdk.model.api.MediaSegmentType import org.jellyfin.sdk.model.api.MediaSegmentType
import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaStream
import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.PlayMethod import org.jellyfin.sdk.model.api.PlayMethod
import org.jellyfin.sdk.model.api.PlaybackInfoDto import org.jellyfin.sdk.model.api.PlaybackInfoDto
@ -209,7 +213,7 @@ class PlaybackViewModel
destination.forceTranscoding, destination.forceTranscoding,
) )
if (!played) { if (!played) {
playUpNextUp() playNextUp()
} }
if (!isPlaylist && queriedItem.type == BaseItemKind.EPISODE) { if (!isPlaylist && queriedItem.type == BaseItemKind.EPISODE) {
@ -364,6 +368,7 @@ class PlaybackViewModel
enableDirectStream = !forceTranscoding, enableDirectStream = !forceTranscoding,
) )
player.prepare() player.prepare()
player.play()
this@PlaybackViewModel.chapters.value = Chapter.fromDto(base, api) this@PlaybackViewModel.chapters.value = Chapter.fromDto(base, api)
Timber.v("chapters=${this@PlaybackViewModel.chapters.value?.size}") Timber.v("chapters=${this@PlaybackViewModel.chapters.value?.size}")
@ -378,29 +383,74 @@ class PlaybackViewModel
currentItemPlayback: ItemPlayback = this@PlaybackViewModel.currentItemPlayback.value!!, currentItemPlayback: ItemPlayback = this@PlaybackViewModel.currentItemPlayback.value!!,
audioIndex: Int?, audioIndex: Int?,
subtitleIndex: Int?, subtitleIndex: Int?,
positionMs: Long = C.TIME_UNSET, positionMs: Long = 0,
userInitiated: Boolean, userInitiated: Boolean,
enableDirectPlay: Boolean = true, enableDirectPlay: Boolean = true,
enableDirectStream: Boolean = true, enableDirectStream: Boolean = true,
) = withContext(Dispatchers.IO) { ) = withContext(Dispatchers.IO) {
val itemId = item.id val itemId = item.id
val playerBackend = preferences.appPreferences.playbackPreferences.playerBackend
val currentPlayback = this@PlaybackViewModel.currentPlayback.value
if (currentPlayback != null && currentPlayback.item.id == item.id && currentPlayback.playMethod == PlayMethod.DIRECT_PLAY) {
// If direct playing, can try to switch tracks without playback restarting
// Except for external subtitles
// TODO there's probably no reason why we can't add external subtitles?
Timber.v("changeStreams direct play")
val source = currentPlayback.mediaSourceInfo
val externalSubtitle = source.findExternalSubtitle(subtitleIndex)
if (externalSubtitle == null) {
val result =
withContext(Dispatchers.Main) {
applyTrackSelections(
player,
playerBackend,
true,
audioIndex,
subtitleIndex,
source,
)
}
if (result.bothSelected) {
// TODO lots of duplicate code in this block
Timber.d("Changes tracks audio=$audioIndex, subtitle=$subtitleIndex")
val itemPlayback =
currentItemPlayback.copy(
sourceId = source.id?.toUUIDOrNull(),
audioIndex = audioIndex ?: TrackIndex.UNSPECIFIED,
subtitleIndex = subtitleIndex ?: TrackIndex.DISABLED,
)
if (userInitiated) {
viewModelScope.launchIO {
Timber.v("Saving user initiated item playback: %s", itemPlayback)
val updated = itemPlaybackRepository.saveItemPlayback(itemPlayback)
withContext(Dispatchers.Main) {
this@PlaybackViewModel.currentItemPlayback.value = updated
}
}
}
withContext(Dispatchers.Main) {
this@PlaybackViewModel.currentPlayback.value =
currentPlayback.copy(
tracks = checkForSupport(player.currentTracks),
)
this@PlaybackViewModel.currentItemPlayback.value = itemPlayback
}
return@withContext
}
} else {
Timber.v("changeStreams direct play, external subtitle was requested")
}
}
// TODO
// if (currentItemPlayback.let {
// it.itemId == itemId &&
// it.audioIndex == audioIndex &&
// it.subtitleIndex == subtitleIndex
// } == true
// ) {
// Timber.i("No change in playback for changeStreams")
// return@withContext
// }
Timber.d( Timber.d(
"changeStreams: userInitiated=$userInitiated, audioIndex=$audioIndex, subtitleIndex=$subtitleIndex, " + "changeStreams: userInitiated=$userInitiated, audioIndex=$audioIndex, subtitleIndex=$subtitleIndex, " +
"enableDirectPlay=$enableDirectPlay, enableDirectStream=$enableDirectStream", "enableDirectPlay=$enableDirectPlay, enableDirectStream=$enableDirectStream, positionMs=$positionMs",
) )
// TODO if the new audio or subtitle index is already in the streams (eg direct play), should toggle in the player instead
val maxBitrate = val maxBitrate =
preferences.appPreferences.playbackPreferences.maxBitrate preferences.appPreferences.playbackPreferences.maxBitrate
.takeIf { it > 0 } ?: AppPreference.DEFAULT_BITRATE .takeIf { it > 0 } ?: AppPreference.DEFAULT_BITRATE
@ -410,7 +460,12 @@ class PlaybackViewModel
itemId, itemId,
PlaybackInfoDto( PlaybackInfoDto(
startTimeTicks = null, startTimeTicks = null,
deviceProfile = deviceProfile, deviceProfile =
if (playerBackend == PlayerBackend.EXO_PLAYER) {
deviceProfile
} else {
mpvDeviceProfile
},
maxAudioChannels = null, maxAudioChannels = null,
audioStreamIndex = audioIndex, audioStreamIndex = audioIndex,
subtitleStreamIndex = subtitleIndex, subtitleStreamIndex = subtitleIndex,
@ -453,6 +508,7 @@ class PlaybackViewModel
} }
val transcodeType = val transcodeType =
when { when {
// playerBackend == PlayerBackend.MPV -> PlayMethod.DIRECT_PLAY
source.supportsDirectPlay -> PlayMethod.DIRECT_PLAY source.supportsDirectPlay -> PlayMethod.DIRECT_PLAY
source.supportsDirectStream -> PlayMethod.DIRECT_STREAM source.supportsDirectStream -> PlayMethod.DIRECT_STREAM
source.supportsTranscoding -> PlayMethod.TRANSCODE source.supportsTranscoding -> PlayMethod.TRANSCODE
@ -461,14 +517,10 @@ class PlaybackViewModel
val decision = StreamDecision(itemId, transcodeType, mediaUrl) val decision = StreamDecision(itemId, transcodeType, mediaUrl)
Timber.v("Playback decision: $decision") Timber.v("Playback decision: $decision")
val externalSubtitleCount = val externalSubtitleCount = source.externalSubtitlesCount
source.mediaStreams
?.count { it.type == MediaStreamType.SUBTITLE && it.isExternal } ?: 0
val externalSubtitle = val externalSubtitle =
source.mediaStreams source.findExternalSubtitle(subtitleIndex)?.let {
?.firstOrNull { it.type == MediaStreamType.SUBTITLE && it.isExternal && it.index == subtitleIndex }
?.let {
it.deliveryUrl?.let { deliveryUrl -> it.deliveryUrl?.let { deliveryUrl ->
var flags = 0 var flags = 0
if (it.isForced) flags = flags.or(C.SELECTION_FLAG_FORCED) if (it.isForced) flags = flags.or(C.SELECTION_FLAG_FORCED)
@ -479,11 +531,13 @@ class PlaybackViewModel
).setId("e:${it.index}") ).setId("e:${it.index}")
.setMimeType(subtitleMimeTypes[it.codec]) .setMimeType(subtitleMimeTypes[it.codec])
.setLanguage(it.language) .setLanguage(it.language)
.setLabel(it.title)
.setSelectionFlags(flags) .setSelectionFlags(flags)
.build() .build()
} }
} }
Timber.v("externalSubtitleCount=$externalSubtitleCount, externalSubtitle=$externalSubtitle")
Timber.v("subtitleIndex=$subtitleIndex, externalSubtitleCount=$externalSubtitleCount, externalSubtitle=$externalSubtitle")
val mediaItem = val mediaItem =
MediaItem MediaItem
@ -497,9 +551,11 @@ class PlaybackViewModel
CurrentPlayback( CurrentPlayback(
item = item, item = item,
tracks = listOf(), tracks = listOf(),
backend = preferences.appPreferences.playbackPreferences.playerBackend,
playMethod = transcodeType, playMethod = transcodeType,
playSessionId = response.playSessionId, playSessionId = response.playSessionId,
liveStreamId = source.liveStreamId, liveStreamId = source.liveStreamId,
mediaSourceInfo = source,
) )
val itemPlayback = val itemPlayback =
currentItemPlayback.copy( currentItemPlayback.copy(
@ -536,7 +592,7 @@ class PlaybackViewModel
duration.value = source.runTimeTicks?.ticks duration.value = source.runTimeTicks?.ticks
loading.value = LoadingState.Success loading.value = LoadingState.Success
currentPlayback.value = playback this@PlaybackViewModel.currentPlayback.value = playback
this@PlaybackViewModel.currentItemPlayback.value = itemPlayback this@PlaybackViewModel.currentItemPlayback.value = itemPlayback
player.setMediaItem( player.setMediaItem(
mediaItem, mediaItem,
@ -548,18 +604,21 @@ class PlaybackViewModel
override fun onTracksChanged(tracks: Tracks) { override fun onTracksChanged(tracks: Tracks) {
Timber.v("onTracksChanged: $tracks") Timber.v("onTracksChanged: $tracks")
if (tracks.groups.isNotEmpty()) { if (tracks.groups.isNotEmpty()) {
val result =
applyTrackSelections( applyTrackSelections(
player, player,
playerBackend,
source.supportsDirectPlay, source.supportsDirectPlay,
audioIndex, audioIndex,
subtitleIndex, subtitleIndex,
externalSubtitleCount, source,
externalSubtitle != null,
) )
if (result.bothSelected) {
player.removeListener(this) player.removeListener(this)
} }
} }
} }
}
player.addListener(onTracksChangedListener) player.addListener(onTracksChangedListener)
} }
} }
@ -727,7 +786,7 @@ class PlaybackViewModel
} }
} }
fun playUpNextUp() { fun playNextUp() {
playlist.value?.let { playlist.value?.let {
if (it.hasNext()) { if (it.hasNext()) {
viewModelScope.launchIO { viewModelScope.launchIO {
@ -735,7 +794,7 @@ class PlaybackViewModel
val item = it.getAndAdvance() val item = it.getAndAdvance()
val played = play(item, 0) val played = play(item, 0)
if (!played) { if (!played) {
playUpNextUp() playNextUp()
} }
} }
} }
@ -768,7 +827,7 @@ class PlaybackViewModel
if (toPlay != null) { if (toPlay != null) {
val played = play(toPlay, 0) val played = play(toPlay, 0)
if (!played) { if (!played) {
playUpNextUp() playNextUp()
} }
} else { } else {
// TODO // TODO
@ -837,7 +896,7 @@ class PlaybackViewModel
PlaystateCommand.PAUSE -> player.pause() PlaystateCommand.PAUSE -> player.pause()
PlaystateCommand.UNPAUSE -> player.play() PlaystateCommand.UNPAUSE -> player.play()
PlaystateCommand.NEXT_TRACK -> playUpNextUp() PlaystateCommand.NEXT_TRACK -> playNextUp()
PlaystateCommand.PREVIOUS_TRACK -> playPrevious() PlaystateCommand.PREVIOUS_TRACK -> playPrevious()
PlaystateCommand.SEEK -> it.seekPositionTicks?.ticks?.let { player.seekTo(it.inWholeMilliseconds) } PlaystateCommand.SEEK -> it.seekPositionTicks?.ticks?.let { player.seekTo(it.inWholeMilliseconds) }
PlaystateCommand.REWIND -> PlaystateCommand.REWIND ->
@ -1013,9 +1072,11 @@ class PlaybackViewModel
data class CurrentPlayback( data class CurrentPlayback(
val item: BaseItem, val item: BaseItem,
val tracks: List<TrackSupport>, val tracks: List<TrackSupport>,
val backend: PlayerBackend,
val playMethod: PlayMethod, val playMethod: PlayMethod,
val playSessionId: String?, val playSessionId: String?,
val liveStreamId: String?, val liveStreamId: String?,
val mediaSourceInfo: MediaSourceInfo,
) )
sealed interface SubtitleSearch { sealed interface SubtitleSearch {
@ -1044,21 +1105,80 @@ val Format.idAsInt: Int?
} }
} }
/**
* Returns the number of external subtitle streams there are
*/
val MediaSourceInfo.externalSubtitlesCount: Int
get() =
mediaStreams
?.count { it.type == MediaStreamType.SUBTITLE && it.isExternal } ?: 0
/**
* Returns the number of embedded subtitle streams there are
*/
val MediaSourceInfo.embeddedSubtitleCount: Int
get() =
mediaStreams
?.count { it.type == MediaStreamType.SUBTITLE && !it.isExternal } ?: 0
/**
* Returns the number of video streams there are
*/
val MediaSourceInfo.videoStreamCount: Int
get() =
mediaStreams
?.count { it.type == MediaStreamType.VIDEO } ?: 0
/**
* Returns the number of audio streams there are
*/
val MediaSourceInfo.audioStreamCount: Int
get() =
mediaStreams
?.count { it.type == MediaStreamType.AUDIO } ?: 0
/**
* Returns the [MediaStream] for the given subtitle index iff it is external
*/
fun MediaSourceInfo.findExternalSubtitle(subtitleIndex: Int?): MediaStream? =
subtitleIndex?.let {
mediaStreams
?.firstOrNull { it.type == MediaStreamType.SUBTITLE && it.isExternal && it.index == subtitleIndex }
}
suspend fun <T> onMain(block: suspend CoroutineScope.() -> T) = withContext(Dispatchers.Main, block) suspend fun <T> onMain(block: suspend CoroutineScope.() -> T) = withContext(Dispatchers.Main, block)
data class TrackSelectionResult(
val audioSelected: Boolean,
val subtitleSelected: Boolean,
) {
val bothSelected: Boolean = audioSelected && subtitleSelected
}
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
private fun applyTrackSelections( private fun applyTrackSelections(
player: Player, player: Player,
playerBackend: PlayerBackend,
supportsDirectPlay: Boolean, supportsDirectPlay: Boolean,
audioIndex: Int?, audioIndex: Int?,
subtitleIndex: Int?, subtitleIndex: Int?,
externalSubtitleCount: Int, source: MediaSourceInfo,
subtitleIsExternal: Boolean, ): TrackSelectionResult {
) { val videoStreamCount = source.videoStreamCount
if (subtitleIndex != null && subtitleIndex >= 0 && (subtitleIsExternal || supportsDirectPlay)) { val audioStreamCount = source.audioStreamCount
val embeddedSubtitleCount = source.embeddedSubtitleCount
val externalSubtitleCount = source.externalSubtitlesCount
val paramsBuilder = player.trackSelectionParameters.buildUpon()
val tracks = player.currentTracks.groups
val subtitleSelected =
if (subtitleIndex != null && subtitleIndex >= 0) {
val subtitleIsExternal = source.findExternalSubtitle(subtitleIndex) != null
if (subtitleIsExternal || supportsDirectPlay) {
val chosenTrack = val chosenTrack =
if (subtitleIsExternal) { if (subtitleIsExternal && playerBackend == PlayerBackend.EXO_PLAYER) {
player.currentTracks.groups.firstOrNull { group -> tracks.firstOrNull { group ->
group.type == C.TRACK_TYPE_TEXT && group.isSupported && group.type == C.TRACK_TYPE_TEXT && group.isSupported &&
(0..<group.mediaTrackGroup.length) (0..<group.mediaTrackGroup.length)
.mapNotNull { .mapNotNull {
@ -1066,8 +1186,20 @@ private fun applyTrackSelections(
}.any { it.endsWith("e:$subtitleIndex") } }.any { it.endsWith("e:$subtitleIndex") }
} }
} else { } else {
val indexToFind = subtitleIndex - externalSubtitleCount + 1 val indexToFind =
player.currentTracks.groups.firstOrNull { group -> calculateIndexToFind(
subtitleIndex,
MediaStreamType.SUBTITLE,
playerBackend,
videoStreamCount,
audioStreamCount,
embeddedSubtitleCount,
externalSubtitleCount,
subtitleIsExternal,
)
Timber.v("Chosen subtitle ($subtitleIndex/$indexToFind) track")
// subtitleIndex - externalSubtitleCount + 1
tracks.firstOrNull { group ->
group.type == C.TRACK_TYPE_TEXT && group.isSupported && group.type == C.TRACK_TYPE_TEXT && group.isSupported &&
(0..<group.mediaTrackGroup.length) (0..<group.mediaTrackGroup.length)
.map { .map {
@ -1078,29 +1210,40 @@ private fun applyTrackSelections(
Timber.v("Chosen subtitle ($subtitleIndex) track: $chosenTrack") Timber.v("Chosen subtitle ($subtitleIndex) track: $chosenTrack")
chosenTrack?.let { chosenTrack?.let {
player.trackSelectionParameters = paramsBuilder
player.trackSelectionParameters
.buildUpon()
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false) .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false)
.setOverrideForType( .setOverrideForType(
TrackSelectionOverride( TrackSelectionOverride(
chosenTrack.mediaTrackGroup, chosenTrack.mediaTrackGroup,
0, 0,
), ),
).build() )
}
chosenTrack != null
} else {
false
} }
} else { } else {
player.trackSelectionParameters = paramsBuilder
player.trackSelectionParameters
.buildUpon()
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true) .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true)
.build()
true
} }
val audioSelected =
if (audioIndex != null && supportsDirectPlay) { if (audioIndex != null && supportsDirectPlay) {
val indexToFind = val indexToFind =
audioIndex - externalSubtitleCount + 1 calculateIndexToFind(
audioIndex,
MediaStreamType.AUDIO,
playerBackend,
videoStreamCount,
audioStreamCount,
embeddedSubtitleCount,
externalSubtitleCount,
false,
)
val chosenTrack = val chosenTrack =
player.currentTracks.groups.firstOrNull { group -> tracks.firstOrNull { group ->
group.type == C.TRACK_TYPE_AUDIO && group.isSupported && group.type == C.TRACK_TYPE_AUDIO && group.isSupported &&
(0..<group.mediaTrackGroup.length) (0..<group.mediaTrackGroup.length)
.map { .map {
@ -1109,16 +1252,58 @@ private fun applyTrackSelections(
} }
Timber.v("Chosen audio ($audioIndex/$indexToFind) track: $chosenTrack") Timber.v("Chosen audio ($audioIndex/$indexToFind) track: $chosenTrack")
chosenTrack?.let { chosenTrack?.let {
player.trackSelectionParameters = paramsBuilder
player.trackSelectionParameters
.buildUpon()
.setTrackTypeDisabled(C.TRACK_TYPE_AUDIO, false) .setTrackTypeDisabled(C.TRACK_TYPE_AUDIO, false)
.setOverrideForType( .setOverrideForType(
TrackSelectionOverride( TrackSelectionOverride(
chosenTrack.mediaTrackGroup, chosenTrack.mediaTrackGroup,
0, 0,
), ),
).build() )
}
chosenTrack != null
} else {
audioIndex == null
}
if (audioSelected && subtitleSelected) {
player.trackSelectionParameters = paramsBuilder.build()
}
return TrackSelectionResult(audioSelected, subtitleSelected)
}
/**
* Maps the server provided index to the track index based on the [PlayerBackend] and other stream information
*/
private fun calculateIndexToFind(
serverIndex: Int,
type: MediaStreamType,
playerBackend: PlayerBackend,
videoStreamCount: Int,
audioStreamCount: Int,
embeddedSubtitleCount: Int,
externalSubtitleCount: Int,
subtitleIsExternal: Boolean,
): Int =
when (playerBackend) {
PlayerBackend.EXO_PLAYER,
PlayerBackend.UNRECOGNIZED,
-> {
serverIndex - externalSubtitleCount + 1
}
// TODO MPV could use literal indexes because they are stored in the track format ID
PlayerBackend.MPV -> {
when (type) {
MediaStreamType.VIDEO -> serverIndex - externalSubtitleCount + 1
MediaStreamType.AUDIO -> serverIndex - externalSubtitleCount - videoStreamCount + 1
MediaStreamType.SUBTITLE -> {
if (subtitleIsExternal) {
serverIndex + embeddedSubtitleCount + 1
} else {
serverIndex - externalSubtitleCount - videoStreamCount - audioStreamCount + 1
}
}
else -> throw UnsupportedOperationException("Cannot calculate index for $type")
} }
} }
} }

View file

@ -0,0 +1,39 @@
package com.github.damontecres.wholphin.ui.playback
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.media3.common.Player
import androidx.media3.common.listen
import androidx.media3.common.util.UnstableApi
@UnstableApi
@Composable
fun rememberPlayerLoadingState(player: Player): PlayerLoadingState {
val state = remember(player) { PlayerLoadingState(player) }
LaunchedEffect(player) {
state.observe()
}
return state
}
@UnstableApi
class PlayerLoadingState(
private val player: Player,
) : State<Boolean> {
override var value by mutableStateOf(player.isLoading)
private set
suspend fun observe() {
value = player.isLoading
player.listen {
if (it.contains(Player.EVENT_IS_LOADING_CHANGED)) {
value = player.isLoading
}
}
}
}

View file

@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.ui.preferences
import androidx.annotation.StringRes import androidx.annotation.StringRes
import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
/** /**
@ -10,6 +11,12 @@ import kotlinx.serialization.Serializable
data class PreferenceGroup( data class PreferenceGroup(
@param:StringRes val title: Int, @param:StringRes val title: Int,
val preferences: List<AppPreference<out Any?>>, val preferences: List<AppPreference<out Any?>>,
val conditionalPreferences: List<ConditionalPreferences> = listOf(),
)
data class ConditionalPreferences(
val condition: (AppPreferences) -> Boolean,
val preferences: List<AppPreference<out Any?>>,
) )
/** /**

View file

@ -43,9 +43,11 @@ import coil3.SingletonImageLoader
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.preferences.advancedPreferences import com.github.damontecres.wholphin.preferences.advancedPreferences
import com.github.damontecres.wholphin.preferences.basicPreferences import com.github.damontecres.wholphin.preferences.basicPreferences
import com.github.damontecres.wholphin.preferences.uiPreferences import com.github.damontecres.wholphin.preferences.uiPreferences
import com.github.damontecres.wholphin.preferences.updatePlaybackPreferences
import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.playOnClickSound import com.github.damontecres.wholphin.ui.playOnClickSound
@ -53,9 +55,11 @@ import com.github.damontecres.wholphin.ui.playSoundOnFocus
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleStylePage import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleStylePage
import com.github.damontecres.wholphin.ui.setup.UpdateViewModel import com.github.damontecres.wholphin.ui.setup.UpdateViewModel
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber
@Composable @Composable
fun PreferencesContent( fun PreferencesContent(
@ -112,6 +116,22 @@ fun PreferencesContent(
visible = true visible = true
} }
LaunchedEffect(preferences.playbackPreferences.playerBackend) {
if (preferences.playbackPreferences.playerBackend == PlayerBackend.MPV) {
Timber.d("Checking for libmpv")
try {
System.loadLibrary("mpv")
System.loadLibrary("player")
} catch (ex: Exception) {
Timber.w(ex, "Could not load libmpv")
showToast(context, "MPV is not supported on this device")
viewModel.preferenceDataStore.updateData {
it.updatePlaybackPreferences { playerBackend = PlayerBackend.EXO_PLAYER }
}
}
}
}
AnimatedVisibility( AnimatedVisibility(
visible = visible, visible = visible,
enter = fadeIn() + slideInHorizontally { it / 2 }, enter = fadeIn() + slideInHorizontally { it / 2 },
@ -179,7 +199,13 @@ fun PreferencesContent(
.padding(top = 8.dp, bottom = 4.dp), .padding(top = 8.dp, bottom = 4.dp),
) )
} }
group.preferences.forEachIndexed { prefIndex, pref -> val groupPreferences =
group.preferences +
group.conditionalPreferences
.filter { it.condition.invoke(preferences) }
.map { it.preferences }
.flatten()
groupPreferences.forEachIndexed { prefIndex, pref ->
pref as AppPreference<Any> pref as AppPreference<Any>
item { item {
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }

View file

@ -4,6 +4,8 @@ import android.graphics.Typeface
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.sp
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.CaptionStyleCompat import androidx.media3.ui.CaptionStyleCompat
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
@ -17,6 +19,8 @@ import com.github.damontecres.wholphin.preferences.SubtitlePreferences
import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
import com.github.damontecres.wholphin.ui.preferences.PreferenceGroup import com.github.damontecres.wholphin.ui.preferences.PreferenceGroup
import com.github.damontecres.wholphin.util.mpv.MPVLib
import com.github.damontecres.wholphin.util.mpv.setPropertyColor
object SubtitleSettings { object SubtitleSettings {
val FontSize = val FontSize =
@ -245,4 +249,56 @@ object SubtitleSettings {
}, },
) )
} }
fun SubtitlePreferences.applyToMpv(density: Density) {
val fo = (fontOpacity / 100.0 * 255).toInt().shl(24)
val fc = Color(fo.or(fontColor))
val bg = Color((backgroundOpacity / 100.0 * 255).toInt().shl(24).or(backgroundColor))
val edge = Color(fo.or(edgeColor))
// TODO weird, but seems to get the size to be very close to matching sizes between renderers
val fontSizePx = with(density) { fontSize.sp.toPx() * .8 }.toInt()
MPVLib.setPropertyInt("sub-font-size", fontSizePx)
MPVLib.setPropertyColor("sub-color", fc)
MPVLib.setPropertyColor("sub-outline-color", edge)
when (edgeStyle) {
EdgeStyle.EDGE_NONE,
EdgeStyle.UNRECOGNIZED,
-> {
MPVLib.setPropertyInt("sub-shadow-offset", 0)
MPVLib.setPropertyDouble("sub-outline-size", 0.0)
}
EdgeStyle.EDGE_SOLID -> {
MPVLib.setPropertyInt("sub-shadow-offset", 0)
MPVLib.setPropertyDouble("sub-outline-size", 1.15)
}
EdgeStyle.EDGE_SHADOW -> {
MPVLib.setPropertyInt("sub-shadow-offset", 4)
MPVLib.setPropertyDouble("sub-outline-size", 0.0)
}
}
// if (fontBold) {
// MPVLib.setPropertyString("sub-font", "Roboto Bold")
// } else {
// MPVLib.setPropertyString("sub-font", "Roboto Regular")
// }
MPVLib.setPropertyBoolean("sub-bold", fontBold)
MPVLib.setPropertyBoolean("sub-italic", fontItalic)
MPVLib.setPropertyColor("sub-back-color", bg)
val borderStyle =
when (backgroundStyle) {
BackgroundStyle.UNRECOGNIZED,
BackgroundStyle.BG_NONE,
-> "outline-and-shadow"
BackgroundStyle.BG_WRAP -> "opaque-box"
BackgroundStyle.BG_BOXED -> "background-box"
}
MPVLib.setPropertyString("sub-border-style", borderStyle)
}
} }

View file

@ -0,0 +1,334 @@
package com.github.damontecres.wholphin.util.mpv
/*
This file is copied from https://github.com/mpv-android/mpv-android/blob/master/app/src/main/java/is/xyz/mpv/MPVLib.kt
Copyright (c) 2016 Ilya Zhuravlev
Copyright (c) 2016 sfan5 <sfan5@live.de>
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction,
including without limitation the rights to use, copy, modify, merge, publish, distribute,
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or
substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import android.content.Context
import android.graphics.Bitmap
import android.view.Surface
// Wrapper for native library
@Suppress("unused")
object MPVLib {
init {
val libs = arrayOf("mpv", "player")
for (lib in libs) {
System.loadLibrary(lib)
}
}
external fun create(appctx: Context)
external fun init()
external fun destroy()
external fun attachSurface(surface: Surface)
external fun detachSurface()
external fun command(cmd: Array<out String>)
external fun setOptionString(
name: String,
value: String,
): Int
external fun grabThumbnail(dimension: Int): Bitmap?
external fun getPropertyInt(property: String): Int?
external fun setPropertyInt(
property: String,
value: Int,
)
external fun getPropertyDouble(property: String): Double?
external fun setPropertyDouble(
property: String,
value: Double,
)
external fun getPropertyBoolean(property: String): Boolean?
external fun setPropertyBoolean(
property: String,
value: Boolean,
)
external fun getPropertyString(property: String): String?
external fun setPropertyString(
property: String,
value: String,
)
external fun observeProperty(
property: String,
format: Int,
)
private val observers = mutableListOf<EventObserver>()
@JvmStatic
fun addObserver(o: EventObserver) {
synchronized(observers) {
observers.add(o)
}
}
@JvmStatic
fun removeObserver(o: EventObserver) {
synchronized(observers) {
observers.remove(o)
}
}
@JvmStatic
fun eventProperty(
property: String,
value: Long,
) {
synchronized(observers) {
for (o in observers) {
o.eventProperty(property, value)
}
}
}
@JvmStatic
fun eventProperty(
property: String,
value: Boolean,
) {
synchronized(observers) {
for (o in observers) {
o.eventProperty(property, value)
}
}
}
@JvmStatic
fun eventProperty(
property: String,
value: Double,
) {
synchronized(observers) {
for (o in observers) {
o.eventProperty(property, value)
}
}
}
@JvmStatic
fun eventProperty(
property: String,
value: String,
) {
synchronized(observers) {
for (o in observers) {
o.eventProperty(property, value)
}
}
}
@JvmStatic
fun eventProperty(property: String) {
synchronized(observers) {
for (o in observers) {
o.eventProperty(property)
}
}
}
@JvmStatic
fun event(eventId: Int) {
synchronized(observers) {
for (o in observers) {
o.event(eventId)
}
}
}
@JvmStatic
fun eventEndFile(
reason: Int,
error: Int,
) {
synchronized(observers) {
for (o in observers) {
o.eventEndFile(reason, error)
}
}
}
private val log_observers = mutableListOf<LogObserver>()
@JvmStatic
fun addLogObserver(o: LogObserver) {
synchronized(log_observers) {
log_observers.add(o)
}
}
@JvmStatic
fun removeLogObserver(o: LogObserver) {
synchronized(log_observers) {
log_observers.remove(o)
}
}
@JvmStatic
fun logMessage(
prefix: String,
level: Int,
text: String,
) {
synchronized(log_observers) {
for (o in log_observers) {
o.logMessage(prefix, level, text)
}
}
}
interface EventObserver {
fun eventProperty(property: String)
fun eventProperty(
property: String,
value: Long,
)
fun eventProperty(
property: String,
value: Boolean,
)
fun eventProperty(
property: String,
value: String,
)
fun eventProperty(
property: String,
value: Double,
)
fun event(eventId: Int)
fun eventEndFile(
reason: Int,
error: Int,
)
}
interface LogObserver {
fun logMessage(
prefix: String,
level: Int,
text: String,
)
}
object MpvFormat {
const val MPV_FORMAT_NONE: Int = 0
const val MPV_FORMAT_STRING: Int = 1
const val MPV_FORMAT_OSD_STRING: Int = 2
const val MPV_FORMAT_FLAG: Int = 3
const val MPV_FORMAT_INT64: Int = 4
const val MPV_FORMAT_DOUBLE: Int = 5
const val MPV_FORMAT_NODE: Int = 6
const val MPV_FORMAT_NODE_ARRAY: Int = 7
const val MPV_FORMAT_NODE_MAP: Int = 8
const val MPV_FORMAT_BYTE_ARRAY: Int = 9
}
object MpvEvent {
const val MPV_EVENT_NONE: Int = 0
const val MPV_EVENT_SHUTDOWN: Int = 1
const val MPV_EVENT_LOG_MESSAGE: Int = 2
const val MPV_EVENT_GET_PROPERTY_REPLY: Int = 3
const val MPV_EVENT_SET_PROPERTY_REPLY: Int = 4
const val MPV_EVENT_COMMAND_REPLY: Int = 5
const val MPV_EVENT_START_FILE: Int = 6
const val MPV_EVENT_END_FILE: Int = 7
const val MPV_EVENT_FILE_LOADED: Int = 8
@Deprecated("")
const val MPV_EVENT_IDLE: Int = 11
@Deprecated("")
const val MPV_EVENT_TICK: Int = 14
const val MPV_EVENT_CLIENT_MESSAGE: Int = 16
const val MPV_EVENT_VIDEO_RECONFIG: Int = 17
const val MPV_EVENT_AUDIO_RECONFIG: Int = 18
const val MPV_EVENT_SEEK: Int = 20
const val MPV_EVENT_PLAYBACK_RESTART: Int = 21
const val MPV_EVENT_PROPERTY_CHANGE: Int = 22
const val MPV_EVENT_QUEUE_OVERFLOW: Int = 24
const val MPV_EVENT_HOOK: Int = 25
}
object MpvLogLevel {
const val MPV_LOG_LEVEL_NONE: Int = 0
const val MPV_LOG_LEVEL_FATAL: Int = 10
const val MPV_LOG_LEVEL_ERROR: Int = 20
const val MPV_LOG_LEVEL_WARN: Int = 30
const val MPV_LOG_LEVEL_INFO: Int = 40
const val MPV_LOG_LEVEL_V: Int = 50
const val MPV_LOG_LEVEL_DEBUG: Int = 60
const val MPV_LOG_LEVEL_TRACE: Int = 70
}
object MpvEndFileReason {
const val MPV_END_FILE_REASON_EOF: Int = 0
const val MPV_END_FILE_REASON_STOP: Int = 2
const val MPV_END_FILE_REASON_QUIT: Int = 3
const val MPV_END_FILE_REASON_ERROR: Int = 4
const val MPV_END_FILE_REASON_REDIRECT: Int = 5
}
object MpvError {
const val MPV_ERROR_SUCCESS = 0
const val MPV_ERROR_EVENT_QUEUE_FULL = -1
const val MPV_ERROR_NOMEM = -2
const val MPV_ERROR_UNINITIALIZED = -3
const val MPV_ERROR_INVALID_PARAMETER = -4
const val MPV_ERROR_OPTION_NOT_FOUND = -5
const val MPV_ERROR_OPTION_FORMAT = -6
const val MPV_ERROR_OPTION_ERROR = -7
const val MPV_ERROR_PROPERTY_NOT_FOUND = -8
const val MPV_ERROR_PROPERTY_FORMAT = -9
const val MPV_ERROR_PROPERTY_UNAVAILABLE = -10
const val MPV_ERROR_PROPERTY_ERROR = -11
const val MPV_ERROR_COMMAND = -12
const val MPV_ERROR_LOADING_FAILED = -13
const val MPV_ERROR_AO_INIT_FAILED = -14
const val MPV_ERROR_VO_INIT_FAILED = -15
const val MPV_ERROR_NOTHING_TO_PLAY = -16
const val MPV_ERROR_UNKNOWN_FORMAT = -17
const val MPV_ERROR_UNSUPPORTED = -18
const val MPV_ERROR_NOT_IMPLEMENTED = -19
const val MPV_ERROR_GENERIC = -20
}
}

View file

@ -0,0 +1,46 @@
package com.github.damontecres.wholphin.util.mpv
import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvFormat.MPV_FORMAT_DOUBLE
import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvFormat.MPV_FORMAT_FLAG
import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvFormat.MPV_FORMAT_INT64
import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvFormat.MPV_FORMAT_NONE
import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvFormat.MPV_FORMAT_STRING
object MPVProperty {
const val POSITION = "time-pos"
const val POSITION_FULL = "time-pos/full"
const val DURATION = "duration/full"
const val PAUSED = "pause"
const val PAUSED_FOR_CACHE = "paused-for-cache"
const val SPEED = "speed"
const val TRACK_LIST = "track-list"
const val ASPECT = "video-params/aspect"
const val ROTATE = "video-params/rotate"
const val TRACK_VIDEO = "current-tracks/video/image"
const val METADATA = "metadata"
const val HWDEC = "hwdec-current"
const val MUTE = "mute"
const val TRACK_AUDIO = "current-tracks/audio/selected"
const val SEEKABLE = "seekable"
const val SUBTITLE_TEXT = "sub-text"
val observedProperties =
mapOf(
POSITION to MPV_FORMAT_INT64,
// POSITION_FULL to MPV_FORMAT_DOUBLE,
DURATION to MPV_FORMAT_DOUBLE,
PAUSED to MPV_FORMAT_FLAG,
PAUSED_FOR_CACHE to MPV_FORMAT_FLAG,
SPEED to MPV_FORMAT_STRING,
TRACK_LIST to MPV_FORMAT_NONE,
ASPECT to MPV_FORMAT_DOUBLE,
ROTATE to MPV_FORMAT_DOUBLE,
TRACK_VIDEO to MPV_FORMAT_NONE,
METADATA to MPV_FORMAT_NONE,
HWDEC to MPV_FORMAT_NONE,
MUTE to MPV_FORMAT_FLAG,
TRACK_AUDIO to MPV_FORMAT_NONE,
SEEKABLE to MPV_FORMAT_FLAG,
SUBTITLE_TEXT to MPV_FORMAT_STRING,
)
}

View file

@ -0,0 +1,81 @@
package com.github.damontecres.wholphin.util.mpv
import com.github.damontecres.wholphin.util.profile.Codec
import com.github.damontecres.wholphin.util.profile.subtitleProfile
import com.github.damontecres.wholphin.util.profile.supportedAudioCodecs
import org.jellyfin.sdk.model.api.DlnaProfileType
import org.jellyfin.sdk.model.api.EncodingContext
import org.jellyfin.sdk.model.api.MediaStreamProtocol
import org.jellyfin.sdk.model.deviceprofile.buildDeviceProfile
val mpvDeviceProfile =
buildDeviceProfile {
name = "mpv"
// TODO
// maxStaticBitrate = maxBitrate
// maxStreamingBitrate = maxBitrate
transcodingProfile {
type = DlnaProfileType.VIDEO
context = EncodingContext.STREAMING
container = Codec.Container.TS
protocol = MediaStreamProtocol.HLS
videoCodec(Codec.Video.HEVC)
videoCodec(Codec.Video.H264)
audioCodec(*supportedAudioCodecs)
copyTimestamps = false
enableSubtitlesInManifest = true
}
directPlayProfile {
type = DlnaProfileType.VIDEO
container(
Codec.Container.ASF,
Codec.Container.AVI,
Codec.Container.HLS,
Codec.Container.M4V,
Codec.Container.MPEG,
Codec.Container.MPEGTS,
Codec.Container.MKV,
Codec.Container.MOV,
Codec.Container.MP4,
Codec.Container.MPG,
Codec.Container.OGM,
Codec.Container.OGV,
Codec.Container.TS,
Codec.Container.VOB,
Codec.Container.WEBM,
Codec.Container.WMV,
Codec.Container.XVID,
)
videoCodec(
Codec.Video.AV1,
Codec.Video.H264,
Codec.Video.HEVC,
Codec.Video.MPEG,
Codec.Video.MPEG2VIDEO,
Codec.Video.VP8,
Codec.Video.VP9,
)
audioCodec(*supportedAudioCodecs, Codec.Audio.WAV, Codec.Audio.OGG)
}
subtitleProfile(Codec.Subtitle.VTT, embedded = true, hls = true, external = true)
subtitleProfile(Codec.Subtitle.WEBVTT, embedded = true, hls = true, external = true)
subtitleProfile(Codec.Subtitle.SRT, embedded = true, external = true)
subtitleProfile(Codec.Subtitle.SUBRIP, embedded = true, external = true)
subtitleProfile(Codec.Subtitle.TTML, embedded = true, external = true)
subtitleProfile(Codec.Subtitle.DVBSUB, embedded = true, encode = true)
subtitleProfile(Codec.Subtitle.DVDSUB, embedded = true, encode = true)
subtitleProfile(Codec.Subtitle.IDX, embedded = true, encode = true)
subtitleProfile(Codec.Subtitle.PGS, embedded = true, encode = true)
subtitleProfile(Codec.Subtitle.PGSSUB, embedded = true, encode = true)
subtitleProfile(Codec.Subtitle.ASS, encode = true, embedded = true, external = true)
subtitleProfile(Codec.Subtitle.SSA, encode = true, embedded = true, external = true)
}

View file

@ -0,0 +1,817 @@
package com.github.damontecres.wholphin.util.mpv
import android.content.Context
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.view.Surface
import android.view.SurfaceHolder
import android.view.SurfaceView
import android.view.TextureView
import androidx.annotation.OptIn
import androidx.compose.ui.graphics.Color
import androidx.media3.common.AudioAttributes
import androidx.media3.common.BasePlayer
import androidx.media3.common.C
import androidx.media3.common.DeviceInfo
import androidx.media3.common.Format
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.MimeTypes
import androidx.media3.common.PlaybackException
import androidx.media3.common.PlaybackParameters
import androidx.media3.common.Player
import androidx.media3.common.Timeline
import androidx.media3.common.TrackGroup
import androidx.media3.common.TrackSelectionParameters
import androidx.media3.common.Tracks
import androidx.media3.common.VideoSize
import androidx.media3.common.text.CueGroup
import androidx.media3.common.util.Clock
import androidx.media3.common.util.ListenerSet
import androidx.media3.common.util.Size
import androidx.media3.common.util.UnstableApi
import androidx.media3.common.util.Util
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import androidx.media3.exoplayer.trackselection.TrackSelector
import androidx.media3.exoplayer.upstream.DefaultBandwidthMeter
import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEndFileReason.MPV_END_FILE_REASON_EOF
import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEndFileReason.MPV_END_FILE_REASON_ERROR
import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEndFileReason.MPV_END_FILE_REASON_STOP
import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEvent.MPV_EVENT_AUDIO_RECONFIG
import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEvent.MPV_EVENT_END_FILE
import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEvent.MPV_EVENT_FILE_LOADED
import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEvent.MPV_EVENT_PLAYBACK_RESTART
import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEvent.MPV_EVENT_VIDEO_RECONFIG
import timber.log.Timber
import kotlin.concurrent.atomics.ExperimentalAtomicApi
import kotlin.time.Duration.Companion.seconds
/**
* This is barebones implementation of a [Player] which plays content using libmpv
*
* It doesn't support every feature or emit every event
*/
@kotlin.OptIn(ExperimentalAtomicApi::class)
@OptIn(UnstableApi::class)
class MpvPlayer(
private val context: Context,
enableHardwareDecoding: Boolean,
) : BasePlayer(),
MPVLib.EventObserver,
TrackSelector.InvalidationListener {
companion object {
private const val DEBUG = false
}
private var isPaused: Boolean = true
private var surface: Surface? = null
private val looper = Util.getCurrentOrMainLooper()
private val handler = Handler(looper)
private val listeners =
ListenerSet<Player.Listener>(
looper,
Clock.DEFAULT,
) { listener, eventFlags ->
listener.onEvents(this@MpvPlayer, Player.Events(eventFlags))
}
private val availableCommands: Player.Commands
private val trackSelector = DefaultTrackSelector(context)
private var mediaItem: MediaItem? = null
private var startPositionMs: Long = 0L
private var durationMs: Long = 0L
private var positionMs: Long = -1L
private var playbackState: Int = STATE_READY
@Volatile
var isReleased = false
private set
@Volatile
private var isLoadingFile = false
init {
Timber.v("config-dir=${context.filesDir.path}")
MPVLib.create(context)
MPVLib.setOptionString("config", "yes")
MPVLib.setOptionString("config-dir", context.filesDir.path)
if (enableHardwareDecoding) {
MPVLib.setOptionString("hwdec", "mediacodec,mediacodec-copy")
MPVLib.setOptionString("vo", "gpu")
} else {
MPVLib.setOptionString("hwdec", "no")
}
MPVLib.setOptionString("gpu-context", "android")
MPVLib.setOptionString("opengl-es", "yes")
MPVLib.setOptionString("hwdec-codecs", "h264,hevc,mpeg4,mpeg2video,vp8,vp9,av1")
val cacheMegs = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) 64 else 32
MPVLib.setOptionString("demuxer-max-bytes", "${cacheMegs * 1024 * 1024}")
MPVLib.setOptionString("demuxer-max-back-bytes", "${cacheMegs * 1024 * 1024}")
MPVLib.init()
MPVLib.setOptionString("force-window", "no")
MPVLib.setOptionString("idle", "yes")
// MPVLib.setOptionString("sub-fonts-dir", File(context.filesDir, "fonts").absolutePath)
MPVLib.addObserver(this)
MPVProperty.observedProperties.forEach(MPVLib::observeProperty)
availableCommands =
Player.Commands
.Builder()
.addAll(
COMMAND_PLAY_PAUSE,
COMMAND_PREPARE,
COMMAND_STOP,
COMMAND_SET_SPEED_AND_PITCH,
COMMAND_SET_SHUFFLE_MODE,
COMMAND_SET_REPEAT_MODE,
COMMAND_GET_CURRENT_MEDIA_ITEM,
COMMAND_GET_TIMELINE,
COMMAND_GET_METADATA,
// COMMAND_SET_PLAYLIST_METADATA,
COMMAND_SET_MEDIA_ITEM,
// COMMAND_CHANGE_MEDIA_ITEMS,
COMMAND_GET_TRACKS,
// COMMAND_GET_AUDIO_ATTRIBUTES,
// COMMAND_SET_AUDIO_ATTRIBUTES,
// COMMAND_GET_VOLUME,
// COMMAND_SET_VOLUME,
COMMAND_SET_VIDEO_SURFACE,
// COMMAND_GET_TEXT,
COMMAND_RELEASE,
).build()
trackSelector.init(this, DefaultBandwidthMeter.getSingletonInstance(context))
}
override fun getApplicationLooper(): Looper = looper
override fun addListener(listener: Player.Listener) {
if (DEBUG) Timber.v("addListener")
listeners.add(listener)
}
override fun removeListener(listener: Player.Listener) {
if (DEBUG) Timber.v("removeListener")
listeners.remove(listener)
}
override fun setMediaItems(
mediaItems: List<MediaItem>,
resetPosition: Boolean,
) {
throwIfReleased()
if (DEBUG) Timber.v("setMediaItems")
mediaItems.firstOrNull()?.let {
mediaItem = it
if (surface != null) {
loadFile(it)
}
}
}
override fun setMediaItems(
mediaItems: List<MediaItem>,
startIndex: Int,
startPositionMs: Long,
) {
if (DEBUG) Timber.v("setMediaItems")
this.startPositionMs = startPositionMs
setMediaItems(mediaItems.subList(startIndex, mediaItems.size), false)
}
override fun addMediaItems(
index: Int,
mediaItems: List<MediaItem>,
): Unit = throw UnsupportedOperationException()
override fun moveMediaItems(
fromIndex: Int,
toIndex: Int,
newIndex: Int,
): Unit = throw UnsupportedOperationException()
override fun replaceMediaItems(
fromIndex: Int,
toIndex: Int,
mediaItems: List<MediaItem>,
): Unit = throw UnsupportedOperationException()
override fun removeMediaItems(
fromIndex: Int,
toIndex: Int,
): Unit = throw UnsupportedOperationException()
override fun getAvailableCommands(): Player.Commands = availableCommands
override fun prepare() {
if (DEBUG) Timber.v("prepare")
durationMs = 0L
positionMs = -1L
playbackState = STATE_READY
}
override fun getPlaybackState(): Int {
if (DEBUG) Timber.v("getPlaybackState")
return playbackState
}
override fun getPlaybackSuppressionReason(): Int = Player.PLAYBACK_SUPPRESSION_REASON_NONE
override fun getPlayerError(): PlaybackException? {
TODO("Not yet implemented")
}
override fun setPlayWhenReady(playWhenReady: Boolean) {
if (isReleased) return
if (DEBUG) Timber.v("setPlayWhenReady")
if (playWhenReady) {
MPVLib.setPropertyBoolean("pause", false)
} else {
MPVLib.setPropertyBoolean("pause", true)
}
notifyListeners(EVENT_PLAY_WHEN_READY_CHANGED) {
onPlayWhenReadyChanged(
playWhenReady,
PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST,
)
}
}
override fun getPlayWhenReady(): Boolean {
if (DEBUG) Timber.v("getPlayWhenReady")
if (isReleased) return false
val isPaused = MPVLib.getPropertyBoolean("pause") ?: this.isPaused
return !isPaused
}
override fun setRepeatMode(repeatMode: Int) {
if (DEBUG) Timber.v("setRepeatMode")
}
override fun getRepeatMode(): Int = Player.REPEAT_MODE_OFF
override fun setShuffleModeEnabled(shuffleModeEnabled: Boolean) {
if (DEBUG) Timber.v("setShuffleModeEnabled")
}
override fun getShuffleModeEnabled(): Boolean = false
override fun isLoading(): Boolean = isLoadingFile
override fun getSeekBackIncrement(): Long = 10_000
override fun getSeekForwardIncrement(): Long = 30_000
override fun getMaxSeekToPreviousPosition(): Long = 10_000
override fun setPlaybackParameters(playbackParameters: PlaybackParameters) {
if (DEBUG) Timber.v("setPlaybackParameters")
MPVLib.setPropertyDouble("speed", playbackParameters.speed.toDouble())
}
override fun getPlaybackParameters(): PlaybackParameters {
if (DEBUG) Timber.v("getPlaybackParameters")
val speed = MPVLib.getPropertyDouble("speed")?.toFloat() ?: 1f
return PlaybackParameters(speed)
}
override fun stop() {
if (DEBUG) Timber.v("stop")
if (isReleased) return
pause()
mediaItem = null
positionMs = -1L
durationMs = 0L
playbackState = STATE_IDLE
notifyListeners(EVENT_IS_PLAYING_CHANGED) { onIsPlayingChanged(false) }
notifyListeners(EVENT_PLAYBACK_STATE_CHANGED) { onPlaybackStateChanged(STATE_IDLE) }
}
override fun release() {
Timber.i("release")
if (!isReleased) {
MPVLib.removeObserver(this)
clearVideoSurfaceView(null)
MPVLib.destroy()
}
isReleased = true
}
override fun getCurrentTracks(): Tracks {
if (DEBUG) Timber.v("getCurrentTracks")
if (isReleased) return Tracks.EMPTY
return getTracks()
}
override fun getTrackSelectionParameters(): TrackSelectionParameters {
if (DEBUG) Timber.v("getTrackSelectionParameters")
return TrackSelectionParameters
.Builder()
.build()
}
override fun setTrackSelectionParameters(parameters: TrackSelectionParameters) {
Timber.v("TrackSelection: setTrackSelectionParameters %s", parameters)
if (isReleased) return
val tracks = getTracks()
if (C.TRACK_TYPE_TEXT in parameters.disabledTrackTypes) {
// Subtitles disabled
Timber.v("TrackSelection: disabling subtitles")
MPVLib.setPropertyString("sid", "no")
}
if (C.TRACK_TYPE_AUDIO in parameters.disabledTrackTypes) {
// Audio disabled
Timber.v("TrackSelection: disabling audio")
MPVLib.setPropertyString("aid", "no")
}
Timber.v("TrackSelection: Got ${parameters.overrides.size} overrides")
parameters.overrides.forEach { (trackGroup, trackSelectionOverride) ->
val result =
tracks.groups.firstOrNull { it.mediaTrackGroup == trackGroup }?.let {
val id = it.mediaTrackGroup.getFormat(0).id
val splits = id?.split(":")
val trackId = splits?.getOrNull(1)
val propertyName =
when (it.mediaTrackGroup.type) {
C.TRACK_TYPE_AUDIO -> "aid"
C.TRACK_TYPE_VIDEO -> "vid"
C.TRACK_TYPE_TEXT -> "sid"
else -> null
}
Timber.v("TrackSelection: activating %s %s '%s'", propertyName, trackId, id)
if (trackId != null && propertyName != null) {
MPVLib.setPropertyString(propertyName, trackId)
true
} else {
false
}
}
if (result != true) {
Timber.w(
"Did not find track to select for type=%s, id=%s",
trackGroup.type,
trackGroup.getFormat(0).id,
)
}
}
}
override fun getMediaMetadata(): MediaMetadata {
if (DEBUG) Timber.v("getMediaMetadata")
return MediaMetadata.EMPTY
}
override fun getPlaylistMetadata(): MediaMetadata {
if (DEBUG) Timber.v("getPlaylistMetadata")
return MediaMetadata.EMPTY
}
override fun setPlaylistMetadata(mediaMetadata: MediaMetadata): Unit = throw UnsupportedOperationException()
override fun getCurrentTimeline(): Timeline {
if (DEBUG) Timber.v("getCurrentTimeline")
// TODO
return Timeline.EMPTY
}
override fun getCurrentPeriodIndex(): Int {
if (DEBUG) Timber.v("getCurrentPeriodIndex")
// TODO
return 0
}
override fun getCurrentMediaItemIndex(): Int {
if (DEBUG) Timber.v("getCurrentMediaItemIndex")
return 0
}
override fun getDuration(): Long {
if (DEBUG) Timber.v("getDuration")
if (isReleased) {
return durationMs
}
val duration =
MPVLib.getPropertyDouble("duration/full")?.seconds?.inWholeMilliseconds
?: durationMs
return duration
}
override fun getCurrentPosition(): Long {
if (DEBUG) Timber.v("getCurrentPosition")
if (isReleased) {
return positionMs
}
val position =
MPVLib.getPropertyDouble("time-pos/full")?.seconds?.inWholeMilliseconds
?: positionMs
return position
}
override fun getBufferedPosition(): Long {
if (DEBUG) Timber.v("getBufferedPosition")
return currentPosition + totalBufferedDuration
}
override fun getTotalBufferedDuration(): Long {
if (DEBUG) Timber.v("getTotalBufferedDuration")
if (isReleased) return 0
return MPVLib.getPropertyDouble("demuxer-cache-duration")?.seconds?.inWholeMilliseconds ?: 0
}
override fun isPlayingAd(): Boolean {
if (DEBUG) Timber.v("isPlayingAd")
return false
}
override fun getCurrentAdGroupIndex(): Int = C.INDEX_UNSET
override fun getCurrentAdIndexInAdGroup(): Int = C.INDEX_UNSET
override fun getContentPosition(): Long = currentPosition
override fun getContentBufferedPosition(): Long = bufferedPosition
override fun getAudioAttributes(): AudioAttributes = throw UnsupportedOperationException()
override fun setVolume(volume: Float): Unit = throw UnsupportedOperationException()
override fun getVolume(): Float = 1f
override fun clearVideoSurface(): Unit = throw UnsupportedOperationException()
override fun clearVideoSurface(surface: Surface?): Unit = throw UnsupportedOperationException()
override fun setVideoSurface(surface: Surface?): Unit = throw UnsupportedOperationException()
override fun setVideoSurfaceHolder(surfaceHolder: SurfaceHolder?): Unit = throw UnsupportedOperationException()
override fun clearVideoSurfaceHolder(surfaceHolder: SurfaceHolder?): Unit = throw UnsupportedOperationException()
override fun setVideoSurfaceView(surfaceView: SurfaceView?) {
throwIfReleased()
if (DEBUG) Timber.v("setVideoSurfaceView")
val surface = surfaceView?.holder?.surface
if (surface != null) {
this.surface = surface
Timber.v("Queued attach")
MPVLib.attachSurface(surface)
MPVLib.setOptionString("force-window", "yes")
Timber.d("Attached surface")
mediaItem?.let(::loadFile)
if (mediaItem == null) {
Timber.w("mediaItem is null in setVideoSurfaceView")
}
} else {
clearVideoSurfaceView(null)
}
}
override fun clearVideoSurfaceView(surfaceView: SurfaceView?) {
Timber.d("clearVideoSurfaceView")
MPVLib.detachSurface()
MPVLib.setPropertyString("vo", "null")
MPVLib.setPropertyString("force-window", "no")
mediaItem = null
}
override fun setVideoTextureView(textureView: TextureView?): Unit = throw UnsupportedOperationException()
override fun clearVideoTextureView(textureView: TextureView?): Unit = throw UnsupportedOperationException()
override fun getVideoSize(): VideoSize {
if (DEBUG) Timber.v("getVideoSize")
if (isReleased) return VideoSize.UNKNOWN
val width = MPVLib.getPropertyInt("width")
val height = MPVLib.getPropertyInt("height")
return if (width != null && height != null) {
VideoSize(width, height)
} else {
VideoSize.UNKNOWN
}
}
override fun getSurfaceSize(): Size = throw UnsupportedOperationException()
override fun getCurrentCues(): CueGroup = CueGroup.EMPTY_TIME_ZERO
override fun getDeviceInfo(): DeviceInfo {
if (DEBUG) Timber.v("getDeviceInfo")
return DeviceInfo.Builder(DeviceInfo.PLAYBACK_TYPE_REMOTE).build()
}
override fun getDeviceVolume(): Int = throw UnsupportedOperationException()
override fun isDeviceMuted(): Boolean = throw UnsupportedOperationException()
@Deprecated("Deprecated in Java")
override fun setDeviceVolume(volume: Int): Unit = throw UnsupportedOperationException()
override fun setDeviceVolume(
volume: Int,
flags: Int,
): Unit = throw UnsupportedOperationException()
@Deprecated("Deprecated in Java")
override fun increaseDeviceVolume(): Unit = throw UnsupportedOperationException()
override fun increaseDeviceVolume(flags: Int): Unit = throw UnsupportedOperationException()
@Deprecated("Deprecated in Java")
override fun decreaseDeviceVolume(): Unit = throw UnsupportedOperationException()
override fun decreaseDeviceVolume(flags: Int): Unit = throw UnsupportedOperationException()
@Deprecated("Deprecated in Java")
override fun setDeviceMuted(muted: Boolean): Unit = throw UnsupportedOperationException()
override fun setDeviceMuted(
muted: Boolean,
flags: Int,
): Unit = throw UnsupportedOperationException()
override fun setAudioAttributes(
audioAttributes: AudioAttributes,
handleAudioFocus: Boolean,
): Unit = throw UnsupportedOperationException()
override fun seekTo(
mediaItemIndex: Int,
positionMs: Long,
seekCommand: Int,
isRepeatingCurrentItem: Boolean,
) {
if (DEBUG) Timber.v("seekTo")
if (isReleased) {
Timber.w("seekTo called after release")
return
}
if (mediaItemIndex == C.INDEX_UNSET) {
return
}
MPVLib.setPropertyDouble("time-pos", positionMs / 1000.0)
}
override fun eventProperty(property: String) {
if (DEBUG) Timber.v("eventProperty: $property")
}
override fun eventProperty(
property: String,
value: Long,
) {
if (DEBUG) Timber.v("eventPropertyLong: $property=$value")
when (property) {
MPVProperty.POSITION -> positionMs = value.seconds.inWholeMilliseconds
}
}
override fun eventProperty(
property: String,
value: Boolean,
) {
if (DEBUG) Timber.v("eventPropertyBoolean: $property=$value")
when (property) {
MPVProperty.PAUSED -> {
isPaused = value
notifyListeners(EVENT_IS_PLAYING_CHANGED) { onIsPlayingChanged(!value) }
}
}
}
override fun eventProperty(
property: String,
value: String,
) {
if (DEBUG) Timber.v("eventPropertyString: $property=$value")
}
override fun eventProperty(
property: String,
value: Double,
) {
Timber.v("eventPropertyDouble: $property=$value")
when (property) {
MPVProperty.DURATION -> durationMs = value.seconds.inWholeMilliseconds
}
}
override fun event(eventId: Int) {
when (eventId) {
// MPV_EVENT_START_FILE -> {
// }
MPV_EVENT_FILE_LOADED -> {
isLoadingFile = false
notifyListeners(EVENT_IS_LOADING_CHANGED) { onIsLoadingChanged(false) }
Timber.d("event: MPV_EVENT_FILE_LOADED")
mediaItem?.let {
it.localConfiguration?.subtitleConfigurations?.forEach {
val url = it.uri.toString()
val title = it.label ?: "External Subtitles"
Timber.v("Adding external subtitle track '$title'")
MPVLib.command(arrayOf("sub-add", url, "auto", title))
}
}
notifyListeners(EVENT_RENDERED_FIRST_FRAME) { onRenderedFirstFrame() }
notifyListeners(EVENT_IS_PLAYING_CHANGED) { onIsPlayingChanged(true) }
getTracks().let {
notifyListeners(EVENT_TRACKS_CHANGED) { onTracksChanged(it) }
}
}
MPV_EVENT_PLAYBACK_RESTART -> {
Timber.d("event: MPV_EVENT_PLAYBACK_RESTART")
getTracks().let {
notifyListeners(EVENT_TRACKS_CHANGED) { onTracksChanged(it) }
}
}
MPV_EVENT_AUDIO_RECONFIG -> {
Timber.d("event: MPV_EVENT_AUDIO_RECONFIG")
getTracks().let {
notifyListeners(EVENT_TRACKS_CHANGED) { onTracksChanged(it) }
}
}
MPV_EVENT_VIDEO_RECONFIG -> {
Timber.d("event: MPV_EVENT_VIDEO_RECONFIG")
getTracks().let {
notifyListeners(EVENT_TRACKS_CHANGED) { onTracksChanged(it) }
}
}
MPV_EVENT_END_FILE -> {
Timber.d("event: MPV_EVENT_END_FILE")
// Handled by eventEndFile
}
else -> {
Timber.v("event: $eventId")
}
}
}
override fun eventEndFile(
reason: Int,
error: Int,
) {
Timber.d("MPV_EVENT_END_FILE: %s %s", reason, error)
notifyListeners(EVENT_IS_PLAYING_CHANGED) { onIsPlayingChanged(false) }
when (reason) {
MPV_END_FILE_REASON_EOF -> {
notifyListeners(EVENT_PLAYBACK_STATE_CHANGED) {
onPlaybackStateChanged(STATE_ENDED)
}
}
MPV_END_FILE_REASON_STOP -> {
// User initiated (eg stop, play next, etc)
}
MPV_END_FILE_REASON_ERROR -> {
Timber.e("libmpv error, error=%s", error)
notifyListeners(EVENT_PLAYER_ERROR) {
onPlayerError(
PlaybackException(
"libmpv error",
null,
error,
),
)
}
}
else -> {
// no-op
}
}
}
private fun loadFile(mediaItem: MediaItem) {
isLoadingFile = true
notifyListeners(EVENT_IS_LOADING_CHANGED) { onIsLoadingChanged(true) }
val url = mediaItem.localConfiguration?.uri.toString()
if (startPositionMs > 0) {
MPVLib.command(
arrayOf(
"loadfile",
url,
"replace",
"-1",
"start=${startPositionMs / 1000.0}",
),
)
} else {
MPVLib.command(arrayOf("loadfile", url, "replace", "-1"))
}
MPVLib.setPropertyString("vo", "gpu")
Timber.d("Called loadfile")
}
private fun throwIfReleased() {
if (isReleased) {
throw IllegalStateException("Cannot access MpvPlayer after it is released")
}
}
private fun notifyListeners(
eventId: Int,
block: Player.Listener.() -> Unit,
) {
handler.post {
listeners.queueEvent(eventId) {
block.invoke(it)
}
listeners.flushEvents()
}
}
private fun getTracks(): Tracks {
val trackCount = MPVLib.getPropertyInt("track-list/count") ?: return Tracks.EMPTY
val groups =
(0..<trackCount).mapNotNull { idx ->
val type = MPVLib.getPropertyString("track-list/$idx/type")
val id = MPVLib.getPropertyInt("track-list/$idx/id")
val lang = MPVLib.getPropertyString("track-list/$idx/lang")
val codec = MPVLib.getPropertyString("track-list/$idx/codec")
val codecDescription = MPVLib.getPropertyString("track-list/$idx/codec-desc")
val isDefault = MPVLib.getPropertyBoolean("track-list/$idx/default") ?: false
val isForced = MPVLib.getPropertyBoolean("track-list/$idx/forced") ?: false
val isExternal = MPVLib.getPropertyBoolean("track-list/$idx/external") ?: false
val isSelected = MPVLib.getPropertyBoolean("track-list/$idx/selected") ?: false
val channelCount = MPVLib.getPropertyInt("track-list/$idx/demux-channel-count")
val title = MPVLib.getPropertyString("track-list/$idx/title")
if (type != null && id != null) {
// TODO do we need the real mimetypes?
val mimeType =
when (type) {
"video" -> MimeTypes.BASE_TYPE_VIDEO + "/todo"
"audio" -> MimeTypes.BASE_TYPE_AUDIO + "/todo"
"sub" -> MimeTypes.BASE_TYPE_TEXT + "/todo"
else -> "unknown/todo"
}
var flags = 0
if (isDefault) flags = flags or C.SELECTION_FLAG_DEFAULT
if (isForced) flags = flags or C.SELECTION_FLAG_FORCED
val builder =
Format
.Builder()
.setId("$idx:$id")
.setCodecs(codec)
.setSampleMimeType(mimeType)
.setLanguage(lang)
.setLabel(listOfNotNull(title, codecDescription).joinToString(","))
.setSelectionFlags(flags)
if (type == "video" && isSelected) {
builder.setWidth(MPVLib.getPropertyInt("width") ?: -1)
builder.setHeight(MPVLib.getPropertyInt("height") ?: -1)
}
channelCount?.let(builder::setChannelCount)
val format = builder.build()
val trackGroup = TrackGroup(format)
val group =
Tracks.Group(
trackGroup,
false,
intArrayOf(C.FORMAT_HANDLED),
booleanArrayOf(isSelected),
)
group
} else {
null
}
}
return Tracks(groups)
}
override fun onTrackSelectionsInvalidated() {
// no-op
}
var subtitleDelay: Double
get() {
if (isReleased) return 0.0
return MPVLib.getPropertyDouble("sub-delay") ?: 0.0
}
set(value) {
if (isReleased) return
MPVLib.setPropertyDouble("sub-delay", value)
}
}
fun MPVLib.setPropertyColor(
property: String,
color: Color,
) = MPVLib.setPropertyString(property, color.mpvFormat)
private val Color.mpvFormat: String get() = "$red/$green/$blue/$alpha"

View file

@ -22,7 +22,7 @@ private val downmixSupportedAudioCodecs =
Codec.Audio.MP3, Codec.Audio.MP3,
) )
private val supportedAudioCodecs = val supportedAudioCodecs =
arrayOf( arrayOf(
Codec.Audio.AAC, Codec.Audio.AAC,
Codec.Audio.AAC_LATM, Codec.Audio.AAC_LATM,
@ -495,7 +495,7 @@ fun createDeviceProfile(
} }
// Little helper function to more easily define subtitle profiles // Little helper function to more easily define subtitle profiles
private fun DeviceProfileBuilder.subtitleProfile( fun DeviceProfileBuilder.subtitleProfile(
format: String, format: String,
embedded: Boolean = false, embedded: Boolean = false,
external: Boolean = false, external: Boolean = false,

View file

@ -0,0 +1,82 @@
LOCAL_PATH:= $(call my-dir)
ifeq ($(TARGET_ARCH_ABI),armeabi-v7a)
PREFIX = $(PREFIX32)
endif
ifeq ($(TARGET_ARCH_ABI),arm64-v8a)
PREFIX = $(PREFIX64)
endif
ifeq ($(TARGET_ARCH_ABI),x86_64)
PREFIX = $(PREFIX_X64)
endif
ifeq ($(TARGET_ARCH_ABI),x86)
PREFIX = $(PREFIX_X86)
endif
include $(CLEAR_VARS)
LOCAL_MODULE := libswresample
LOCAL_SRC_FILES := $(PREFIX)/lib/$(LOCAL_MODULE).so
include $(PREBUILT_SHARED_LIBRARY)
include $(CLEAR_VARS)
LOCAL_MODULE := libpostproc
LOCAL_SRC_FILES := $(PREFIX)/lib/$(LOCAL_MODULE).so
# only include if library file exists
ifneq (,$(wildcard $(LOCAL_SRC_FILES)))
include $(PREBUILT_SHARED_LIBRARY)
endif
include $(CLEAR_VARS)
LOCAL_MODULE := libavutil
LOCAL_SRC_FILES := $(PREFIX)/lib/$(LOCAL_MODULE).so
include $(PREBUILT_SHARED_LIBRARY)
include $(CLEAR_VARS)
LOCAL_MODULE := libavcodec
LOCAL_SRC_FILES := $(PREFIX)/lib/$(LOCAL_MODULE).so
include $(PREBUILT_SHARED_LIBRARY)
include $(CLEAR_VARS)
LOCAL_MODULE := libavformat
LOCAL_SRC_FILES := $(PREFIX)/lib/$(LOCAL_MODULE).so
include $(PREBUILT_SHARED_LIBRARY)
include $(CLEAR_VARS)
LOCAL_MODULE := libswscale
LOCAL_SRC_FILES := $(PREFIX)/lib/$(LOCAL_MODULE).so
include $(PREBUILT_SHARED_LIBRARY)
include $(CLEAR_VARS)
LOCAL_MODULE := libavfilter
LOCAL_SRC_FILES := $(PREFIX)/lib/$(LOCAL_MODULE).so
include $(PREBUILT_SHARED_LIBRARY)
include $(CLEAR_VARS)
LOCAL_MODULE := libavdevice
LOCAL_SRC_FILES := $(PREFIX)/lib/$(LOCAL_MODULE).so
LOCAL_EXPORT_C_INCLUDES := $(PREFIX)/include
include $(PREBUILT_SHARED_LIBRARY)
include $(CLEAR_VARS)
LOCAL_MODULE := libmpv
LOCAL_SRC_FILES := $(PREFIX)/lib/libmpv.so
LOCAL_EXPORT_C_INCLUDES := $(PREFIX)/include
include $(PREBUILT_SHARED_LIBRARY)
include $(CLEAR_VARS)
LOCAL_MODULE := libplayer
LOCAL_CFLAGS := -Werror
LOCAL_CPPFLAGS += -std=c++11
LOCAL_SRC_FILES := \
main.cpp \
render.cpp \
log.cpp \
jni_utils.cpp \
property.cpp \
event.cpp \
thumbnail.cpp
LOCAL_LDLIBS := -llog -lGLESv3 -lEGL -latomic
LOCAL_SHARED_LIBRARIES := swscale avcodec mpv
include $(BUILD_SHARED_LIBRARY)

View file

@ -0,0 +1,17 @@
APP_ABI :=
ifneq ($(PREFIX32),)
APP_ABI += armeabi-v7a
endif
ifneq ($(PREFIX64),)
APP_ABI += arm64-v8a
endif
ifneq ($(PREFIX_X64),)
APP_ABI += x86_64
endif
ifneq ($(PREFIX_X86),)
APP_ABI += x86
endif
APP_PLATFORM := android-21
APP_STL := c++_shared
APP_SUPPORT_FLEXIBLE_PAGE_SIZES := true

View file

@ -0,0 +1,16 @@
# JNI bindings for libmpv
The files in this directory are copied from https://github.com/mpv-android/mpv-android/tree/master/app/src/main/jni
## License & copyright
The files are copyrighted and used under MIT license below:
Copyright (c) 2016 Ilya Zhuravlev
Copyright (c) 2016 sfan5 <sfan5@live.de>
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

120
app/src/main/jni/event.cpp Normal file
View file

@ -0,0 +1,120 @@
#include <jni.h>
#include <mpv/client.h>
#include "globals.h"
#include "jni_utils.h"
#include "log.h"
static void sendPropertyUpdateToJava(JNIEnv *env, mpv_event_property *prop)
{
jstring jprop = env->NewStringUTF(prop->name);
jstring jvalue = NULL;
switch (prop->format) {
case MPV_FORMAT_NONE:
env->CallStaticVoidMethod(mpv_MPVLib, mpv_MPVLib_eventProperty_S, jprop);
break;
case MPV_FORMAT_FLAG:
env->CallStaticVoidMethod(mpv_MPVLib, mpv_MPVLib_eventProperty_Sb, jprop,
(jboolean) (*(int*)prop->data != 0));
break;
case MPV_FORMAT_INT64:
env->CallStaticVoidMethod(mpv_MPVLib, mpv_MPVLib_eventProperty_Sl, jprop,
(jlong) *(int64_t*)prop->data);
break;
case MPV_FORMAT_DOUBLE:
env->CallStaticVoidMethod(mpv_MPVLib, mpv_MPVLib_eventProperty_Sd, jprop,
(jdouble) *(double*)prop->data);
break;
case MPV_FORMAT_STRING:
jvalue = env->NewStringUTF(*(const char**)prop->data);
env->CallStaticVoidMethod(mpv_MPVLib, mpv_MPVLib_eventProperty_SS, jprop, jvalue);
break;
default:
ALOGV("sendPropertyUpdateToJava: Unknown property update format received in callback: %d!", prop->format);
break;
}
if (jprop)
env->DeleteLocalRef(jprop);
if (jvalue)
env->DeleteLocalRef(jvalue);
}
static void sendEventToJava(JNIEnv *env, int event)
{
env->CallStaticVoidMethod(mpv_MPVLib, mpv_MPVLib_event, event);
}
static void sendEndFileEventToJava(JNIEnv *env, mpv_event_end_file *event)
{
env->CallStaticVoidMethod(mpv_MPVLib, mpv_MPVLib_end_file_event, event->reason, event->error);
}
static void sendLogMessageToJava(JNIEnv *env, mpv_event_log_message *msg)
{
// filter the most obvious cases of invalid utf-8, since Java would choke on it
const auto invalid_utf8 = [] (unsigned char c) {
return c == 0xc0 || c == 0xc1 || c >= 0xf5;
};
for (int i = 0; msg->text[i]; i++) {
if (invalid_utf8(static_cast<unsigned char>(msg->text[i])))
return;
}
jstring jprefix = env->NewStringUTF(msg->prefix);
jstring jtext = env->NewStringUTF(msg->text);
env->CallStaticVoidMethod(mpv_MPVLib, mpv_MPVLib_logMessage_SiS,
jprefix, (jint) msg->log_level, jtext);
if (jprefix)
env->DeleteLocalRef(jprefix);
if (jtext)
env->DeleteLocalRef(jtext);
}
void *event_thread(void *arg)
{
JNIEnv *env = NULL;
acquire_jni_env(g_vm, &env);
if (!env)
die("failed to acquire java env");
while (1) {
mpv_event *mp_event;
mpv_event_property *mp_property = NULL;
mpv_event_log_message *msg = NULL;
mpv_event_end_file *mp_end_file = NULL;
mp_event = mpv_wait_event(g_mpv, -1.0);
if (g_event_thread_request_exit)
break;
if (mp_event->event_id == MPV_EVENT_NONE)
continue;
switch (mp_event->event_id) {
case MPV_EVENT_LOG_MESSAGE:
msg = (mpv_event_log_message*)mp_event->data;
ALOGV("[%s:%s] %s", msg->prefix, msg->level, msg->text);
sendLogMessageToJava(env, msg);
break;
case MPV_EVENT_PROPERTY_CHANGE:
mp_property = (mpv_event_property*)mp_event->data;
sendPropertyUpdateToJava(env, mp_property);
break;
case MPV_EVENT_END_FILE:
mp_end_file = (mpv_event_end_file*)mp_event->data;
sendEndFileEventToJava(env, mp_end_file);
default:
ALOGV("event: %s\n", mpv_event_name(mp_event->event_id));
sendEventToJava(env, mp_event->event_id);
break;
}
}
g_vm->DetachCurrentThread();
return NULL;
}

3
app/src/main/jni/event.h Normal file
View file

@ -0,0 +1,3 @@
#pragma once
void *event_thread(void *arg);

View file

@ -0,0 +1,7 @@
#pragma once
#include <atomic>
extern JavaVM *g_vm;
extern mpv_handle *g_mpv;
extern std::atomic<bool> g_event_thread_request_exit;

View file

@ -0,0 +1,52 @@
#define UTIL_EXTERN
#include "jni_utils.h"
#include <jni.h>
#include <stdlib.h>
bool acquire_jni_env(JavaVM *vm, JNIEnv **env)
{
int ret = vm->GetEnv((void**) env, JNI_VERSION_1_6);
if (ret == JNI_EDETACHED)
return vm->AttachCurrentThread(env, NULL) == 0;
else
return ret == JNI_OK;
}
// Apparently it's considered slow to FindClass and GetMethodID every time we need them,
// so let's have a nice cache here.
void init_methods_cache(JNIEnv *env)
{
static bool methods_initialized = false;
if (methods_initialized)
return;
#define FIND_CLASS(name) reinterpret_cast<jclass>(env->NewGlobalRef(env->FindClass(name)))
java_Integer = FIND_CLASS("java/lang/Integer");
java_Integer_init = env->GetMethodID(java_Integer, "<init>", "(I)V");
java_Double = FIND_CLASS("java/lang/Double");
java_Double_init = env->GetMethodID(java_Double, "<init>", "(D)V");
java_Boolean = FIND_CLASS("java/lang/Boolean");
java_Boolean_init = env->GetMethodID(java_Boolean, "<init>", "(Z)V");
android_graphics_Bitmap = FIND_CLASS("android/graphics/Bitmap");
// createBitmap(int[], int, int, android.graphics.Bitmap$Config)
android_graphics_Bitmap_createBitmap = env->GetStaticMethodID(android_graphics_Bitmap, "createBitmap", "([IIILandroid/graphics/Bitmap$Config;)Landroid/graphics/Bitmap;");
android_graphics_Bitmap_Config = FIND_CLASS("android/graphics/Bitmap$Config");
// static final android.graphics.Bitmap$Config ARGB_8888
android_graphics_Bitmap_Config_ARGB_8888 = env->GetStaticFieldID(android_graphics_Bitmap_Config, "ARGB_8888", "Landroid/graphics/Bitmap$Config;");
mpv_MPVLib = FIND_CLASS("com/github/damontecres/wholphin/util/mpv/MPVLib");
mpv_MPVLib_eventProperty_S = env->GetStaticMethodID(mpv_MPVLib, "eventProperty", "(Ljava/lang/String;)V"); // eventProperty(String)
mpv_MPVLib_eventProperty_Sb = env->GetStaticMethodID(mpv_MPVLib, "eventProperty", "(Ljava/lang/String;Z)V"); // eventProperty(String, boolean)
mpv_MPVLib_eventProperty_Sl = env->GetStaticMethodID(mpv_MPVLib, "eventProperty", "(Ljava/lang/String;J)V"); // eventProperty(String, long)
mpv_MPVLib_eventProperty_Sd = env->GetStaticMethodID(mpv_MPVLib, "eventProperty", "(Ljava/lang/String;D)V"); // eventProperty(String, double)
mpv_MPVLib_eventProperty_SS = env->GetStaticMethodID(mpv_MPVLib, "eventProperty", "(Ljava/lang/String;Ljava/lang/String;)V"); // eventProperty(String, String)
mpv_MPVLib_event = env->GetStaticMethodID(mpv_MPVLib, "event", "(I)V"); // event(int)
mpv_MPVLib_end_file_event = env->GetStaticMethodID(mpv_MPVLib, "eventEndFile", "(II)V"); // eventEndFile(int, int)
mpv_MPVLib_logMessage_SiS = env->GetStaticMethodID(mpv_MPVLib, "logMessage", "(Ljava/lang/String;ILjava/lang/String;)V"); // logMessage(String, int, String)
#undef FIND_CLASS
methods_initialized = true;
}

View file

@ -0,0 +1,30 @@
#pragma once
#include <jni.h>
#define jni_func_name(name) Java_com_github_damontecres_wholphin_util_mpv_MPVLib_##name
#define jni_func(return_type, name, ...) JNIEXPORT return_type JNICALL jni_func_name(name) (JNIEnv *env, jobject obj, ##__VA_ARGS__)
bool acquire_jni_env(JavaVM *vm, JNIEnv **env);
void init_methods_cache(JNIEnv *env);
#ifndef UTIL_EXTERN
#define UTIL_EXTERN extern
#endif
UTIL_EXTERN jclass java_Integer, java_Double, java_Boolean;
UTIL_EXTERN jmethodID java_Integer_init, java_Double_init, java_Boolean_init;
UTIL_EXTERN jclass android_graphics_Bitmap, android_graphics_Bitmap_Config;
UTIL_EXTERN jmethodID android_graphics_Bitmap_createBitmap;
UTIL_EXTERN jfieldID android_graphics_Bitmap_Config_ARGB_8888;
UTIL_EXTERN jclass mpv_MPVLib;
UTIL_EXTERN jmethodID mpv_MPVLib_eventProperty_S,
mpv_MPVLib_eventProperty_Sb,
mpv_MPVLib_eventProperty_Sl,
mpv_MPVLib_eventProperty_Sd,
mpv_MPVLib_eventProperty_SS,
mpv_MPVLib_event,
mpv_MPVLib_end_file_event,
mpv_MPVLib_logMessage_SiS;

9
app/src/main/jni/log.cpp Normal file
View file

@ -0,0 +1,9 @@
#include "log.h"
#include <stdlib.h>
void die(const char *msg)
{
ALOGE("%s", msg);
exit(1);
}

20
app/src/main/jni/log.h Normal file
View file

@ -0,0 +1,20 @@
#pragma once
#include <android/log.h>
#define DEBUG 1
#define LOG_TAG "mpv"
#define ALOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
#if DEBUG
#define ALOGV(...) __android_log_print(ANDROID_LOG_VERBOSE, LOG_TAG, __VA_ARGS__)
#else
#define ALOGV(...) (void)0
#endif
__attribute__((noreturn)) void die(const char *msg);
#define CHECK_MPV_INIT() do { \
if (__builtin_expect(!g_mpv, 0)) \
die("libmpv is not initialized"); \
} while (0)

108
app/src/main/jni/main.cpp Normal file
View file

@ -0,0 +1,108 @@
#include <jni.h>
#include <stdlib.h>
#include <stdio.h>
#include <time.h>
#include <locale.h>
#include <atomic>
#include <mpv/client.h>
#include <pthread.h>
extern "C" {
#include <libavcodec/jni.h>
}
#include "log.h"
#include "jni_utils.h"
#include "event.h"
#define ARRAYLEN(a) (sizeof(a)/sizeof(a[0]))
extern "C" {
jni_func(void, create, jobject appctx);
jni_func(void, init);
jni_func(void, destroy);
jni_func(void, command, jobjectArray jarray);
};
JavaVM *g_vm;
mpv_handle *g_mpv;
std::atomic<bool> g_event_thread_request_exit(false);
static pthread_t event_thread_id;
static void prepare_environment(JNIEnv *env, jobject appctx) {
setlocale(LC_NUMERIC, "C");
if (!env->GetJavaVM(&g_vm) && g_vm)
av_jni_set_java_vm(g_vm, NULL);
jobject global_appctx = env->NewGlobalRef(appctx);
if (global_appctx)
av_jni_set_android_app_ctx(global_appctx, NULL);
init_methods_cache(env);
}
jni_func(void, create, jobject appctx) {
prepare_environment(env, appctx);
if (g_mpv)
die("mpv is already initialized");
g_mpv = mpv_create();
if (!g_mpv)
die("context init failed");
// use terminal log level but request verbose messages
// this way --msg-level can be used to adjust later
mpv_request_log_messages(g_mpv, "terminal-default");
mpv_set_option_string(g_mpv, "msg-level", "all=v");
}
jni_func(void, init) {
if (!g_mpv)
die("mpv is not created");
if (mpv_initialize(g_mpv) < 0)
die("mpv init failed");
g_event_thread_request_exit = false;
if (pthread_create(&event_thread_id, NULL, event_thread, NULL) != 0)
die("thread create failed");
pthread_setname_np(event_thread_id, "event_thread");
}
jni_func(void, destroy) {
if (!g_mpv) {
ALOGV("mpv destroy called but it's already destroyed");
return;
}
// poke event thread and wait for it to exit
g_event_thread_request_exit = true;
mpv_wakeup(g_mpv);
pthread_join(event_thread_id, NULL);
mpv_terminate_destroy(g_mpv);
g_mpv = NULL;
}
jni_func(void, command, jobjectArray jarray) {
CHECK_MPV_INIT();
const char *arguments[128] = {0};
int len = env->GetArrayLength(jarray);
if (len >= ARRAYLEN(arguments))
die("too many command arguments");
for (int i = 0; i < len; ++i)
arguments[i] = env->GetStringUTFChars((jstring)env->GetObjectArrayElement(jarray, i), NULL);
mpv_command(g_mpv, arguments);
for (int i = 0; i < len; ++i)
env->ReleaseStringUTFChars((jstring)env->GetObjectArrayElement(jarray, i), arguments[i]);
}

View file

@ -0,0 +1,125 @@
#include <jni.h>
#include <stdlib.h>
#include <mpv/client.h>
#include "jni_utils.h"
#include "log.h"
#include "globals.h"
extern "C" {
jni_func(jint, setOptionString, jstring option, jstring value);
jni_func(jobject, getPropertyInt, jstring property);
jni_func(void, setPropertyInt, jstring property, jint value);
jni_func(jobject, getPropertyDouble, jstring property);
jni_func(void, setPropertyDouble, jstring property, jdouble value);
jni_func(jobject, getPropertyBoolean, jstring property);
jni_func(void, setPropertyBoolean, jstring property, jboolean value);
jni_func(jstring, getPropertyString, jstring jproperty);
jni_func(void, setPropertyString, jstring jproperty, jstring jvalue);
jni_func(void, observeProperty, jstring property, jint format);
}
jni_func(jint, setOptionString, jstring joption, jstring jvalue) {
CHECK_MPV_INIT();
const char *option = env->GetStringUTFChars(joption, NULL);
const char *value = env->GetStringUTFChars(jvalue, NULL);
int result = mpv_set_option_string(g_mpv, option, value);
env->ReleaseStringUTFChars(joption, option);
env->ReleaseStringUTFChars(jvalue, value);
return result;
}
static int common_get_property(JNIEnv *env, jstring jproperty, mpv_format format, void *output)
{
CHECK_MPV_INIT();
const char *prop = env->GetStringUTFChars(jproperty, NULL);
int result = mpv_get_property(g_mpv, prop, format, output);
if (result == MPV_ERROR_PROPERTY_UNAVAILABLE)
ALOGV("mpv_get_property(%s) format %d was unavailable", prop, format);
else if (result < 0)
ALOGE("mpv_get_property(%s) format %d returned error %s", prop, format, mpv_error_string(result));
env->ReleaseStringUTFChars(jproperty, prop);
return result;
}
static int common_set_property(JNIEnv *env, jstring jproperty, mpv_format format, void *value)
{
CHECK_MPV_INIT();
const char *prop = env->GetStringUTFChars(jproperty, NULL);
int result = mpv_set_property(g_mpv, prop, format, value);
if (result < 0)
ALOGE("mpv_set_property(%s, %p) format %d returned error %s", prop, value, format, mpv_error_string(result));
env->ReleaseStringUTFChars(jproperty, prop);
return result;
}
jni_func(jobject, getPropertyInt, jstring jproperty) {
int64_t value = 0;
if (common_get_property(env, jproperty, MPV_FORMAT_INT64, &value) < 0)
return NULL;
return env->NewObject(java_Integer, java_Integer_init, (jint)value);
}
jni_func(jobject, getPropertyDouble, jstring jproperty) {
double value = 0;
if (common_get_property(env, jproperty, MPV_FORMAT_DOUBLE, &value) < 0)
return NULL;
return env->NewObject(java_Double, java_Double_init, (jdouble)value);
}
jni_func(jobject, getPropertyBoolean, jstring jproperty) {
int value = 0;
if (common_get_property(env, jproperty, MPV_FORMAT_FLAG, &value) < 0)
return NULL;
return env->NewObject(java_Boolean, java_Boolean_init, (jboolean)value);
}
jni_func(jstring, getPropertyString, jstring jproperty) {
char *value;
if (common_get_property(env, jproperty, MPV_FORMAT_STRING, &value) < 0)
return NULL;
jstring jvalue = env->NewStringUTF(value);
mpv_free(value);
return jvalue;
}
jni_func(void, setPropertyInt, jstring jproperty, jint jvalue) {
int64_t value = static_cast<int64_t>(jvalue);
common_set_property(env, jproperty, MPV_FORMAT_INT64, &value);
}
jni_func(void, setPropertyDouble, jstring jproperty, jdouble jvalue) {
double value = static_cast<double>(jvalue);
common_set_property(env, jproperty, MPV_FORMAT_DOUBLE, &value);
}
jni_func(void, setPropertyBoolean, jstring jproperty, jboolean jvalue) {
int value = jvalue == JNI_TRUE ? 1 : 0;
common_set_property(env, jproperty, MPV_FORMAT_FLAG, &value);
}
jni_func(void, setPropertyString, jstring jproperty, jstring jvalue) {
const char *value = env->GetStringUTFChars(jvalue, NULL);
common_set_property(env, jproperty, MPV_FORMAT_STRING, &value);
env->ReleaseStringUTFChars(jvalue, value);
}
jni_func(void, observeProperty, jstring property, jint format) {
CHECK_MPV_INIT();
const char *prop = env->GetStringUTFChars(property, NULL);
int result = mpv_observe_property(g_mpv, 0, prop, (mpv_format)format);
if (result < 0)
ALOGE("mpv_observe_property(%s) format %d returned error %s", prop, format, mpv_error_string(result));
env->ReleaseStringUTFChars(property, prop);
}

View file

@ -0,0 +1,38 @@
#include <jni.h>
#include <mpv/client.h>
#include "jni_utils.h"
#include "log.h"
#include "globals.h"
extern "C" {
jni_func(void, attachSurface, jobject surface_);
jni_func(void, detachSurface);
};
static jobject surface;
jni_func(void, attachSurface, jobject surface_) {
CHECK_MPV_INIT();
surface = env->NewGlobalRef(surface_);
if (!surface)
die("invalid surface provided");
int64_t wid = reinterpret_cast<intptr_t>(surface);
int result = mpv_set_option(g_mpv, "wid", MPV_FORMAT_INT64, &wid);
if (result < 0)
ALOGE("mpv_set_option(wid) returned error %s", mpv_error_string(result));
}
jni_func(void, detachSurface) {
CHECK_MPV_INIT();
int64_t wid = 0;
int result = mpv_set_option(g_mpv, "wid", MPV_FORMAT_INT64, &wid);
if (result < 0)
ALOGE("mpv_set_option(wid) returned error %s", mpv_error_string(result));
env->DeleteGlobalRef(surface);
surface = NULL;
}

View file

@ -0,0 +1,133 @@
#include <stdlib.h>
#include <string>
#include <jni.h>
#include <android/bitmap.h>
#include <mpv/client.h>
extern "C" {
#include <libswscale/swscale.h>
};
#include "jni_utils.h"
#include "globals.h"
#include "log.h"
extern "C" {
jni_func(jobject, grabThumbnail, jint dimension);
};
static inline mpv_node make_node_str(const char *s)
{
mpv_node r{};
r.format = MPV_FORMAT_STRING;
r.u.string = const_cast<char*>(s);
return r;
}
jni_func(jobject, grabThumbnail, jint dimension) {
CHECK_MPV_INIT();
mpv_node result{};
{
mpv_node c{}, c_args[2];
mpv_node_list c_array{};
c_args[0] = make_node_str("screenshot-raw");
c_args[1] = make_node_str("video");
c_array.num = 2;
c_array.values = c_args;
c.format = MPV_FORMAT_NODE_ARRAY;
c.u.list = &c_array;
if (mpv_command_node(g_mpv, &c, &result) < 0) {
ALOGE("screenshot-raw command failed");
return NULL;
}
}
// extract relevant property data from the node map mpv returns
int w = 0, h = 0, stride = 0;
bool format_ok = false;
struct mpv_byte_array *data = NULL;
do {
if (result.format != MPV_FORMAT_NODE_MAP)
break;
for (int i = 0; i < result.u.list->num; i++) {
std::string key(result.u.list->keys[i]);
const mpv_node *val = &result.u.list->values[i];
if (key == "w" || key == "h" || key == "stride") {
if (val->format != MPV_FORMAT_INT64)
break;
if (key == "w")
w = val->u.int64;
else if (key == "h")
h = val->u.int64;
else
stride = val->u.int64;
} else if (key == "format") {
if (val->format != MPV_FORMAT_STRING)
break;
format_ok = !strcmp(val->u.string, "bgr0");
} else if (key == "data") {
if (val->format != MPV_FORMAT_BYTE_ARRAY)
break;
data = val->u.ba;
}
}
} while (0);
if (!w || !h || !stride || !format_ok || !data) {
ALOGE("extracting data failed");
mpv_free_node_contents(&result);
return NULL;
}
ALOGV("screenshot w:%d h:%d stride:%d", w, h, stride);
// crop to square
int crop_left = 0, crop_top = 0;
int new_w = w, new_h = h;
if (w > h) {
crop_left = (w - h) / 2;
new_w = h;
} else {
crop_top = (h - w) / 2;
new_h = w;
}
ALOGV("cropped w:%u h:%u", new_w, new_h);
uint8_t *new_data = reinterpret_cast<uint8_t*>(data->data);
new_data += crop_left * sizeof(uint32_t); // move begin rightwards
new_data += stride * crop_top; // move begin downwards
// convert & scale to appropriate size
struct SwsContext *ctx = sws_getContext(
new_w, new_h, AV_PIX_FMT_BGR0,
dimension, dimension, AV_PIX_FMT_RGB32,
SWS_BICUBIC, NULL, NULL, NULL);
if (!ctx) {
mpv_free_node_contents(&result);
return NULL;
}
jintArray arr = env->NewIntArray(dimension * dimension);
jint *scaled = env->GetIntArrayElements(arr, NULL);
uint8_t *src_p[4] = { new_data }, *dst_p[4] = { (uint8_t*) scaled };
int src_stride[4] = { stride },
dst_stride[4] = { (int) sizeof(jint) * dimension };
sws_scale(ctx, src_p, src_stride, 0, new_h, dst_p, dst_stride);
sws_freeContext(ctx);
mpv_free_node_contents(&result); // frees data->data
// create android.graphics.Bitmap
env->ReleaseIntArrayElements(arr, scaled, 0);
jobject bitmap_config =
env->GetStaticObjectField(android_graphics_Bitmap_Config, android_graphics_Bitmap_Config_ARGB_8888);
jobject bitmap =
env->CallStaticObjectMethod(android_graphics_Bitmap, android_graphics_Bitmap_createBitmap,
arr, dimension, dimension, bitmap_config);
env->DeleteLocalRef(arr);
env->DeleteLocalRef(bitmap_config);
return bitmap;
}

View file

@ -29,6 +29,15 @@ enum MediaExtensionStatus{
MES_DISABLED = 2; MES_DISABLED = 2;
} }
enum PlayerBackend{
EXO_PLAYER = 0;
MPV = 1;
}
message MpvOptions{
bool enable_hardware_decoding = 1;
}
message PlaybackOverrides{ message PlaybackOverrides{
bool ac3_supported = 1; bool ac3_supported = 1;
bool downmix_stereo = 2; bool downmix_stereo = 2;
@ -59,6 +68,8 @@ message PlaybackPreferences {
PrefContentScale global_content_scale = 17; PrefContentScale global_content_scale = 17;
ShowNextUpWhen show_next_up_when = 18; ShowNextUpWhen show_next_up_when = 18;
bool one_click_pause = 19; bool one_click_pause = 19;
PlayerBackend player_backend = 20;
MpvOptions mpv_options = 21;
} }
message HomePagePreferences{ message HomePagePreferences{

View file

@ -59,6 +59,12 @@
<string name="reset">Reset</string> <string name="reset">Reset</string>
<string name="bold_font">Bold font</string> <string name="bold_font">Bold font</string>
<string name="player_backend">Playback Backend</string>
<string name="mpv_hardware_decoding">MPV: Use hardware decoding</string>
<string name="disable_if_crash">Disable if you experience crashes</string>
<string name="mpv_options">MPV Options</string>
<string name="exoplayer_options">ExoPlayer Options</string>
<string-array name="theme_song_volume"> <string-array name="theme_song_volume">
<item>Disabled</item> <item>Disabled</item>
<item>Lowest</item> <item>Lowest</item>
@ -126,4 +132,8 @@
<item>Boxed</item> <item>Boxed</item>
</string-array> </string-array>
<string-array name="player_backend_options">
<item>ExoPlayer (default)</item>
<item>MPV (Experimental)</item>
</string-array>
</resources> </resources>

3
scripts/ffmpeg/README.md Normal file
View file

@ -0,0 +1,3 @@
# FFmpeg ExoPlayer decoder extension module
Builds the ffmpeg decoder extension module for `ExoPlayer` which supports extra codecs during playback.

View file

@ -7,6 +7,10 @@ if [ -z "$1" ]; then
fi fi
NDK_PATH="$1" NDK_PATH="$1"
SCRIPT_PATH="$(realpath "${BASH_SOURCE[0]}")"
SCRIPT_DIR="$(dirname "${SCRIPT_PATH}")"
PROJECT_ROOT="$(realpath "${SCRIPT_DIR}/../../")"
# Config # Config
ANDROID_ABI=21 ANDROID_ABI=21
ENABLED_DECODERS=(dca ac3 eac3 mlp truehd) ENABLED_DECODERS=(dca ac3 eac3 mlp truehd)
@ -14,8 +18,8 @@ FFMPEG_BRANCH="release/6.0"
# Path configs # Path configs
DIR_PATH="$(pwd)" DIR_PATH="$(pwd)"
TARGET_PATH="$DIR_PATH/app/libs" TARGET_PATH="$PROJECT_ROOT/app/libs"
MEDIA_PATH="$DIR_PATH//ffmpeg_decoder/media" MEDIA_PATH="$DIR_PATH/ffmpeg_decoder/media"
FFMPEG_MODULE_PATH="$MEDIA_PATH/libraries/decoder_ffmpeg/src/main" FFMPEG_MODULE_PATH="$MEDIA_PATH/libraries/decoder_ffmpeg/src/main"
FFMPEG_PATH="$DIR_PATH/ffmpeg_decoder/ffmpeg" FFMPEG_PATH="$DIR_PATH/ffmpeg_decoder/ffmpeg"
HOST="$(uname -s | tr '[:upper:]' '[:lower:]')" HOST="$(uname -s | tr '[:upper:]' '[:lower:]')"
@ -24,7 +28,9 @@ HOST_PLATFORM="$HOST-x86_64"
mkdir -p "$TARGET_PATH" mkdir -p "$TARGET_PATH"
mkdir -p ffmpeg_decoder mkdir -p ffmpeg_decoder
media_version="$(grep "androidx-media3 = " gradle/libs.versions.toml | awk -F'"' '{print $2}')" echo "$PROJECT_ROOT/gradle/libs.versions.toml"
media_version="$(grep "androidx-media3 = " "$PROJECT_ROOT/gradle/libs.versions.toml" | awk -F'"' '{print $2}')"
pushd ffmpeg_decoder || exit pushd ffmpeg_decoder || exit

7
scripts/mpv/README.md Normal file
View file

@ -0,0 +1,7 @@
# MPV build scripts
This scripts are adapted from https://github.com/mpv-android/mpv-android/tree/ae0d956c5a98ab8bf25af7e2c73bcb59e19c15b7/buildscripts licensed MIT.
## Instructions
TODO

180
scripts/mpv/buildall.sh Executable file
View file

@ -0,0 +1,180 @@
#!/bin/bash -e
cd "$( dirname "${BASH_SOURCE[0]}" )"
. ./include/depinfo.sh
cleanbuild=0
nodeps=0
clang=1
target=mpv-android
arch=armv7l
getdeps () {
varname="dep_${1//-/_}[*]"
echo ${!varname}
}
loadarch () {
unset CC CXX CPATH LIBRARY_PATH C_INCLUDE_PATH CPLUS_INCLUDE_PATH
unset CFLAGS CXXFLAGS CPPFLAGS LDFLAGS
local apilvl=21
# ndk_triple: what the toolchain actually is
# cc_triple: what Google pretends the toolchain is
if [ "$1" == "armv7l" ]; then
export ndk_suffix=
export ndk_triple=arm-linux-androideabi
cc_triple=armv7a-linux-androideabi$apilvl
prefix_name=armv7l
elif [ "$1" == "arm64" ]; then
export ndk_suffix=-arm64
export ndk_triple=aarch64-linux-android
cc_triple=$ndk_triple$apilvl
prefix_name=arm64
elif [ "$1" == "x86" ]; then
export ndk_suffix=-x86
export ndk_triple=i686-linux-android
cc_triple=$ndk_triple$apilvl
prefix_name=x86
elif [ "$1" == "x86_64" ]; then
export ndk_suffix=-x64
export ndk_triple=x86_64-linux-android
cc_triple=$ndk_triple$apilvl
prefix_name=x86_64
else
echo "Invalid architecture" >&2
exit 1
fi
export prefix_dir="$PWD/prefix/$prefix_name"
if [ $clang -eq 1 ]; then
export CC=$cc_triple-clang
export CXX=$cc_triple-clang++
else
export CC=$cc_triple-gcc
export CXX=$cc_triple-g++
fi
export LDFLAGS="-Wl,-O1,--icf=safe -Wl,-z,max-page-size=16384"
export AR=llvm-ar
export RANLIB=llvm-ranlib
}
setup_prefix () {
if [ ! -d "$prefix_dir" ]; then
mkdir -p "$prefix_dir"
# enforce flat structure (/usr/local -> /)
ln -s . "$prefix_dir/usr"
ln -s . "$prefix_dir/local"
fi
local cpu_family=${ndk_triple%%-*}
[ "$cpu_family" == "i686" ] && cpu_family=x86
if ! command -v pkg-config >/dev/null; then
echo "pkg-config not provided!"
return 1
fi
# meson wants to be spoonfed this file, so create it ahead of time
# also define: release build, static libs and no source downloads at runtime(!!!)
cat >"$prefix_dir/crossfile.tmp" <<CROSSFILE
[built-in options]
buildtype = 'release'
default_library = 'static'
wrap_mode = 'nodownload'
prefix = '/usr/local'
[binaries]
c = '$CC'
cpp = '$CXX'
ar = 'llvm-ar'
nm = 'llvm-nm'
strip = 'llvm-strip'
pkgconfig = 'pkg-config'
pkg-config = 'pkg-config'
[host_machine]
system = 'android'
cpu_family = '$cpu_family'
cpu = '${CC%%-*}'
endian = 'little'
CROSSFILE
# also avoid rewriting it needlessly
if cmp -s "$prefix_dir"/crossfile.{tmp,txt}; then
rm "$prefix_dir/crossfile.tmp"
else
mv "$prefix_dir"/crossfile.{tmp,txt}
fi
}
build () {
if [ $1 != "mpv-android" ] && [ ! -d deps/$1 ]; then
printf >&2 '\e[1;31m%s\e[m\n' "Target $1 not found"
return 1
fi
if [ $nodeps -eq 0 ]; then
printf >&2 '\e[1;34m%s\e[m\n' "Preparing $1..."
local deps=$(getdeps $1)
echo >&2 "Dependencies: $deps"
for dep in $deps; do
build $dep
done
fi
printf >&2 '\e[1;34m%s\e[m\n' "Building $1..."
if [ "$1" == "mpv-android" ]; then
pushd ..
BUILDSCRIPT=buildscripts/scripts/$1.sh
else
pushd deps/$1
BUILDSCRIPT=../../scripts/$1.sh
fi
[ $cleanbuild -eq 1 ] && $BUILDSCRIPT clean
$BUILDSCRIPT build
popd
}
usage () {
printf '%s\n' \
"Usage: buildall.sh [options] [target]" \
"Builds the specified target (default: $target)" \
"-n Do not build dependencies" \
"--clean Clean build dirs before compiling" \
"--gcc Use gcc compiler (unsupported!)" \
"--arch <arch> Build for specified architecture (default: $arch; supported: armv7l, arm64, x86, x86_64)"
exit 0
}
while [ $# -gt 0 ]; do
case "$1" in
--clean)
cleanbuild=1
;;
-n|--no-deps)
nodeps=1
;;
--gcc)
clang=0
;;
--arch)
shift
arch=$1
;;
-h|--help)
usage
;;
-*)
echo "Unknown flag $1" >&2
exit 1
;;
*)
target=$1
;;
esac
shift
done
loadarch $arch
setup_prefix
build $target
[ "$target" == "mpv-android" ] && \
ls -lh ../app/build/outputs/apk/{default,api29}/*/*.apk
exit 0

70
scripts/mpv/get_dependencies.sh Executable file
View file

@ -0,0 +1,70 @@
#!/usr/bin/env bash
set -exou pipefail
BUILD_PATH="./deps"
mkdir -p "$BUILD_PATH"
pushd "$BUILD_PATH" || exit
function clone(){
repo=$1
branch=$2
dir=$3
shift 3
if [[ -d "$dir" ]]; then
pushd "$dir" || exit
git checkout --force "$branch"
popd || exit
else
git clone "$repo" --depth 1 --single-branch -b "$branch" "$dir" "$@"
fi
}
clone "https://github.com/videolan/dav1d" "1.5.2" dav1d
clone "https://github.com/FFmpeg/FFmpeg" "n8.0" ffmpeg
clone "https://gitlab.freedesktop.org/freetype/freetype.git" "VER-2-14-1" freetype2 --recurse-submodules
clone "https://github.com/libass/libass" "master" libass
clone "https://github.com/haasn/libplacebo" "master" libplacebo --recurse-submodules
clone "https://github.com/mpv-player/mpv" "master" mpv
if [[ ! -d mbedtls ]]; then
mkdir mbedtls
wget https://github.com/Mbed-TLS/mbedtls/releases/download/mbedtls-3.6.4/mbedtls-3.6.4.tar.bz2 -O - | \
tar -xj -C mbedtls --strip-components=1
fi
if [[ ! -d fribidi ]]; then
mkdir fribidi
wget https://github.com/fribidi/fribidi/releases/download/v1.0.16/fribidi-1.0.16.tar.xz -O - | \
tar -xJ -C fribidi --strip-components=1
fi
if [[ ! -d harfbuzz ]]; then
mkdir harfbuzz
wget https://github.com/harfbuzz/harfbuzz/releases/download/12.1.0/harfbuzz-12.1.0.tar.xz -O - | \
tar -xJ -C harfbuzz --strip-components=1
fi
version_unibreak="6.1"
if [[ ! -d unibreak ]]; then
mkdir unibreak
wget https://github.com/adah1972/libunibreak/releases/download/libunibreak_${version_unibreak//./_}/libunibreak-${version_unibreak}.tar.gz -O - | \
tar -xz -C unibreak --strip-components=1
fi
if [[ ! -d lua ]]; then
mkdir lua
wget https://www.lua.org/ftp/lua-5.2.4.tar.gz -O - | \
tar -xz -C lua --strip-components=1
fi
# python packages: jsonschema jinja2 meson
popd || exit

91
scripts/mpv/include/ci.sh Executable file
View file

@ -0,0 +1,91 @@
#!/bin/bash -e
# go to buildscripts root folder
cd "$( dirname "${BASH_SOURCE[0]}" )/.."
. ./include/depinfo.sh
msg() {
printf '==> %s\n' "$1"
}
fetch_prefix() {
if [[ "$CACHE_MODE" == folder ]]; then
local text=
if [ -f "$CACHE_FOLDER/id.txt" ]; then
text=$(cat "$CACHE_FOLDER/id.txt")
else
echo "Cache seems to be empty"
fi
printf 'Expecting "%s",\nfound "%s".\n' "$ci_tarball" "$text"
if [[ "$text" == "$ci_tarball" ]]; then
tar -xzf "$CACHE_FOLDER/data.tgz" -C prefix && return 0
fi
fi
return 1
}
build_prefix() {
msg "Building the prefix ($ci_tarball)..."
msg "Fetching deps"
IN_CI=1 ./include/download-deps.sh
# build everything mpv depends on (but not mpv itself)
for x in ${dep_mpv[@]}; do
msg "Building $x"
./buildall.sh $x
done
if [[ "$CACHE_MODE" == folder && -w "$CACHE_FOLDER" ]]; then
msg "Compressing the prefix"
tar -cvzf "$CACHE_FOLDER/data.tgz" -C prefix .
echo "$ci_tarball" >"$CACHE_FOLDER/id.txt"
fi
}
export WGET="wget --progress=bar:force"
if [ "$1" = "export" ]; then
# export variable with unique cache identifier
echo "CACHE_IDENTIFIER=$ci_tarball"
exit 0
elif [ "$1" = "install" ]; then
# install deps
if [[ -n "$ANDROID_HOME" && -d "$ANDROID_HOME" ]]; then
msg "Linking existing SDK"
mkdir -p sdk
ln -sv "$ANDROID_HOME" sdk/android-sdk-linux
fi
msg "Fetching SDK + NDK"
IN_CI=1 ./include/download-sdk.sh
msg "Fetching mpv"
mkdir -p deps/mpv
$WGET https://github.com/mpv-player/mpv/archive/master.tar.gz -O master.tgz
tar -xzf master.tgz -C deps/mpv --strip-components=1
rm master.tgz
msg "Trying to fetch existing prefix"
mkdir -p prefix
fetch_prefix || build_prefix
exit 0
elif [ "$1" = "build" ]; then
# run build
:
else
exit 1
fi
msg "Building mpv"
./buildall.sh -n mpv || {
# show logfile if configure failed
[ ! -f deps/mpv/_build/config.h ] && cat deps/mpv/_build/meson-logs/meson-log.txt
exit 1
}
msg "Building mpv-android"
./buildall.sh -n
exit 0

43
scripts/mpv/include/depinfo.sh Executable file
View file

@ -0,0 +1,43 @@
#!/bin/bash -e
## Dependency versions
# Make sure to keep v_ndk and v_ndk_n in sync, both are listed on the NDK download page
v_sdk=11076708_latest
v_ndk=r29
v_ndk_n=29.0.14206865
v_sdk_platform=35
v_sdk_build_tools=35.0.0
v_lua=5.2.4
v_unibreak=6.1
v_harfbuzz=12.1.0
v_fribidi=1.0.16
v_freetype=2.14.1
v_mbedtls=3.6.4
## Dependency tree
# I would've used a dict but putting arrays in a dict is not a thing
dep_mbedtls=()
dep_dav1d=()
dep_ffmpeg=(mbedtls dav1d)
dep_freetype2=()
dep_fribidi=()
dep_harfbuzz=()
dep_unibreak=()
dep_libass=(freetype2 fribidi harfbuzz unibreak)
dep_lua=()
dep_libplacebo=()
dep_mpv=(ffmpeg libass lua libplacebo)
dep_mpv_android=(mpv)
## for CI workflow
# pinned ffmpeg revision
v_ci_ffmpeg=n8.0
# filename used to uniquely identify a build prefix
ci_tarball="prefix-ndk-${v_ndk}-lua-${v_lua}-unibreak-${v_unibreak}-harfbuzz-${v_harfbuzz}-fribidi-${v_fribidi}-freetype-${v_freetype}-mbedtls-${v_mbedtls}-ffmpeg-${v_ci_ffmpeg}.tgz"

32
scripts/mpv/include/path.sh Executable file
View file

@ -0,0 +1,32 @@
#!/bin/bash
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && cd .. && pwd )"
. "$DIR/include/depinfo.sh"
os=linux
[[ "$OSTYPE" == "darwin"* ]] && os=mac
export os
if [ "$os" == "mac" ]; then
[ -z "$cores" ] && cores=$(sysctl -n hw.ncpu)
# various things rely on GNU behaviour
export INSTALL=`which ginstall`
export SED=gsed
else
[ -z "$cores" ] && cores=$(grep -c ^processor /proc/cpuinfo)
fi
cores=${cores:-4}
# configure pkg-config paths if inside buildscripts
if [ -n "$ndk_triple" ]; then
export PKG_CONFIG_SYSROOT_DIR="$prefix_dir"
export PKG_CONFIG_LIBDIR="$PKG_CONFIG_SYSROOT_DIR/lib/pkgconfig"
unset PKG_CONFIG_PATH
fi
toolchain=$(echo "$DIR/sdk/android-ndk-${v_ndk}/toolchains/llvm/prebuilt/"*)
[ -d "$toolchain" ] && \
export PATH="$toolchain/bin:$DIR/sdk/android-ndk-${v_ndk}:$DIR/sdk/bin:$PATH"
export ANDROID_HOME="$DIR/sdk/android-sdk-$os"
unset ANDROID_SDK_ROOT ANDROID_NDK_ROOT

22
scripts/mpv/scripts/dav1d.sh Executable file
View file

@ -0,0 +1,22 @@
#!/bin/bash -e
. ../../include/path.sh
build=_build$ndk_suffix
if [ "$1" == "build" ]; then
true
elif [ "$1" == "clean" ]; then
rm -rf $build
exit 0
else
exit 255
fi
unset CC CXX # meson wants these unset
meson setup $build --cross-file "$prefix_dir"/crossfile.txt \
-Denable_tests=false -Db_lto=true -Dstack_alignment=16
ninja -C $build -j$cores
DESTDIR="$prefix_dir" ninja -C $build install

48
scripts/mpv/scripts/ffmpeg.sh Executable file
View file

@ -0,0 +1,48 @@
#!/bin/bash -e
. ../../include/path.sh
if [ "$1" == "build" ]; then
true
elif [ "$1" == "clean" ]; then
rm -rf _build$ndk_suffix
exit 0
else
exit 255
fi
mkdir -p _build$ndk_suffix
cd _build$ndk_suffix
cpu=armv7-a
[[ "$ndk_triple" == "aarch64"* ]] && cpu=armv8-a
[[ "$ndk_triple" == "x86_64"* ]] && cpu=generic
[[ "$ndk_triple" == "i686"* ]] && cpu="i686 --disable-asm"
cpuflags=
[[ "$ndk_triple" == "arm"* ]] && cpuflags="$cpuflags -mfpu=neon -mcpu=cortex-a8"
args=(
--target-os=android --enable-cross-compile
--cross-prefix=$ndk_triple- --cc=$CC --pkg-config=pkg-config --nm=llvm-nm
--arch=${ndk_triple%%-*} --cpu=$cpu
--extra-cflags="-I$prefix_dir/include $cpuflags" --extra-ldflags="-L$prefix_dir/lib"
--enable-{jni,mediacodec,mbedtls,libdav1d} --disable-vulkan
--disable-static --enable-shared --enable-{gpl,version3}
# disable unneeded parts
--disable-{stripping,doc,programs}
# to keep the build lean we disable some feature quite aggressively:
# - muxers, encoders: mpv-android does not have any way to use these
# - devices: no practical use on Android
--disable-{muxers,encoders,devices}
# useful to taking screenshots
--enable-encoder=mjpeg,png
# useful for the `dump-cache` command
--enable-muxer=mov,matroska,mpegts
)
../configure "${args[@]}"
make -j$cores
make DESTDIR="$prefix_dir" install

View file

@ -0,0 +1,21 @@
#!/bin/bash -e
. ../../include/path.sh
build=_build$ndk_suffix
if [ "$1" == "build" ]; then
true
elif [ "$1" == "clean" ]; then
rm -rf $build
exit 0
else
exit 255
fi
unset CC CXX # meson wants these unset
meson setup $build --cross-file "$prefix_dir"/crossfile.txt
ninja -C $build -j$cores
DESTDIR="$prefix_dir" ninja -C $build install

22
scripts/mpv/scripts/fribidi.sh Executable file
View file

@ -0,0 +1,22 @@
#!/bin/bash -e
. ../../include/path.sh
build=_build$ndk_suffix
if [ "$1" == "build" ]; then
true
elif [ "$1" == "clean" ]; then
rm -rf $build
exit 0
else
exit 255
fi
unset CC CXX # meson wants these unset
meson setup $build --cross-file "$prefix_dir"/crossfile.txt \
-D{tests,docs}=false
ninja -C $build -j$cores
DESTDIR="$prefix_dir" ninja -C $build install

22
scripts/mpv/scripts/harfbuzz.sh Executable file
View file

@ -0,0 +1,22 @@
#!/bin/bash -e
. ../../include/path.sh
build=_build$ndk_suffix
if [ "$1" == "build" ]; then
true
elif [ "$1" == "clean" ]; then
rm -rf $build
exit 0
else
exit 255
fi
unset CC CXX # meson wants these unset
meson setup $build --cross-file "$prefix_dir"/crossfile.txt \
-Dtests=disabled -Ddocs=disabled
ninja -C $build -j$cores
DESTDIR="$prefix_dir" ninja -C $build install

25
scripts/mpv/scripts/libass.sh Executable file
View file

@ -0,0 +1,25 @@
#!/bin/bash -e
. ../../include/path.sh
if [ "$1" == "build" ]; then
true
elif [ "$1" == "clean" ]; then
rm -rf _build$ndk_suffix
exit 0
else
exit 255
fi
[ -f configure ] || ./autogen.sh
mkdir -p _build$ndk_suffix
cd _build$ndk_suffix
../configure \
--host=$ndk_triple --with-pic \
--enable-static --disable-shared \
--enable-libunibreak --disable-require-system-font-provider
make -j$cores
make DESTDIR="$prefix_dir" install

View file

@ -0,0 +1,25 @@
#!/bin/bash -e
. ../../include/path.sh
build=_build$ndk_suffix
if [ "$1" == "build" ]; then
true
elif [ "$1" == "clean" ]; then
rm -rf $build
exit 0
else
exit 255
fi
unset CC CXX
meson setup $build --cross-file "$prefix_dir"/crossfile.txt \
-Dvulkan=disabled -Ddemos=false
ninja -C $build -j$cores
DESTDIR="$prefix_dir" ninja -C $build install
# add missing library for static linking
# this isn't "-lstdc++" due to a meson bug: https://github.com/mesonbuild/meson/issues/11300
${SED:-sed} '/^Libs:/ s|$| -lc++|' "$prefix_dir/lib/pkgconfig/libplacebo.pc" -i

44
scripts/mpv/scripts/lua.sh Executable file
View file

@ -0,0 +1,44 @@
#!/bin/bash -e
. ../../include/path.sh
if [ "$1" == "build" ]; then
true
elif [ "$1" == "clean" ]; then
make clean
exit 0
else
exit 255
fi
# Building seperately from source tree is not supported, this means we are forced to always clean
$0 clean
mycflags=(
# ensures correct linking into libmpv.so
-fPIC
# bionic is missing decimal_point in localeconv [src/llex.c]
-Dgetlocaledecpoint\\\(\\\)=\\\(46\\\)
# force fallback as ftello/fseeko are not defined [src/liolib.c]
-Dlua_fseek
)
# LUA_T= and LUAC_T= to disable building lua & luac
# -Dgetlocaledecpoint()=('.') fixes bionic missing decimal_point in localeconv
make CC="$CC" AR="$AR rc" RANLIB="$RANLIB" \
MYCFLAGS="${mycflags[*]}" \
PLAT=linux LUA_T= LUAC_T= -j$cores
# TO_BIN=/dev/null disables installing lua & luac
make INSTALL=${INSTALL:-install} INSTALL_TOP="$prefix_dir" TO_BIN=/dev/null install
# make pc only generates a partial pkg-config file because ????
mkdir -p $prefix_dir/lib/pkgconfig
make pc >$prefix_dir/lib/pkgconfig/lua.pc
cat >>$prefix_dir/lib/pkgconfig/lua.pc <<'EOF'
Name: Lua
Description:
Version: ${version}
Libs: -L${libdir} -llua
Cflags: -I${includedir}
EOF

22
scripts/mpv/scripts/mbedtls.sh Executable file
View file

@ -0,0 +1,22 @@
#!/bin/bash -e
. ../../include/path.sh
if [ "$1" == "build" ]; then
true
elif [ "$1" == "clean" ]; then
make clean
exit 0
else
exit 255
fi
$0 clean # separate building not supported, always clean
if [[ "$ndk_triple" == "i686"* ]]; then
./scripts/config.py unset MBEDTLS_AESNI_C
else
./scripts/config.py set MBEDTLS_AESNI_C
fi
make -j$cores no_test
make DESTDIR="$prefix_dir" install

View file

@ -0,0 +1,77 @@
#!/bin/bash -e
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
BUILD="$DIR/.."
MPV_ANDROID="$DIR/../.."
. $BUILD/include/path.sh
. $BUILD/include/depinfo.sh
if [ "$1" == "build" ]; then
true
elif [ "$1" == "clean" ]; then
rm -rf $MPV_ANDROID/{app,.}/build $MPV_ANDROID/app/src/main/{libs,obj}
exit 0
else
exit 255
fi
[ -n "$ANDROID_SIGNING_KEY" ] && BUNDLE=1
nativeprefix () {
if [ -f $BUILD/prefix/$1/lib/libmpv.so ]; then
echo $BUILD/prefix/$1
else
echo >&2 "Warning: libmpv.so not found in native prefix for $1, support will be omitted"
fi
}
prefix32=$(nativeprefix "armv7l")
prefix64=$(nativeprefix "arm64")
prefix_x64=$(nativeprefix "x86_64")
prefix_x86=$(nativeprefix "x86")
if [[ -z "$prefix32" && -z "$prefix64" && -z "$prefix_x64" && -z "$prefix_x86" ]]; then
echo >&2 "Error: no mpv library detected."
exit 255
fi
PREFIX32=$prefix32 PREFIX64=$prefix64 PREFIX_X64=$prefix_x64 PREFIX_X86=$prefix_x86 \
ndk-build -C app/src/main -j$cores
targets=(assembleDebug)
if [ -z "$DONT_BUILD_RELEASE" ]; then
targets+=(assembleRelease)
[ -n "$BUNDLE" ] && targets+=(bundleRelease)
fi
./gradlew "${targets[@]}"
if [ -n "$ANDROID_SIGNING_KEY" ]; then
cd "${MPV_ANDROID}/app/build/outputs/apk"
apksigner=${ANDROID_HOME}/build-tools/${v_sdk_build_tools}/apksigner
for v in default api29; do
pushd $v
# sign the universal debug APK
"$apksigner" sign --ks "${ANDROID_SIGNING_KEY}" \
--in debug/app-$v-universal-debug.apk --out debug/app-$v-universal-debug-signed.apk
# but all of the release APKs
for apk in release/*-unsigned.apk; do
"$apksigner" sign --ks "${ANDROID_SIGNING_KEY}" \
--in $apk --out ${apk/-unsigned/-signed}
done
popd
done
# and the bundle
cd ../bundle
if [ -n "$BUNDLE" ]; then
if [ -z "$ANDROID_SIGNING_ALIAS" ]; then
echo >&2 "Error: ANDROID_SIGNING_ALIAS must be set to use jarsigner"
exit 1
fi
pushd defaultRelease
jarsigner -keystore "${ANDROID_SIGNING_KEY}" -signedjar \
app-default-release-signed.aab app-default-release.aab \
"${ANDROID_SIGNING_ALIAS}"
popd
fi
fi

30
scripts/mpv/scripts/mpv.sh Executable file
View file

@ -0,0 +1,30 @@
#!/bin/bash -e
. ../../include/path.sh
build=_build$ndk_suffix
if [ "$1" == "build" ]; then
true
elif [ "$1" == "clean" ]; then
rm -rf $build
exit 0
else
exit 255
fi
unset CC CXX # meson wants these unset
meson setup $build --cross-file "$prefix_dir"/crossfile.txt \
--default-library shared \
-Diconv=disabled -Dlua=enabled \
-Dlibmpv=true -Dcplayer=false \
-Dmanpage-build=disabled
ninja -C $build -j$cores
if [ -f $build/libmpv.a ]; then
echo >&2 "Meson fucked up, forcing rebuild."
$0 clean
exec $0 build
fi
DESTDIR="$prefix_dir" ninja -C $build install

24
scripts/mpv/scripts/unibreak.sh Executable file
View file

@ -0,0 +1,24 @@
#!/bin/bash -e
. ../../include/path.sh
build=_build$ndk_suffix
if [ "$1" == "build" ]; then
true
elif [ "$1" == "clean" ]; then
rm -rf $build
exit 0
else
exit 255
fi
mkdir -p $build
cd $build
../configure \
--host=$ndk_triple --with-pic \
--enable-static --disable-shared
make -j$cores
make DESTDIR="$prefix_dir" install