Compare commits

..
1 Commits
Author SHA1 Message Date
edde746 d3a35d56d9 chore: bump version 2025-11-08 20:00:04 +01:00
591 changed files with 19518 additions and 162834 deletions
-19
View File
@@ -1,19 +0,0 @@
.git
build/
.dart_tool/
.idea/
*.tar.gz
nul
libmpv-prefix/
steamfiles/
pubspec_overrides.yaml
# Flutter ephemeral / generated plugin symlinks
linux/flutter/ephemeral/
windows/flutter/
macos/Flutter/
ios/Flutter/
android/.gradle/
android/app/build/
.flutter-plugins
.flutter-plugins-dependencies
-3
View File
@@ -1,3 +0,0 @@
*.sh text eol=lf
*.Dockerfile text eol=lf
Dockerfile text eol=lf
-1
View File
@@ -1 +0,0 @@
github: edde746
-108
View File
@@ -1,108 +0,0 @@
name: Bug Report
description: Report a bug or unexpected behavior
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to report a bug! Please fill out the information below to help us diagnose and fix the issue.
- type: textarea
id: description
attributes:
label: Bug Description
description: A clear and concise description of what the bug is.
placeholder: What went wrong?
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to Reproduce
description: Steps to reproduce the behavior
placeholder: |
1. Go to '...'
2. Tap on '...'
3. Scroll down to '...'
4. See error
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected Behavior
description: What did you expect to happen?
placeholder: Describe what should have happened
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual Behavior
description: What actually happened?
placeholder: Describe what actually happened
validations:
required: true
- type: input
id: plezy-version
attributes:
label: Plezy Version
description: Check the app settings for the version number and paste it here
placeholder: e.g., 1.7.0
validations:
required: true
- type: dropdown
id: platform
attributes:
label: Platform
description: Which platform(s) are affected?
multiple: true
options:
- iOS
- Android
- Windows
- macOS
- Android TV
- Linux
validations:
required: true
- type: input
id: device
attributes:
label: Device Information
description: What device are you using?
placeholder: e.g., iPhone 14 Pro, Pixel 7, Chrome on macOS
- type: textarea
id: logs
attributes:
label: Logs and Stack Traces
description: Please paste any relevant logs or stack traces
value: |
<details>
<summary>Logs</summary>
```
Paste logs here
```
</details>
- type: textarea
id: screenshots
attributes:
label: Screenshots
description: If applicable, add screenshots to help explain your problem
placeholder: Drag and drop screenshots here
- type: textarea
id: additional
attributes:
label: Additional Context
description: Add any other context about the problem here
-5
View File
@@ -1,5 +0,0 @@
blank_issues_enabled: false
contact_links:
- name: Questions and Discussions
url: https://github.com/edde746/plezy/discussions
about: Ask questions and discuss ideas with the community
@@ -1,65 +0,0 @@
name: Feature Request
description: Suggest a new feature or enhancement
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
Thanks for suggesting a new feature! Please provide as much detail as possible.
- type: textarea
id: problem
attributes:
label: Problem Statement
description: Is your feature request related to a problem? Please describe.
placeholder: I'm frustrated when... / It would be helpful if...
validations:
required: true
- type: textarea
id: solution
attributes:
label: Proposed Solution
description: Describe the solution you'd like to see
placeholder: What feature would solve this problem?
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives Considered
description: Have you considered any alternative solutions or features?
placeholder: Describe alternatives you've considered
- type: textarea
id: use-case
attributes:
label: Use Case
description: Describe how you would use this feature
placeholder: |
1. As a user, I would...
2. This would help me...
3. The benefit would be...
- type: dropdown
id: platform
attributes:
label: Target Platform
description: Which platform(s) should this feature target?
multiple: true
options:
- iOS
- Android
- Windows
- macOS
- Android TV
- Linux
- All platforms
- type: textarea
id: additional
attributes:
label: Additional Context
description: Add any other context, mockups, or screenshots about the feature request
placeholder: Add mockups, links to similar features, or any other helpful context
+65 -461
View File
@@ -1,13 +1,8 @@
name: Build
name: Build Flutter App
on:
workflow_dispatch:
env:
SENTRY_DART_DEFINE: ${{ github.repository == 'edde746/plezy' && '--dart-define=ENABLE_SENTRY=true' || '' }}
GIT_COMMIT_DART_DEFINE: --dart-define=GIT_COMMIT=${{ github.sha }}
SENTRY_ENV_DART_DEFINE: --dart-define=SENTRY_ENVIRONMENT=github
jobs:
build-android:
runs-on: ubuntu-latest
@@ -62,35 +57,27 @@ jobs:
storeFile=upload-keystore.jks
EOF
- name: Build APKs
run: flutter build apk --release --split-per-abi --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }} ${{ env.GIT_COMMIT_DART_DEFINE }} ${{ env.SENTRY_ENV_DART_DEFINE }}
- name: Build APK
run: flutter build apk --release --dart-define=ENABLE_UPDATE_CHECK=true
- name: Clean up keystore
if: always()
run: rm -f android/app/upload-keystore.jks android/key.properties
- name: Package APKs
- name: Rename APK
run: |
tar -czf plezy-android-arm64-v8a.tar.gz -C build/app/outputs/flutter-apk app-arm64-v8a-release.apk --transform 's/app-arm64-v8a-release.apk/plezy.apk/'
tar -czf plezy-android-armeabi-v7a.tar.gz -C build/app/outputs/flutter-apk app-armeabi-v7a-release.apk --transform 's/app-armeabi-v7a-release.apk/plezy.apk/'
tar -czf plezy-android-x86_64.tar.gz -C build/app/outputs/flutter-apk app-x86_64-release.apk --transform 's/app-x86_64-release.apk/plezy.apk/'
cp build/app/outputs/flutter-apk/app-release.apk plezy-android.apk
- name: Attest APKs
- name: Attest APK
uses: actions/attest-build-provenance@v2
with:
subject-path: |
plezy-android-arm64-v8a.tar.gz
plezy-android-armeabi-v7a.tar.gz
plezy-android-x86_64.tar.gz
subject-path: plezy-android.apk
- name: Upload APKs
- name: Upload APK
uses: actions/upload-artifact@v4
with:
name: android-apk
path: |
plezy-android-arm64-v8a.tar.gz
plezy-android-armeabi-v7a.tar.gz
plezy-android-x86_64.tar.gz
path: plezy-android.apk
build-ios:
runs-on: macos-26
@@ -131,7 +118,7 @@ jobs:
run: flutter pub get
- name: Build iOS (no codesign)
run: flutter build ios --release --no-codesign --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }} ${{ env.GIT_COMMIT_DART_DEFINE }} ${{ env.SENTRY_ENV_DART_DEFINE }}
run: flutter build ios --release --no-codesign --dart-define=ENABLE_UPDATE_CHECK=true
- name: Create IPA
run: |
@@ -189,7 +176,7 @@ jobs:
run: flutter pub get
- name: Build macOS
run: flutter build macos --release --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }} ${{ env.GIT_COMMIT_DART_DEFINE }} ${{ env.SENTRY_ENV_DART_DEFINE }}
run: flutter build macos --release --dart-define=ENABLE_UPDATE_CHECK=true
- name: Import Code Signing Certificate
env:
@@ -220,21 +207,13 @@ jobs:
IDENTITY=$(security find-identity -v -p codesigning | grep "Developer ID Application" | head -1 | grep -o '".*"' | tr -d '"')
echo "Signing with identity: $IDENTITY"
APP_PATH="build/macos/Build/Products/Release/Plezy.app"
APP_PATH="build/macos/Build/Products/Release/plezy.app"
# Deep sign everything inside-out: executables, dylibs, XPC services, app bundles, frameworks
find "$APP_PATH/Contents/Frameworks" \( -name "*.xpc" -o -name "*.app" -o -name "*.framework" -o -name "*.dylib" \) -print0 \
| sort -zr \
| while IFS= read -r -d '' item; do
echo "Signing: $item"
codesign --force --sign "$IDENTITY" --timestamp --options runtime "$item"
done
# Sign any remaining bare executables (e.g. Sparkle's Autoupdate)
find "$APP_PATH/Contents/Frameworks" -type f -perm +111 ! -name "*.dylib" ! -path "*/Headers/*" ! -path "*/Modules/*" -print0 \
| while IFS= read -r -d '' exe; do
codesign --force --sign "$IDENTITY" --timestamp --options runtime "$exe" 2>/dev/null || true
done
# Sign all frameworks and dylibs first (inside-out signing)
find "$APP_PATH/Contents/Frameworks" -name "*.framework" -o -name "*.dylib" | while read framework; do
echo "Signing: $framework"
codesign --force --sign "$IDENTITY" --timestamp --options runtime "$framework"
done
# Sign the app bundle itself
echo "Signing app bundle: $APP_PATH"
@@ -242,7 +221,7 @@ jobs:
- name: Verify Signature
run: |
APP_PATH="build/macos/Build/Products/Release/Plezy.app"
APP_PATH="build/macos/Build/Products/Release/plezy.app"
echo "Verifying signature..."
codesign -dvvv "$APP_PATH"
codesign --verify --deep --strict --verbose=2 "$APP_PATH"
@@ -253,7 +232,7 @@ jobs:
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
APP_PATH="build/macos/Build/Products/Release/Plezy.app"
APP_PATH="build/macos/Build/Products/Release/plezy.app"
# Create a temporary ZIP for notarization submission
echo "Creating ZIP for notarization..."
@@ -275,48 +254,27 @@ jobs:
echo "Verifying notarization..."
xcrun stapler validate "$APP_PATH"
- name: Create DMG
- name: Create ZIP
run: |
mkdir -p dmg-staging
cp -R build/macos/Build/Products/Release/Plezy.app dmg-staging/
ln -s /Applications dmg-staging/Applications
hdiutil create -format ULMO -srcfolder dmg-staging -volname "Plezy" plezy-macos.dmg
cd build/macos/Build/Products/Release
ditto -c -k --sequesterRsrc --keepParent plezy.app $GITHUB_WORKSPACE/plezy-macos.zip
- name: Sign DMG for Sparkle (EdDSA)
if: env.SPARKLE_PRIVATE_KEY != ''
env:
SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }}
run: |
printf -- "-----BEGIN ED25519 PRIVATE KEY-----\n%s\n-----END ED25519 PRIVATE KEY-----\n" "$SPARKLE_PRIVATE_KEY" > ed25519_key.pem
OUTPUT=$(dart run auto_updater:sign_update plezy-macos.dmg ed25519_key.pem)
rm ed25519_key.pem
# Extract edSignature value from output like: sparkle:edSignature="..." length="..."
SIGNATURE=$(echo "$OUTPUT" | grep -o 'edSignature="[^"]*"' | sed 's/edSignature="//;s/"//')
echo "MACOS_ED_SIGNATURE=$SIGNATURE" >> $GITHUB_ENV
echo "MACOS_DMG_SIZE=$(stat -f%z plezy-macos.dmg)" >> $GITHUB_ENV
- name: Attest macOS DMG
- name: Attest macOS ZIP
uses: actions/attest-build-provenance@v2
with:
subject-path: plezy-macos.dmg
subject-path: plezy-macos.zip
- name: Write macOS signature metadata
run: |
echo "${{ env.MACOS_ED_SIGNATURE }}" > macos-ed-signature.txt
echo "${{ env.MACOS_DMG_SIZE }}" > macos-dmg-size.txt
- name: Upload macOS DMG
- name: Upload macOS ZIP
uses: actions/upload-artifact@v4
with:
name: macos-dmg
path: |
plezy-macos.dmg
macos-ed-signature.txt
macos-dmg-size.txt
name: macos-zip
path: plezy-macos.zip
build-windows-x64:
build-windows:
runs-on: windows-latest
permissions:
id-token: write
attestations: write
contents: read
steps:
- uses: actions/checkout@v4
@@ -339,136 +297,32 @@ jobs:
- name: Install dependencies
run: flutter pub get
- name: Build Windows x64
run: flutter build windows --release --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }} ${{ env.GIT_COMMIT_DART_DEFINE }} ${{ env.SENTRY_ENV_DART_DEFINE }}
- name: Build Windows
run: flutter build windows --release --dart-define=ENABLE_UPDATE_CHECK=true
- name: Upload x64 build
uses: actions/upload-artifact@v4
with:
name: windows-x64-build
path: build/windows/x64/runner/Release/
build-windows-arm64:
runs-on: windows-11-arm
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- name: Install 7-Zip
run: choco install 7zip -y
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: "master"
cache: true
- name: Cache Pub dependencies
uses: actions/cache@v4
with:
path: |
~\AppData\Local\Pub\Cache
key: ${{ runner.os }}-arm64-pub-${{ hashFiles('**/pubspec.lock') }}
restore-keys: |
${{ runner.os }}-arm64-pub-
- name: Install dependencies
run: flutter pub get
- name: Build Windows arm64
run: flutter build windows --release --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }} ${{ env.GIT_COMMIT_DART_DEFINE }} ${{ env.SENTRY_ENV_DART_DEFINE }}
- name: Upload arm64 build
uses: actions/upload-artifact@v4
with:
name: windows-arm64-build
path: build/windows/arm64/runner/Release/
package-windows:
needs: [build-windows-x64, build-windows-arm64]
runs-on: windows-latest
permissions:
id-token: write
attestations: write
contents: read
steps:
- uses: actions/checkout@v4
- name: Setup Dart
uses: dart-lang/setup-dart@v1
- name: Download x64 build
uses: actions/download-artifact@v4
with:
name: windows-x64-build
path: build-x64
- name: Download arm64 build
uses: actions/download-artifact@v4
with:
name: windows-arm64-build
path: build-arm64
- name: Read version from pubspec
id: version
shell: pwsh
run: |
$v = (Select-String -Path pubspec.yaml -Pattern '^version:\s*(\S+)').Matches[0].Groups[1].Value -replace '\+.*'
echo "version=$v" >> $env:GITHUB_OUTPUT
- name: Build installer and portables
run: .\windows\build-installer.ps1 -X64BuildDir "build-x64" -Arm64BuildDir "build-arm64" -Version "${{ steps.version.outputs.version }}"
- name: Sign installer for WinSparkle (EdDSA)
if: env.SPARKLE_PRIVATE_KEY != ''
env:
SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }}
shell: pwsh
run: |
mkdir _signer | Out-Null
@{name="signer"; environment=@{sdk=">=3.0.0 <4.0.0"}; dependencies=@{cryptography="^2.7.0"}} | ConvertTo-Json -Depth 3 | Out-File _signer/pubspec.yaml
Invoke-WebRequest -Uri "https://raw.githubusercontent.com/edde746/auto_updater/main/packages/auto_updater/bin/sign_update.dart" -OutFile _signer/sign.dart
Push-Location _signer
dart pub get
Set-Content -Path ed25519_key.pem -Value $env:SPARKLE_PRIVATE_KEY -Encoding ascii -NoNewline
$output = dart run sign.dart ../plezy-windows-installer.exe ed25519_key.pem
Pop-Location
Remove-Item _signer -Recurse -Force
$sig = [regex]::Match($output, 'edSignature="([^"]*)"').Groups[1].Value
Set-Content -Path win-ed-signature.txt -Value $sig -Encoding ascii -NoNewline
Set-Content -Path win-installer-size.txt -Value (Get-Item plezy-windows-installer.exe).Length.ToString() -Encoding ascii -NoNewline
- name: Build Windows Installer
run: .\windows\build-installer.ps1
- name: Attest Windows artifacts
uses: actions/attest-build-provenance@v2
with:
subject-path: |
plezy-windows-x64-portable.7z
plezy-windows-arm64-portable.7z
plezy-windows-portable.zip
plezy-windows-installer.exe
- name: Upload x64 portable
- name: Upload Portable Archive
uses: actions/upload-artifact@v4
with:
name: windows-x64-portable
path: plezy-windows-x64-portable.7z
name: windows-portable
path: plezy-windows-portable.zip
- name: Upload arm64 portable
uses: actions/upload-artifact@v4
with:
name: windows-arm64-portable
path: plezy-windows-arm64-portable.7z
- name: Upload installer
- name: Upload Installer
uses: actions/upload-artifact@v4
with:
name: windows-installer
path: |
plezy-windows-installer.exe
win-ed-signature.txt
win-installer-size.txt
path: plezy-windows-installer.exe
build-linux-x64:
build-linux:
runs-on: ubuntu-latest
permissions:
id-token: write
@@ -495,216 +349,49 @@ jobs:
- name: Cache APT packages
uses: awalsh128/cache-apt-pkgs-action@latest
with:
packages: clang cmake meson ninja-build pkg-config nasm libgtk-3-dev libevdev-dev liblzma-dev libstdc++-12-dev libasound2-dev libass-dev libfreetype-dev libfontconfig-dev libfribidi-dev libharfbuzz-dev libepoxy-dev libegl-dev libgl-dev libgnutls28-dev libpipewire-0.3-dev libva-dev libvdpau-dev libx11-dev libxext-dev libxrandr-dev libxcursor-dev libxi-dev libxss-dev libxpresent-dev libxkbcommon-dev libpulse-dev libdbus-1-dev libdrm-dev libgbm-dev libwayland-dev wayland-protocols liblcms2-dev ruby ruby-dev rubygems build-essential rpm libarchive-tools imagemagick libcurl4-openssl-dev
version: 1.1
packages: clang cmake ninja-build pkg-config libgtk-3-dev liblzma-dev libstdc++-12-dev libasound2-dev libmpv-dev mpv keybinder-3.0
version: 1.0
- name: Install Linux dependencies
run: |
sudo apt-get update
sudo apt-get install -y clang cmake meson ninja-build pkg-config nasm libgtk-3-dev libevdev-dev liblzma-dev libstdc++-12-dev libasound2-dev libass-dev libfreetype-dev libfontconfig-dev libfribidi-dev libharfbuzz-dev libepoxy-dev libegl-dev libgl-dev libgnutls28-dev libpipewire-0.3-dev libva-dev libvdpau-dev libx11-dev libxext-dev libxrandr-dev libxcursor-dev libxi-dev libxss-dev libxpresent-dev libxkbcommon-dev libpulse-dev libdbus-1-dev libdrm-dev libgbm-dev libwayland-dev wayland-protocols liblcms2-dev ruby ruby-dev rubygems build-essential rpm libarchive-tools imagemagick libcurl4-openssl-dev
- name: Cache libmpv build
id: libmpv-cache
uses: actions/cache@v4
with:
path: libmpv-prefix
key: libmpv-${{ runner.arch }}-${{ hashFiles('linux/packaging/build-libmpv.sh') }}
- name: Build libmpv
if: steps.libmpv-cache.outputs.cache-hit != 'true'
run: bash linux/packaging/build-libmpv.sh
- name: Install fpm
run: sudo gem install fpm
sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev liblzma-dev libstdc++-12-dev libasound2-dev libmpv-dev mpv keybinder-3.0
- name: Install dependencies
run: flutter pub get
- name: Build Linux x64
run: flutter build linux --release --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }} ${{ env.GIT_COMMIT_DART_DEFINE }} ${{ env.SENTRY_ENV_DART_DEFINE }}
env:
PKG_CONFIG_PATH: ${{ github.workspace }}/libmpv-prefix/lib/pkgconfig:${{ github.workspace }}/libmpv-prefix/lib/x86_64-linux-gnu/pkgconfig
- name: Build Linux
run: flutter build linux --release --dart-define=ENABLE_UPDATE_CHECK=true
- name: Build Linux Packages
run: ARCH_SUFFIX=x64 OUTPUT_DIR=$GITHUB_WORKSPACE python3 linux/packaging/build-packages.py
- name: Copy libmpv into bundle
run: |
BUNDLE_LIB=build/linux/x64/release/bundle/lib
LIBMPV_DIR=$(dirname "$(find libmpv-prefix -name 'libmpv.so' | head -1)")
cp -a "$LIBMPV_DIR"/libmpv.so* "$BUNDLE_LIB/"
cp -a libmpv-prefix/lib/libshaderc_shared.so* "$BUNDLE_LIB/"
- name: Bundle shared libraries for portable tarball
run: bash linux/packaging/bundle-libs.sh build/linux/x64/release/bundle
- name: Copy wrapper script into bundle
run: cp linux/packaging/plezy.sh build/linux/x64/release/bundle/plezy.sh
- name: Verify no missing dependencies
- name: Create Archive
run: |
cd build/linux/x64/release/bundle
MISSING=$(LD_LIBRARY_PATH=lib ldd ./plezy 2>&1 | grep "not found" || true)
if [[ -n "$MISSING" ]]; then
echo "ERROR: Unresolved dependencies found:" >&2
echo "$MISSING" >&2
exit 1
fi
echo "All dependencies resolved."
tar -czf $GITHUB_WORKSPACE/plezy-linux.tar.gz *
- name: Create tarball
run: |
cd build/linux/x64/release/bundle
tar -czf $GITHUB_WORKSPACE/plezy-linux-x64.tar.gz *
- name: Attest Linux x64 artifacts
- name: Attest Linux tarball
uses: actions/attest-build-provenance@v2
with:
subject-path: |
plezy-linux-x64.tar.gz
plezy-linux-x64.deb
plezy-linux-x64.rpm
plezy-linux-x64.pkg.tar.zst
subject-path: plezy-linux.tar.gz
- name: Upload Linux x64 artifacts
- name: Upload Linux App
uses: actions/upload-artifact@v4
with:
name: linux-x64
path: |
plezy-linux-x64.tar.gz
plezy-linux-x64.deb
plezy-linux-x64.rpm
plezy-linux-x64.pkg.tar.zst
build-linux-arm64:
runs-on: ubuntu-24.04-arm
permissions:
id-token: write
attestations: write
contents: read
steps:
- uses: actions/checkout@v4
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: "master"
cache: true
- name: Cache Pub dependencies
uses: actions/cache@v4
with:
path: |
~/.pub-cache
key: ${{ runner.os }}-arm64-pub-${{ hashFiles('**/pubspec.lock') }}
restore-keys: |
${{ runner.os }}-arm64-pub-
- name: Cache APT packages
uses: awalsh128/cache-apt-pkgs-action@latest
with:
packages: clang cmake meson ninja-build pkg-config nasm libgtk-3-dev libevdev-dev liblzma-dev libstdc++-12-dev libasound2-dev libass-dev libfreetype-dev libfontconfig-dev libfribidi-dev libharfbuzz-dev libepoxy-dev libegl-dev libgl-dev libgnutls28-dev libpipewire-0.3-dev libva-dev libvdpau-dev libx11-dev libxext-dev libxrandr-dev libxcursor-dev libxi-dev libxss-dev libxpresent-dev libxkbcommon-dev libpulse-dev libdbus-1-dev libdrm-dev libgbm-dev libwayland-dev wayland-protocols liblcms2-dev ruby ruby-dev rubygems build-essential rpm libarchive-tools imagemagick libcurl4-openssl-dev
version: 1.1
- name: Install Linux dependencies
run: |
sudo apt-get update
sudo apt-get install -y clang cmake meson ninja-build pkg-config nasm libgtk-3-dev libevdev-dev liblzma-dev libstdc++-12-dev libasound2-dev libass-dev libfreetype-dev libfontconfig-dev libfribidi-dev libharfbuzz-dev libepoxy-dev libegl-dev libgl-dev libgnutls28-dev libpipewire-0.3-dev libva-dev libvdpau-dev libx11-dev libxext-dev libxrandr-dev libxcursor-dev libxi-dev libxss-dev libxpresent-dev libxkbcommon-dev libpulse-dev libdbus-1-dev libdrm-dev libgbm-dev libwayland-dev wayland-protocols liblcms2-dev ruby ruby-dev rubygems build-essential rpm libarchive-tools imagemagick libcurl4-openssl-dev
- name: Cache libmpv build
id: libmpv-cache
uses: actions/cache@v4
with:
path: libmpv-prefix
key: libmpv-${{ runner.arch }}-${{ hashFiles('linux/packaging/build-libmpv.sh') }}
- name: Build libmpv
if: steps.libmpv-cache.outputs.cache-hit != 'true'
run: bash linux/packaging/build-libmpv.sh
- name: Install fpm
run: sudo gem install fpm
- name: Install dependencies
run: flutter pub get
- name: Build Linux arm64
run: flutter build linux --release --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }} ${{ env.GIT_COMMIT_DART_DEFINE }} ${{ env.SENTRY_ENV_DART_DEFINE }}
env:
PKG_CONFIG_PATH: ${{ github.workspace }}/libmpv-prefix/lib/pkgconfig:${{ github.workspace }}/libmpv-prefix/lib/aarch64-linux-gnu/pkgconfig
- name: Find bundle directory
id: find-bundle
run: |
BUNDLE_DIR=$(find build/linux -path "*/release/bundle" -type d | head -1)
echo "bundle_dir=$BUNDLE_DIR" >> $GITHUB_OUTPUT
- name: Build Linux Packages
run: |
BUILD_DIR=${{ steps.find-bundle.outputs.bundle_dir }} \
ARCH_SUFFIX=arm64 \
OUTPUT_DIR=$GITHUB_WORKSPACE \
python3 linux/packaging/build-packages.py
- name: Copy libmpv into bundle
run: |
BUNDLE_LIB=${{ steps.find-bundle.outputs.bundle_dir }}/lib
LIBMPV_DIR=$(dirname "$(find libmpv-prefix -name 'libmpv.so' | head -1)")
cp -a "$LIBMPV_DIR"/libmpv.so* "$BUNDLE_LIB/"
cp -a libmpv-prefix/lib/libshaderc_shared.so* "$BUNDLE_LIB/"
- name: Bundle shared libraries for portable tarball
run: bash linux/packaging/bundle-libs.sh ${{ steps.find-bundle.outputs.bundle_dir }}
- name: Copy wrapper script into bundle
run: cp linux/packaging/plezy.sh ${{ steps.find-bundle.outputs.bundle_dir }}/plezy.sh
- name: Verify no missing dependencies
run: |
cd ${{ steps.find-bundle.outputs.bundle_dir }}
MISSING=$(LD_LIBRARY_PATH=lib ldd ./plezy 2>&1 | grep "not found" || true)
if [[ -n "$MISSING" ]]; then
echo "ERROR: Unresolved dependencies found:" >&2
echo "$MISSING" >&2
exit 1
fi
echo "All dependencies resolved."
- name: Create tarball
run: |
cd ${{ steps.find-bundle.outputs.bundle_dir }}
tar -czf $GITHUB_WORKSPACE/plezy-linux-arm64.tar.gz *
- name: Attest Linux arm64 artifacts
uses: actions/attest-build-provenance@v2
with:
subject-path: |
plezy-linux-arm64.tar.gz
plezy-linux-arm64.deb
plezy-linux-arm64.rpm
plezy-linux-arm64.pkg.tar.zst
- name: Upload Linux arm64 artifacts
uses: actions/upload-artifact@v4
with:
name: linux-arm64
path: |
plezy-linux-arm64.tar.gz
plezy-linux-arm64.deb
plezy-linux-arm64.rpm
plezy-linux-arm64.pkg.tar.zst
name: linux-app
path: plezy-linux.tar.gz
create-release:
needs: [build-android, build-ios, build-macos, package-windows, build-linux-x64, build-linux-arm64]
needs:
[
build-android,
build-ios,
build-macos,
build-windows,
build-linux,
]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: pubspec.yaml
sparse-checkout-cone-mode: false
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
@@ -713,99 +400,16 @@ jobs:
- name: Display structure of downloaded files
run: ls -R artifacts
- name: Read version from pubspec.yaml
id: version
run: |
VERSION=$(grep '^version:' pubspec.yaml | sed 's/version: //' | sed 's/+.*//')
BUILD_NUMBER=$(grep '^version:' pubspec.yaml | sed 's/.*+//')
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "build_number=$BUILD_NUMBER" >> $GITHUB_OUTPUT
- name: Generate appcast.xml
run: |
VERSION="${{ steps.version.outputs.version }}"
BUILD_NUMBER="${{ steps.version.outputs.build_number }}"
# Read macOS signature metadata (may be empty if secrets not configured)
MACOS_ED_SIG=""
MACOS_DMG_SIZE="0"
if [ -f artifacts/macos-dmg/macos-ed-signature.txt ]; then
MACOS_ED_SIG=$(cat artifacts/macos-dmg/macos-ed-signature.txt)
fi
if [ -f artifacts/macos-dmg/macos-dmg-size.txt ]; then
MACOS_DMG_SIZE=$(cat artifacts/macos-dmg/macos-dmg-size.txt)
fi
# Read Windows signature metadata (may be empty if secrets not configured)
WIN_ED_SIG=""
WIN_INSTALLER_SIZE="0"
if [ -f artifacts/windows-installer/win-ed-signature.txt ]; then
WIN_ED_SIG=$(cat artifacts/windows-installer/win-ed-signature.txt)
fi
if [ -f artifacts/windows-installer/win-installer-size.txt ]; then
WIN_INSTALLER_SIZE=$(cat artifacts/windows-installer/win-installer-size.txt)
fi
cat > appcast.xml << XMLEOF
<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle">
<channel>
<title>Plezy Updates</title>
<item>
<title>Version ${VERSION}</title>
<sparkle:version>${BUILD_NUMBER}</sparkle:version>
<sparkle:shortVersionString>${VERSION}</sparkle:shortVersionString>
<sparkle:releaseNotesLink>https://gh-html.edde746.dev/edde746/plezy/${VERSION}</sparkle:releaseNotesLink>
XMLEOF
# macOS enclosure
if [ -n "$MACOS_ED_SIG" ]; then
cat >> appcast.xml << XMLEOF
<enclosure url="https://github.com/edde746/plezy/releases/download/${VERSION}/plezy-macos.dmg"
length="${MACOS_DMG_SIZE}" type="application/octet-stream"
sparkle:edSignature="${MACOS_ED_SIG}"
sparkle:os="macos" />
XMLEOF
fi
# Windows enclosure
if [ -n "$WIN_ED_SIG" ]; then
cat >> appcast.xml << XMLEOF
<enclosure url="https://github.com/edde746/plezy/releases/download/${VERSION}/plezy-windows-installer.exe"
length="${WIN_INSTALLER_SIZE}" type="application/octet-stream"
sparkle:edSignature="${WIN_ED_SIG}"
sparkle:installerArguments="/SILENT /SP-"
sparkle:os="windows" />
XMLEOF
fi
cat >> appcast.xml << 'XMLEOF'
</item>
</channel>
</rss>
XMLEOF
# Clean up leading whitespace from heredoc indentation
sed -i 's/^ //' appcast.xml
echo "Generated appcast.xml:"
cat appcast.xml
- name: Create Release
uses: softprops/action-gh-release@v1
with:
files: |
artifacts/android-apk/plezy-android-arm64-v8a.tar.gz
artifacts/android-apk/plezy-android-armeabi-v7a.tar.gz
artifacts/android-apk/plezy-android-x86_64.tar.gz
artifacts/android-apk/plezy-android.apk
artifacts/ios-ipa/plezy-ios.ipa
artifacts/macos-dmg/plezy-macos.dmg
artifacts/windows-x64-portable/plezy-windows-x64-portable.7z
artifacts/windows-arm64-portable/plezy-windows-arm64-portable.7z
artifacts/macos-zip/plezy-macos.zip
artifacts/windows-portable/plezy-windows-portable.zip
artifacts/windows-installer/plezy-windows-installer.exe
artifacts/linux-x64/*
artifacts/linux-arm64/*
appcast.xml
artifacts/linux-app/plezy-linux.tar.gz
draft: true
prerelease: false
generate_release_notes: true
-161
View File
@@ -1,161 +0,0 @@
name: CI - Sanity Checks
on:
push:
branches:
- main
pull_request:
branches:
- main
workflow_dispatch:
jobs:
analyze:
name: Code Analysis
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: "stable"
cache: true
- name: Cache Pub dependencies
uses: actions/cache@v4
with:
path: |
~/.pub-cache
key: ${{ runner.os }}-pub-v2-${{ hashFiles('**/pubspec.lock') }}
restore-keys: |
${{ runner.os }}-pub-v2-
- name: Install dependencies
run: |
flutter pub get
- name: Verify formatting
run: |
# Find all Dart files excluding generated files
find lib $([ -d test ] && echo test) -name "*.dart" ! -name "*.g.dart" ! -name "*.freezed.dart" -type f 2>/dev/null | while IFS= read -r file; do
files_found=true
break
done
if [ "$files_found" != "true" ]; then
echo "No Dart files found to format"
exit 0
fi
find lib $([ -d test ] && echo test) -name "*.dart" ! -name "*.g.dart" ! -name "*.freezed.dart" -type f 2>/dev/null -print0 | xargs -0 dart format --output=none --set-exit-if-changed
- name: Analyze code
run: |
# Run flutter analyze
# Fails on errors or warnings only (info messages are allowed)
flutter analyze 2>&1 | tee analyze_output.txt || true
# Check output for errors or warnings
if grep -q "error •" analyze_output.txt; then
echo "❌ Analysis failed with errors"
exit 1
elif grep -q "warning •" analyze_output.txt; then
echo "⚠️ Analysis completed with warnings"
exit 1
else
echo "✅ Analysis passed!"
exit 0
fi
- name: Check for unused code
run: |
echo "🔍 Checking for unused code..."
dart run dart_code_linter:metrics check-unused-code lib 2>&1 | tee unused_code.txt
if grep -qi "no unused code found" unused_code.txt; then
echo "✅ No unused code found"
else
echo "❌ Found unused code:"
cat unused_code.txt
exit 1
fi
- name: Check for unused files
run: |
echo "🔍 Checking for unused files..."
dart run dart_code_linter:metrics check-unused-files lib 2>&1 | tee unused_files.txt
if grep -qi "no unused files found" unused_files.txt; then
echo "✅ No unused files found"
else
echo "❌ Found unused files:"
cat unused_files.txt
exit 1
fi
test:
name: Unit Tests
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: "stable"
cache: true
- name: Cache Pub dependencies
uses: actions/cache@v4
with:
path: |
~/.pub-cache
key: ${{ runner.os }}-pub-v2-${{ hashFiles('**/pubspec.lock') }}
restore-keys: |
${{ runner.os }}-pub-v2-
- name: Install dependencies
run: |
flutter clean
flutter pub get
- name: Run tests
run: |
if [ -d "test" ] && [ "$(find test -name '*_test.dart' | wc -l)" -gt 0 ]; then
flutter test
else
echo "No tests found, skipping test execution"
fi
dependency-check:
name: Dependency Validation
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: "stable"
cache: true
- name: Cache Pub dependencies
uses: actions/cache@v4
with:
path: |
~/.pub-cache
key: ${{ runner.os }}-pub-v2-${{ hashFiles('**/pubspec.lock') }}
restore-keys: |
${{ runner.os }}-pub-v2-
- name: Verify dependencies
run: |
flutter clean
flutter pub get
flutter pub outdated
-82
View File
@@ -1,82 +0,0 @@
name: Release
on:
workflow_dispatch:
inputs:
version:
description: 'Version number (e.g., 1.9.0)'
required: true
type: string
permissions:
contents: write
actions: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Validate version format
run: |
if ! echo "${{ inputs.version }}" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "Error: Version must be in semantic versioning format (e.g., 1.9.0)"
exit 1
fi
- name: Extract current build number
id: get_build
run: |
# Extract current build number from pubspec.yaml
CURRENT_VERSION=$(grep '^version:' pubspec.yaml | sed 's/version: //')
CURRENT_BUILD=$(echo "$CURRENT_VERSION" | cut -d'+' -f2)
NEW_BUILD=$((CURRENT_BUILD + 1))
echo "current_build=$CURRENT_BUILD" >> $GITHUB_OUTPUT
echo "new_build=$NEW_BUILD" >> $GITHUB_OUTPUT
echo "Current build: $CURRENT_BUILD"
echo "New build: $NEW_BUILD"
- name: Update pubspec.yaml
run: |
NEW_VERSION="${{ inputs.version }}+${{ steps.get_build.outputs.new_build }}"
sed -i "s/^version: .*/version: $NEW_VERSION/" pubspec.yaml
echo "Updated version to: $NEW_VERSION"
cat pubspec.yaml | grep '^version:'
- name: Configure Git
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Commit version bump
run: |
git add pubspec.yaml
git commit -m "chore: bump version to ${{ inputs.version }}"
- name: Create Git tag
run: |
git tag "${{ inputs.version }}"
echo "Created tag: ${{ inputs.version }}"
- name: Push changes and tag
run: |
git push origin main
git push origin --tags
- name: Trigger build workflow
run: |
curl -L \
-X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \
-H "X-GitHub-Api-Version: 2022-11-28" \
https://api.github.com/repos/${{ github.repository }}/actions/workflows/build.yml/dispatches \
-d '{"ref":"main"}'
echo "Build workflow triggered successfully"
-27
View File
@@ -1,27 +0,0 @@
name: SonarQube
on:
workflow_dispatch:
jobs:
sonarqube:
name: SonarQube
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: "stable"
cache: true
- name: Install dependencies
run: flutter pub get
- name: SonarQube Scan
uses: SonarSource/sonarqube-scan-action@v6
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
-78
View File
@@ -1,78 +0,0 @@
name: Update Packages
on:
release:
types: [published]
workflow_dispatch:
jobs:
update-homebrew:
runs-on: macos-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.repository.default_branch }}
fetch-depth: 0
- name: Download and hash release
id: release
run: |
VERSION="${{ github.event.release.tag_name || github.ref_name }}"
curl -L -o plezy-macos.dmg \
"https://github.com/edde746/plezy/releases/download/$VERSION/plezy-macos.dmg"
SHA256=$(shasum -a 256 plezy-macos.dmg | cut -d' ' -f1)
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "sha256=$SHA256" >> $GITHUB_OUTPUT
- name: Update cask
run: |
sed -i '' "s/version \".*\"/version \"${{ steps.release.outputs.version }}\"/" Casks/plezy.rb
sed -i '' "s/sha256 \".*\"/sha256 \"${{ steps.release.outputs.sha256 }}\"/" Casks/plezy.rb
- name: Commit
run: |
BRANCH="${{ github.event.repository.default_branch }}"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add Casks/plezy.rb
if ! git diff --staged --quiet; then
git commit -m "chore: update cask to ${{ steps.release.outputs.version }}"
git push origin "HEAD:$BRANCH"
else
echo "No cask changes to commit."
fi
update-winget:
runs-on: windows-latest
steps:
- uses: vedantmgoyal9/winget-releaser@v2
with:
identifier: edde746.Plezy
installers-regex: 'plezy-windows-installer\.exe$'
token: ${{ secrets.WINGET_TOKEN }}
update-appcast-branch:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- name: Download appcast.xml from release
run: |
gh release download "${{ github.event.release.tag_name }}" \
--pattern "appcast.xml"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Push to appcast branch
run: |
VERSION="${{ github.event.release.tag_name }}"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
BLOB=$(git hash-object -w appcast.xml)
TREE=$(printf "100644 blob %s\tappcast.xml\n" "$BLOB" | git mktree)
COMMIT=$(git commit-tree "$TREE" -m "Update appcast for $VERSION")
git push origin "$COMMIT:refs/heads/appcast" --force
-1
View File
@@ -32,7 +32,6 @@ migrate_working_dir/
.pub-cache/
.pub/
/build/
/debug-info/
# Symbolication related
app.*.symbols
-69
View File
@@ -1,69 +0,0 @@
# Contributing
## Getting Started
1. Fork and clone the repository
2. Run `flutter pub get` to install dependencies
3. Run `dart run build_runner build` to generate code
4. Start developing!
## Development
- Follow Dart/Flutter conventions
- Run `dart format .` to format your code (note: generated files like `*.g.dart` are excluded from CI checks)
- Run `flutter analyze` before submitting to check for issues
- Run `flutter test` if tests are available
- Test your changes thoroughly
### Code Quality Checks
The project includes automated CI checks that run on all pull requests:
1. **Code Formatting**: Ensures code follows Dart formatting standards
- Run locally: `dart format .` to format all files
- Note: CI only checks non-generated files (excludes `.g.dart`, `.freezed.dart`)
- Generated files are reformatted automatically by build tools
2. **Static Analysis**: Checks for code issues and potential bugs
- Run locally: `flutter analyze`
- Note: CI excludes generated files from analysis (configured in `analysis_options.yaml`)
3. **Tests**: Runs unit and widget tests (when available)
- Run locally: `flutter test`
All these checks must pass before your changes can be merged.
## Internationalization (i18n)
This project uses `slang` for internationalization with JSON files.
### Adding New Strings
1. Add your string to `lib/i18n/strings.i18n.json`:
```json
{
"section": {
"myNewString": "My new text"
}
}
```
2. Run `dart run slang` to regenerate translation files
3. Use in your code:
```dart
Text(t.section.myNewString)
```
### Adding New Languages
1. Create new JSON file: `lib/i18n/[locale].i18n.json`
2. Copy structure from `en.i18n.json` and translate values
3. Run `dart run slang` to regenerate files
### Guidelines
- Organize strings logically in nested objects
- Use camelCase for keys
- Keep strings concise and clear
- Always run `dart run slang` after changes
-35
View File
@@ -1,35 +0,0 @@
cask "plezy" do
version "1.32.0"
sha256 "23567afb7c0e1bef1f99af5228898577e942c5cc58c95473c0465d4d111478df"
url "https://github.com/edde746/plezy/releases/download/#{version}/plezy-macos.dmg"
name "Plezy"
desc "Modern Plex client built with Flutter"
homepage "https://github.com/edde746/plezy"
livecheck do
url :url
strategy :github_latest
end
auto_updates true
app "Plezy.app"
postflight do
system_command "/usr/bin/xattr",
args: ["-cr", "#{appdir}/Plezy.app"],
sudo: false
end
uninstall quit: "com.edde746.plezy"
zap trash: [
"~/Library/Application Support/com.edde746.plezy",
"~/Library/Caches/com.edde746.plezy",
"~/Library/HTTPStorages/com.edde746.plezy",
"~/Library/Preferences/com.edde746.plezy.plist",
"~/Library/Saved Application State/com.edde746.plezy.savedState",
"~/Library/WebKit/com.edde746.plezy",
]
end
+98
View File
@@ -0,0 +1,98 @@
# Privacy Policy for Plezy
**Last Updated: October 22, 2025**
## Introduction
Plezy ("we", "our", or "the app") is a third-party Plex client that allows you to access and stream content from your Plex Media Server. This privacy policy explains how we handle your information when you use our app.
## Information We Collect
### Authentication Information
When you sign in to Plezy using your Plex account, we collect and process:
- Plex authentication tokens
- Plex account username
- Plex server connection information
### Local Network Information
To connect to your Plex Media Server on your local network, we may access:
- Local network device discovery information
- IP addresses of Plex servers on your network
### Usage Information
The app stores locally on your device:
- Your authentication session
- App preferences and settings
- Recently accessed content history
## How We Use Your Information
We use the collected information solely to:
- Authenticate you with your Plex account
- Connect to and communicate with your Plex Media Server
- Display your media library and stream content
- Maintain your session and app preferences
## Data Storage and Security
- **Authentication tokens** are stored securely on your device using platform-specific secure storage mechanisms
- **App preferences** are stored locally on your device
- **No data** is transmitted to our servers - all communication is directly between your device and your Plex Media Server or Plex's authentication services
## Third-Party Services
### Plex
Plezy uses Plex's authentication and media services. When you use this app:
- You authenticate directly with Plex's servers
- Your media streaming occurs between your device and your Plex Media Server
- Plex's own privacy policy applies to their services: https://www.plex.tv/about/privacy-legal/
We do not control and are not responsible for Plex's data practices.
## Data Sharing
We do not:
- Sell your personal information
- Share your information with third parties for marketing purposes
- Transmit your data to our own servers
- Track your viewing habits outside of local app functionality
Your data is only shared with:
- **Plex services** for authentication and media server communication (as required for the app to function)
## Your Rights and Choices
You can:
- **Sign out** at any time to remove your authentication session from the device
- **Uninstall the app** to remove all locally stored data
- **Manage your Plex account** directly through Plex's services
## Children's Privacy
Plezy does not knowingly collect personal information from children under 13. The app relies on Plex account authentication, and users must comply with Plex's terms of service and age requirements.
## Changes to This Privacy Policy
We may update this privacy policy from time to time. We will notify users of any material changes by updating the "Last Updated" date at the top of this policy.
## Data Retention
- Authentication tokens are retained on your device until you sign out or uninstall the app
- Local preferences and settings are retained until you uninstall the app
- No data is retained on our servers as we do not operate any backend services
## International Data Transfers
As we do not operate backend servers, there are no international data transfers from our side. Any data transfers occur directly between your device and Plex's services, subject to Plex's privacy policy.
## Legal Compliance
This app complies with:
- General Data Protection Regulation (GDPR)
- California Consumer Privacy Act (CCPA)
- Children's Online Privacy Protection Act (COPPA)
- Other applicable privacy laws
## Your Consent
By using Plezy, you consent to this privacy policy and the processing of your information as described herein.
+58 -50
View File
@@ -3,98 +3,106 @@
Plezy
</h1>
A modern Plex client for desktop and mobile. Built with Flutter for native performance and a clean interface.
Plezy is a modern Plex media client that provides a seamless streaming experience across desktop and mobile platforms. Built with Flutter, it offers native performance and a clean, intuitive interface for browsing and playing your Plex media library.
<p align="center">
<img src="assets/screenshots/macos-home.png" alt="Plezy macOS Home Screen" width="800" />
</p>
*More screenshots in the [screenshots folder](assets/screenshots/#readme)*
*See more screenshots in the [screenshots folder](assets/screenshots/#readme)*
## Download
### Mobile
<a href='https://apps.apple.com/us/app/id6754315964'><img height='60' alt='Download on the App Store' src='./assets/app-store-badge.png'/></a>
<a href='https://play.google.com/store/apps/details?id=com.edde746.plezy'><img height='60' alt='Get it on Google Play' src='./assets/play-store-badge.png'/></a>
<a href='https://www.amazon.com/gp/product/B0GK65CVS1'><img height='60' alt='Available at the Amazon App Store' src='./assets/amazon-badge.png'/></a>
- [Windows (x64, arm64)](https://github.com/edde746/plezy/releases/latest/download/plezy-windows-installer.exe)
- [macOS (x64, arm64)](https://github.com/edde746/plezy/releases/latest/download/plezy-macos.dmg)
- [Linux (x64, arm64)](https://github.com/edde746/plezy/releases/latest) - .deb, .rpm, .pkg.tar.zst, and portable tar.gz available
- [Nix](https://search.nixos.org/packages?channel=unstable&query=plezy) - Community package by [@mio-19](https://github.com/mio-19) and [@MiniHarinn](https://github.com/MiniHarinn)
- **Homebrew** (macOS):
```bash
brew tap edde746/plezy https://github.com/edde746/plezy
brew install --cask plezy
```
- [AUR](https://aur.archlinux.org/packages/plezy-bin) (Arch Linux) - Community maintained by [@jianglai](https://github.com/jianglai):
```bash
yay -S plezy-bin
```
- **WinGet** (Windows):
```bash
winget install edde746.plezy
```
> Google Play version is in closed testing ([required by Google](https://support.google.com/googleplay/android-developer/answer/14151465#overview)). Join the [Google Group](https://groups.google.com/g/plezy-testers-2) to get access.
### Desktop
- [Windows (x64)](https://github.com/edde746/plezy/releases/latest/download/plezy-windows-installer.exe)
- [macOS (Universal)](https://github.com/edde746/plezy/releases/latest/download/plezy-macos.zip)
- [Linux (x64)](https://github.com/edde746/plezy/releases/latest/download/plezy-linux.tar.gz)
> Download the latest release from the [Releases page](https://github.com/edde746/plezy/releases)
## Features
### 🔐 Authentication
### 🔐 Authentication & Server Management
- Sign in with Plex
- Automatic server discovery and smart connection selection
- Automatic server discovery with smart connection selection
- Persistent sessions with auto-login
### 📚 Media Browsing
- Browse libraries with rich metadata
- Discover featured content
- Advanced search across all media
- Collections and playlists
- Season and episode navigation
### 🎬 Playback
- Wide codec support (HEVC, AV1, VP9, and more)
- HDR and Dolby Vision (not Linux)
- Full ASS/SSA subtitle support
- Audio and subtitle preferences synced with Plex profile
- Progress sync and resume
### 🎬 Video Playback
- Wide codec support including HEVC, AV1, VP9, and more
- Advanced subtitle rendering with full ASS/SSA support
- Audio and subtitle track selection with user profile preferences
- Playback progress sync and resume functionality
- Auto-play next episode
### 📺 Live TV & DVR
- EPG guide grid
- Channel tuning
- DVR recording rules and scheduled recordings
- Multi-server DVR support
## Prerequisites
### 📥 Downloads
- Download media for offline viewing
- Background downloads with queue management
- Flutter SDK 3.8.1 or higher
- A Plex account
- Access to a Plex Media Server (local or remote)
### 👥 Watch Together
- Synchronized playback with friends
- Real-time play/pause and seek sync
## Building from Source
### Prerequisites
- Flutter SDK 3.8.1+
- A Plex account with server access
### Setup
## Installation
1. Clone the repository:
```bash
git clone https://github.com/edde746/plezy.git
cd plezy
```
2. Install dependencies:
```bash
flutter pub get
```
3. Generate required code:
```bash
dart run build_runner build
```
4. Run the application:
```bash
flutter run
```
## Development
### Code Generation
After modifying model classes:
The project uses code generation for JSON serialization. After modifying model classes, run:
```bash
dart run build_runner build --delete-conflicting-outputs
```
## Building for Production
### Android
```bash
flutter build apk --release
# or
flutter build appbundle --release
```
### Desktop
```bash
flutter build macos --release
flutter build windows --release
flutter build linux --release
```
## Acknowledgments
- Built with [Flutter](https://flutter.dev)
- Media playback powered by [MediaKit](https://github.com/media-kit/media-kit)
- Designed for [Plex Media Server](https://www.plex.tv)
- Playback powered by [mpv](https://mpv.io) via [MPVKit](https://github.com/mpvkit/MPVKit) and [libmpv-android](https://github.com/jarnedemeulemeester/libmpv-android)
-68
View File
@@ -1,69 +1 @@
include: package:flutter_lints/flutter.yaml
analyzer:
exclude:
- "**/*.g.dart"
- "**/*.freezed.dart"
plugins:
- dart_code_linter
dart_code_linter:
extends:
- package:dart_code_linter/presets/recommended.yaml
rules:
# --- Flutter rules (on top of recommended) ---
- avoid-border-all
- avoid-shrink-wrap-in-lists
- avoid-expanded-as-spacer
- avoid-wrapping-in-padding
- prefer-const-border-radius
- prefer-correct-edge-insets-constructor
- prefer-define-hero-tag
- use-setstate-synchronously
# --- Additional useful Dart rules ---
- avoid-cascade-after-if-null
- avoid-collection-methods-with-unrelated-types
- avoid-unnecessary-type-assertions
- avoid-unrelated-type-assertions
- double-literal-format
- prefer-first
- prefer-last
- prefer-enums-by-name
- prefer-commenting-analyzer-ignores
# --- Disable noisy rules from recommended preset ---
- no-magic-number: false
- avoid-dynamic: false
- format-comment: false
- newline-before-return: false
- prefer-moving-to-variable: false
- member-ordering: false
- prefer-extracting-callbacks: false
- avoid-returning-widgets: false
- no-equal-arguments: false
- avoid-passing-async-when-sync-expected: false
- avoid-redundant-async: false
- no-empty-block: false
- prefer-trailing-comma: false
- avoid-non-null-assertion: false
- prefer-conditional-expressions: false
metrics:
cyclomatic-complexity: 70
halstead-volume: 16000
maintainability-index: 8
maximum-nesting-level: 7
number-of-parameters: 25
number-of-methods: 150
source-lines-of-code: 500
anti-patterns:
- long-method:
lines-of-code: 500
- long-parameter-list:
number-of-parameters: 25
formatter:
page_width: 120
-10
View File
@@ -1,10 +0,0 @@
# Autogenerated by fastlane
#
# Ensure this file is checked in to source control!
source "https://rubygems.org"
gem 'fastlane'
plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile')
eval_gemfile(plugins_path) if File.exist?(plugins_path)
-244
View File
@@ -1,244 +0,0 @@
GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.8)
abbrev (0.1.2)
addressable (2.8.8)
public_suffix (>= 2.0.2, < 8.0)
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.4.0)
aws-partitions (1.1215.0)
aws-sdk-core (3.242.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
base64
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.122.0)
aws-sdk-core (~> 3, >= 3.241.4)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.213.0)
aws-sdk-core (~> 3, >= 3.241.4)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4)
base64 (0.2.0)
benchmark (0.5.0)
bigdecimal (4.0.1)
claide (1.1.0)
colored (1.2)
colored2 (3.1.2)
commander (4.6.0)
highline (~> 2.0.0)
csv (3.3.5)
declarative (0.0.20)
digest-crc (0.7.0)
rake (>= 12.0.0, < 14.0.0)
domain_name (0.6.20240107)
dotenv (2.8.1)
emoji_regex (3.2.3)
excon (0.112.0)
faraday (1.10.5)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1)
faraday-httpclient (~> 1.0)
faraday-multipart (~> 1.0)
faraday-net_http (~> 1.0)
faraday-net_http_persistent (~> 1.0)
faraday-patron (~> 1.0)
faraday-rack (~> 1.0)
faraday-retry (~> 1.0)
ruby2_keywords (>= 0.0.4)
faraday-cookie_jar (0.0.8)
faraday (>= 0.8.0)
http-cookie (>= 1.0.0)
faraday-em_http (1.0.0)
faraday-em_synchrony (1.0.1)
faraday-excon (1.1.0)
faraday-httpclient (1.0.1)
faraday-multipart (1.2.0)
multipart-post (~> 2.0)
faraday-net_http (1.0.2)
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
faraday-retry (1.0.3)
faraday_middleware (1.2.1)
faraday (~> 1.0)
fastimage (2.4.0)
fastlane (2.232.1)
CFPropertyList (>= 2.3, < 4.0.0)
abbrev (~> 0.1.2)
addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0)
aws-sdk-s3 (~> 1.197)
babosa (>= 1.0.3, < 2.0.0)
base64 (~> 0.2.0)
benchmark (>= 0.1.0)
bundler (>= 1.17.3, < 5.0.0)
colored (~> 1.2)
commander (~> 4.6)
csv (~> 3.3)
dotenv (>= 2.1.1, < 3.0.0)
emoji_regex (>= 0.1, < 4.0)
excon (>= 0.71.0, < 1.0.0)
faraday (~> 1.0)
faraday-cookie_jar (~> 0.0.6)
faraday_middleware (~> 1.0)
fastimage (>= 2.1.0, < 3.0.0)
fastlane-sirp (>= 1.0.0)
gh_inspector (>= 1.1.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.3)
google-apis-playcustomapp_v1 (~> 0.1)
google-cloud-env (>= 1.6.0, <= 2.1.1)
google-cloud-storage (~> 1.31)
highline (~> 2.0)
http-cookie (~> 1.0.5)
json (< 3.0.0)
jwt (>= 2.1.0, < 3)
logger (>= 1.6, < 2.0)
mini_magick (>= 4.9.4, < 5.0.0)
multipart-post (>= 2.0.0, < 3.0.0)
mutex_m (~> 0.3.0)
naturally (~> 2.2)
nkf (~> 0.2.0)
optparse (>= 0.1.1, < 1.0.0)
ostruct (>= 0.1.0)
plist (>= 3.1.0, < 4.0.0)
rubyzip (>= 2.0.0, < 3.0.0)
security (= 0.1.5)
simctl (~> 1.6.3)
terminal-notifier (>= 2.0.0, < 3.0.0)
terminal-table (~> 3)
tty-screen (>= 0.6.3, < 1.0.0)
tty-spinner (>= 0.8.0, < 1.0.0)
word_wrap (~> 1.0.0)
xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.4.1)
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
fastlane-plugin-amazon_appstore (1.4.0)
faraday (~> 1.0)
faraday_middleware (~> 1.0)
fastlane-sirp (1.0.0)
sysrandom (~> 1.0)
gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.96.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-core (0.18.0)
addressable (~> 2.5, >= 2.5.1)
googleauth (~> 1.9)
httpclient (>= 2.8.3, < 3.a)
mini_mime (~> 1.0)
mutex_m
representable (~> 3.0)
retriable (>= 2.0, < 4.a)
google-apis-iamcredentials_v1 (0.26.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-playcustomapp_v1 (0.17.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-storage_v1 (0.60.0)
google-apis-core (>= 0.15.0, < 2.a)
google-cloud-core (1.8.0)
google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0)
google-cloud-env (2.1.1)
faraday (>= 1.0, < 3.a)
google-cloud-errors (1.5.0)
google-cloud-storage (1.58.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-core (>= 0.18, < 2)
google-apis-iamcredentials_v1 (~> 0.18)
google-apis-storage_v1 (>= 0.42)
google-cloud-core (~> 1.6)
googleauth (~> 1.9)
mini_mime (~> 1.0)
googleauth (1.11.2)
faraday (>= 1.0, < 3.a)
google-cloud-env (~> 2.1)
jwt (>= 1.4, < 3.0)
multi_json (~> 1.11)
os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a)
highline (2.0.3)
http-cookie (1.0.8)
domain_name (~> 0.5)
httpclient (2.9.0)
mutex_m
jmespath (1.6.2)
json (2.18.1)
jwt (2.10.2)
base64
logger (1.7.0)
mini_magick (4.13.2)
mini_mime (1.1.5)
multi_json (1.19.1)
multipart-post (2.4.1)
mutex_m (0.3.0)
nanaimo (0.4.0)
naturally (2.3.0)
nkf (0.2.0)
optparse (0.8.1)
os (1.1.4)
ostruct (0.6.3)
plist (3.7.2)
public_suffix (7.0.2)
rake (13.3.1)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
retriable (3.1.2)
rexml (3.4.4)
rouge (3.28.0)
ruby2_keywords (0.0.5)
rubyzip (2.4.1)
security (0.1.5)
signet (0.21.0)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 4.0)
multi_json (~> 1.10)
simctl (1.6.10)
CFPropertyList
naturally
sysrandom (1.0.5)
terminal-notifier (2.0.0)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
trailblazer-option (0.1.2)
tty-cursor (0.7.1)
tty-screen (0.8.2)
tty-spinner (0.9.3)
tty-cursor (~> 0.7)
uber (0.1.0)
unicode-display_width (2.6.0)
word_wrap (1.0.0)
xcodeproj (1.27.0)
CFPropertyList (>= 2.3.3, < 4.0)
atomos (~> 0.1.3)
claide (>= 1.0.2, < 2.0)
colored2 (~> 3.1)
nanaimo (~> 0.4.0)
rexml (>= 3.3.6, < 4.0)
xcpretty (0.4.1)
rouge (~> 3.28.0)
xcpretty-travis-formatter (1.0.1)
xcpretty (~> 0.2, >= 0.0.7)
PLATFORMS
arm64-darwin-25
ruby
DEPENDENCIES
fastlane
fastlane-plugin-amazon_appstore
BUNDLED WITH
2.7.2
+1 -128
View File
@@ -8,66 +8,6 @@ plugins {
id("dev.flutter.flutter-gradle-plugin")
}
val mpvVersion = "v1.0.6"
val mpvDir = layout.buildDirectory.dir("libmpv").get().asFile
val mpvAar = "libmpv-release.aar"
val downloadLibmpv by tasks.registering {
val stamp = File(mpvDir, ".version")
outputs.upToDateWhen { stamp.exists() && stamp.readText().trim() == mpvVersion }
doLast {
mpvDir.mkdirs()
val url = "https://github.com/edde746/libmpv-android/releases/download/$mpvVersion/$mpvAar"
exec { commandLine("curl", "-sfL", url, "-o", File(mpvDir, mpvAar).absolutePath) }
stamp.writeText(mpvVersion)
}
}
val assVersion = "safety-2"
val assDir = layout.buildDirectory.dir("libass").get().asFile
val assAars = listOf("lib_ass-release.aar", "lib_ass_kt-release.aar", "lib_ass_media-release.aar")
val downloadLibass by tasks.registering {
val stamp = File(assDir, ".version")
outputs.upToDateWhen { stamp.exists() && stamp.readText().trim() == assVersion }
doLast {
assDir.mkdirs()
val baseUrl = "https://github.com/edde746/libass-android/releases/download/$assVersion"
assAars.forEach { name ->
val dest = File(assDir, name)
exec { commandLine("curl", "-sfL", "$baseUrl/$name", "-o", dest.absolutePath) }
}
stamp.writeText(assVersion)
}
}
val doviVersion = "2.3.1"
val doviDir = layout.buildDirectory.dir("libdovi").get().asFile
val doviAbis = mapOf(
"arm64-v8a" to "aarch64-linux-android",
"armeabi-v7a" to "armv7-linux-androideabi",
"x86" to "i686-linux-android",
"x86_64" to "x86_64-linux-android",
)
val downloadLibdovi by tasks.registering {
val stamp = File(doviDir, ".version")
outputs.upToDateWhen { stamp.exists() && stamp.readText().trim() == doviVersion }
doLast {
doviDir.mkdirs()
val baseUrl = "https://github.com/edde746/libdovi-builds/releases/download/v$doviVersion"
doviAbis.forEach { (abi, triple) ->
val archive = File(doviDir, "$triple.tar.gz")
exec { commandLine("curl", "-sfL", "$baseUrl/libdovi-$triple.tar.gz", "-o", archive.absolutePath) }
val outDir = File(doviDir, "$abi/lib")
outDir.mkdirs()
exec { commandLine("tar", "-xzf", archive.absolutePath, "-C", outDir.absolutePath) }
archive.delete()
}
stamp.writeText(doviVersion)
}
}
android {
namespace = "com.edde746.plezy"
compileSdk = flutter.compileSdkVersion
@@ -86,32 +26,10 @@ android {
applicationId = "com.edde746.plezy"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = 25 // Fire OS 6.x (API 25); overrides libmpv-android's minSdk=26
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
externalNativeBuild {
cmake {
arguments += listOf(
"-DDOVI_ENABLE_LIBDOVI=ON",
"-DDOVI_LIBDOVI_PREBUILT_ROOT=${doviDir.absolutePath}"
)
}
}
if (System.getenv("AMAZON") != null) {
versionCode = (flutter.versionCode ?: 0) + 3000
ndk {
abiFilters += listOf("armeabi-v7a", "arm64-v8a")
}
}
}
externalNativeBuild {
cmake {
path = file("src/main/cpp/CMakeLists.txt")
}
}
signingConfigs {
@@ -137,16 +55,6 @@ android {
signingConfig = signingConfigs.getByName("release")
}
// If key.properties doesn't exist, it will use debug signing for CI builds
ndk {
debugSymbolLevel = "FULL"
}
}
}
packaging {
jniLibs {
// Resolve conflict between libass-android and libmpv native libraries
pickFirsts.add("lib/*/libc++_shared.so")
}
}
}
@@ -154,38 +62,3 @@ android {
flutter {
source = "../.."
}
// Download libdovi before any CMake/native build task
tasks.matching { it.name.contains("CMake") || it.name.contains("externalNative") }.configureEach {
dependsOn(downloadLibdovi)
}
// Download libmpv and libass AARs before compilation
tasks.matching { it.name.startsWith("pre") && it.name.endsWith("Build") }.configureEach {
dependsOn(downloadLibmpv)
dependsOn(downloadLibass)
}
dependencies {
implementation(files(File(mpvDir, mpvAar)))
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
// Android TV Watch Next integration
implementation("androidx.tvprovider:tvprovider:1.0.0")
// Media3 ExoPlayer for Android
implementation("androidx.media3:media3-exoplayer:1.9.2")
implementation("androidx.media3:media3-ui:1.9.2")
implementation("androidx.media3:media3-common:1.9.2")
// Cronet for HTTP/2 multiplexing + better connection management
implementation("androidx.media3:media3-datasource-cronet:1.9.2")
implementation("org.chromium.net:cronet-embedded:143.7445.0")
// FFmpeg audio decoder for unsupported codecs (ALAC, DTS, TrueHD, etc.)
implementation("org.jellyfin.media3:media3-ffmpeg-decoder:1.9.0+1")
// libass-android for ASS/SSA subtitle rendering
assAars.forEach { implementation(files(File(assDir, it))) }
}
+2 -54
View File
@@ -1,56 +1,28 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Allow minSdk=25 despite libmpv-android declaring minSdk=26 -->
<uses-sdk tools:overrideLibrary="dev.jdtech.mpv" />
<!-- Internet access permissions -->
<uses-permission android:name="android.permission.INTERNET"/>
<!-- Camera permission for QR code scanning (companion remote pairing) -->
<uses-permission android:name="android.permission.CAMERA"/>
<!-- PIP and media session foreground service permissions -->
<!-- Media session permissions for OS media controls -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
<!-- Background download notifications and foreground service -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
<!-- Explicitly remove media permissions that media_kit may add -->
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" tools:node="remove" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" tools:node="remove" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" tools:node="remove" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" tools:node="remove" />
<!-- Camera not required (QR scanning is optional, some devices lack cameras) -->
<uses-feature android:name="android.hardware.camera.any" android:required="false" />
<!-- Android TV support (not required, but allow detection) -->
<uses-feature android:name="android.software.leanback" android:required="false" />
<!-- Android TV Watch Next integration -->
<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA"/>
<!-- Touchscreen not required for TV -->
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
<!-- Android Automotive OS support -->
<uses-feature android:name="android.hardware.type.automotive" android:required="false" />
<application
android:label="Plezy"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:banner="@drawable/tv_banner"
android:appCategory="video"
android:largeHeap="true"
android:usesCleartextTraffic="true">
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:supportsPictureInPicture="true"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
@@ -65,27 +37,8 @@
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
<!-- Allow app to appear in Android TV launcher -->
<category android:name="android.intent.category.LEANBACK_LAUNCHER"/>
</intent-filter>
<!-- Deep link handler for Watch Next items -->
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="plezy" android:host="play"/>
</intent-filter>
</activity>
<!-- FileProvider for sharing downloaded files with external players -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="com.edde746.plezy.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_provider_paths" />
</provider>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
@@ -111,10 +64,5 @@
<action android:name="android.intent.action.VIEW" />
<data android:scheme="http" />
</intent>
<!-- Required to resolve external video player apps on Android 11+ -->
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:mimeType="video/*" />
</intent>
</queries>
</manifest>
-19
View File
@@ -1,19 +0,0 @@
cmake_minimum_required(VERSION 3.22.1)
project(dovi_bridge)
option(DOVI_ENABLE_LIBDOVI "Link real libdovi" ON)
set(DOVI_LIBDOVI_PREBUILT_ROOT "" CACHE PATH "Path to prebuilt libdovi")
add_library(dovi_bridge SHARED dovi_bridge.cpp)
if(DOVI_ENABLE_LIBDOVI)
add_library(dovi_static STATIC IMPORTED)
set_target_properties(dovi_static PROPERTIES
IMPORTED_LOCATION "${DOVI_LIBDOVI_PREBUILT_ROOT}/${ANDROID_ABI}/lib/libdovi.a")
target_compile_definitions(dovi_bridge PRIVATE DOVI_REAL_LINKED=1)
target_include_directories(dovi_bridge PRIVATE ${CMAKE_SOURCE_DIR}/include)
target_link_libraries(dovi_bridge dovi_static log)
else()
target_compile_definitions(dovi_bridge PRIVATE DOVI_REAL_LINKED=0)
target_link_libraries(dovi_bridge log)
endif()
-126
View File
@@ -1,126 +0,0 @@
#include <jni.h>
#include <android/log.h>
#include <cstring>
#include <new>
#define TAG "DoviBridge"
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__)
#define LOGW(...) __android_log_print(ANDROID_LOG_WARN, TAG, __VA_ARGS__)
#if DOVI_REAL_LINKED
#include "include/libdovi/rpu_parser.h"
#endif
static const char* BRIDGE_VERSION = "1.0.0";
extern "C" JNIEXPORT jbyteArray JNICALL
Java_com_edde746_plezy_exoplayer_DoviBridge_nativeConvertDv7RpuToDv81(
JNIEnv *env, jclass, jbyteArray payload, jint mode) {
#if !DOVI_REAL_LINKED
return nullptr;
#else
if (payload == nullptr) return nullptr;
jsize len = env->GetArrayLength(payload);
if (len <= 0) return nullptr;
// Valid RPU NALs are typically <2 KiB; reject unreasonable sizes
if (len > 8192) {
LOGW("RPU payload too large (%d bytes), skipping", len);
return nullptr;
}
// Copy to native heap so libdovi never touches JVM heap memory.
// GetByteArrayElements on ART may return a direct heap pointer; any
// out-of-bounds access by libdovi would corrupt adjacent JVM objects.
auto *buf = new (std::nothrow) uint8_t[static_cast<size_t>(len)];
if (buf == nullptr) return nullptr;
env->GetByteArrayRegion(payload, 0, len, reinterpret_cast<jbyte *>(buf));
if (env->ExceptionCheck()) {
delete[] buf;
return nullptr;
}
// Try dovi_parse_unspec62_nalu first (handles escaped NALs), fallback to dovi_parse_rpu
DoviRpuOpaque *rpu = dovi_parse_unspec62_nalu(buf, static_cast<size_t>(len));
if (rpu == nullptr) {
delete[] buf;
return nullptr;
}
const char *err = dovi_rpu_get_error(rpu);
if (err != nullptr) {
// Fallback: try dovi_parse_rpu (raw RPU without NAL framing)
dovi_rpu_free(rpu);
rpu = dovi_parse_rpu(buf, static_cast<size_t>(len));
if (rpu == nullptr) {
delete[] buf;
return nullptr;
}
err = dovi_rpu_get_error(rpu);
if (err != nullptr) {
LOGW("RPU parse failed: %s", err);
dovi_rpu_free(rpu);
delete[] buf;
return nullptr;
}
}
delete[] buf;
// Convert to target profile (mode 2 = P8.1 with no-op curves)
int32_t ret = dovi_convert_rpu_with_mode(rpu, static_cast<uint8_t>(mode));
if (ret != 0) {
err = dovi_rpu_get_error(rpu);
LOGW("RPU conversion failed (mode %d): %s", mode, err ? err : "unknown");
dovi_rpu_free(rpu);
return nullptr;
}
// Write back as UNSPEC62 NAL
const DoviData *out = dovi_write_unspec62_nalu(rpu);
if (out == nullptr || out->data == nullptr || out->len == 0) {
err = dovi_rpu_get_error(rpu);
LOGW("RPU write failed: %s", err ? err : "unknown");
if (out != nullptr) dovi_data_free(out);
dovi_rpu_free(rpu);
return nullptr;
}
if (out->len > 16384) {
LOGW("RPU output unexpectedly large (%zu bytes), discarding", out->len);
dovi_data_free(out);
dovi_rpu_free(rpu);
return nullptr;
}
jbyteArray result = env->NewByteArray(static_cast<jsize>(out->len));
if (result != nullptr) {
env->SetByteArrayRegion(result, 0, static_cast<jsize>(out->len),
reinterpret_cast<const jbyte *>(out->data));
}
dovi_data_free(out);
dovi_rpu_free(rpu);
return result;
#endif
}
extern "C" JNIEXPORT jboolean JNICALL
Java_com_edde746_plezy_exoplayer_DoviBridge_nativeIsConversionPathReady(
JNIEnv *, jclass) {
#if DOVI_REAL_LINKED
return JNI_TRUE;
#else
return JNI_FALSE;
#endif
}
extern "C" JNIEXPORT jstring JNICALL
Java_com_edde746_plezy_exoplayer_DoviBridge_nativeGetBridgeVersion(
JNIEnv *env, jclass) {
return env->NewStringUTF(BRIDGE_VERSION);
}
@@ -1,747 +0,0 @@
// SPDX-License-Identifier: MIT
#ifndef DOVI_H
#define DOVI_H
#define RPU_PARSER_MAJOR 3
#define RPU_PARSER_MINOR 3
#define RPU_PARSER_PATCH 2
#include <stddef.h>
#include <stdint.h>
#include <stdlib.h>
#include <stdbool.h>
#define DoviNUM_COMPONENTS 3
/**
* Opaque Dolby Vision RPU.
*
* Use dovi_rpu_free to free.
* It should be freed regardless of whether or not an error occurred.
*/
typedef struct DoviRpuOpaque DoviRpuOpaque;
/**
* Struct representing a data buffer
*/
typedef struct {
/**
* Pointer to the data buffer
*/
const uint8_t *data;
/**
* Data buffer size
*/
size_t len;
} DoviData;
/**
* C struct for rpu_data_header()
*/
typedef struct {
/**
* Profile guessed from the values in the header
*/
uint8_t guessed_profile;
/**
* Enhancement layer type (FEL or MEL) if the RPU is profile 7
* null pointer if not profile 7
*/
const char *el_type;
/**
* Deprecated since 3.2.0
* The field is not actually part of the RPU header
*/
uint8_t rpu_nal_prefix;
uint8_t rpu_type;
uint16_t rpu_format;
uint8_t vdr_rpu_profile;
uint8_t vdr_rpu_level;
bool vdr_seq_info_present_flag;
bool chroma_resampling_explicit_filter_flag;
uint8_t coefficient_data_type;
uint64_t coefficient_log2_denom;
uint8_t vdr_rpu_normalized_idc;
bool bl_video_full_range_flag;
uint64_t bl_bit_depth_minus8;
uint64_t el_bit_depth_minus8;
uint64_t vdr_bit_depth_minus8;
bool spatial_resampling_filter_flag;
uint8_t reserved_zero_3bits;
bool el_spatial_resampling_filter_flag;
bool disable_residual_flag;
bool vdr_dm_metadata_present_flag;
bool use_prev_vdr_rpu_flag;
uint64_t prev_vdr_rpu_id;
} DoviRpuDataHeader;
/**
* Struct representing a data buffer
*/
typedef struct {
/**
* Pointer to the data buffer. Can be null if length is zero.
*/
const uint16_t *data;
/**
* Data buffer size
*/
size_t len;
} DoviU16Data;
/**
* Struct representing a data buffer
*/
typedef struct {
/**
* Pointer to the data buffer. Can be null if length is zero.
*/
const uint64_t *data;
/**
* Data buffer size
*/
size_t len;
} DoviU64Data;
/**
* Struct representing a data buffer
*/
typedef struct {
/**
* Pointer to the data buffer
*/
const int64_t *data;
/**
* Data buffer size
*/
size_t len;
} DoviI64Data;
/**
* Struct representing a 2D data buffer
*/
typedef struct {
/**
* Pointer to the list of Data structs
*/
const DoviI64Data *const *list;
/**
* List length
*/
size_t len;
} DoviI64Data2D;
/**
* Struct representing a 2D data buffer
*/
typedef struct {
/**
* Pointer to the list of Data structs
*/
const DoviU64Data *const *list;
/**
* List length
*/
size_t len;
} DoviU64Data2D;
typedef struct {
DoviU64Data poly_order_minus1;
DoviData linear_interp_flag;
DoviI64Data2D poly_coef_int;
DoviU64Data2D poly_coef;
} DoviPolynomialCurve;
/**
* Struct representing a 3D data buffer
*/
typedef struct {
/**
* Pointer to the list of I64Data2D structs
*/
const DoviI64Data2D *const *list;
/**
* List length
*/
size_t len;
} DoviI64Data3D;
/**
* Struct representing a 3D data buffer
*/
typedef struct {
/**
* Pointer to the list of U64Data2D structs
*/
const DoviU64Data2D *const *list;
/**
* List length
*/
size_t len;
} DoviU64Data3D;
typedef struct {
DoviData mmr_order_minus1;
DoviI64Data mmr_constant_int;
DoviU64Data mmr_constant;
DoviI64Data3D mmr_coef_int;
DoviU64Data3D mmr_coef;
} DoviMMRCurve;
typedef struct {
/**
* [2, 9]
*/
uint64_t num_pivots_minus2;
DoviU16Data pivots;
/**
* Consistent for a component
* Luma (component 0): Polynomial = 0
* Chroma (components 1 and 2): MMR = 1
*/
uint8_t mapping_idc;
/**
* mapping_idc = 0, null pointer otherwise
*/
const DoviPolynomialCurve *polynomial;
/**
* mapping_idc = 1, null pointer otherwise
*/
const DoviMMRCurve *mmr;
} DoviReshapingCurve;
/**
* C struct for rpu_data_nlq()
*/
typedef struct {
uint16_t nlq_offset[DoviNUM_COMPONENTS];
uint64_t vdr_in_max_int[DoviNUM_COMPONENTS];
uint64_t vdr_in_max[DoviNUM_COMPONENTS];
uint64_t linear_deadzone_slope_int[DoviNUM_COMPONENTS];
uint64_t linear_deadzone_slope[DoviNUM_COMPONENTS];
uint64_t linear_deadzone_threshold_int[DoviNUM_COMPONENTS];
uint64_t linear_deadzone_threshold[DoviNUM_COMPONENTS];
} DoviRpuDataNlq;
/**
* C struct for rpu_data_mapping()
*/
typedef struct {
uint64_t vdr_rpu_id;
uint64_t mapping_color_space;
uint64_t mapping_chroma_format_idc;
uint64_t num_x_partitions_minus1;
uint64_t num_y_partitions_minus1;
DoviReshapingCurve curves[DoviNUM_COMPONENTS];
/**
* Set to -1 to represent Option::None
*/
int32_t nlq_method_idc;
/**
* Set to -1 to represent Option::None
*/
int32_t nlq_num_pivots_minus2;
/**
* Length of zero when not present. Only present in profile 4 and 7.
*/
DoviU16Data nlq_pred_pivot_value;
/**
* Pointer to `RpuDataNlq` struct, null if not dual layer profile
*/
const DoviRpuDataNlq *nlq;
} DoviRpuDataMapping;
/**
* Statistical analysis of the frame: min, max, avg brightness.
*/
typedef struct {
uint16_t min_pq;
uint16_t max_pq;
uint16_t avg_pq;
} DoviExtMetadataBlockLevel1;
/**
* Creative intent trim passes per target display peak brightness
*/
typedef struct {
uint16_t target_max_pq;
uint16_t trim_slope;
uint16_t trim_offset;
uint16_t trim_power;
uint16_t trim_chroma_weight;
uint16_t trim_saturation_gain;
int16_t ms_weight;
} DoviExtMetadataBlockLevel2;
typedef struct {
/**
* Pointer to the list of ExtMetadataBlockLevel2 structs
*/
const DoviExtMetadataBlockLevel2 *const *list;
/**
* List length
*/
size_t len;
} DoviLevel2BlockList;
/**
* Level 1 offsets.
*/
typedef struct {
uint16_t min_pq_offset;
uint16_t max_pq_offset;
uint16_t avg_pq_offset;
} DoviExtMetadataBlockLevel3;
/**
* Something about temporal stability
*/
typedef struct {
uint16_t anchor_pq;
uint16_t anchor_power;
} DoviExtMetadataBlockLevel4;
/**
* Active area of the picture (letterbox, aspect ratio)
*/
typedef struct {
uint16_t active_area_left_offset;
uint16_t active_area_right_offset;
uint16_t active_area_top_offset;
uint16_t active_area_bottom_offset;
} DoviExtMetadataBlockLevel5;
/**
* ST2086/HDR10 metadata fallback
*/
typedef struct {
uint16_t max_display_mastering_luminance;
uint16_t min_display_mastering_luminance;
uint16_t max_content_light_level;
uint16_t max_frame_average_light_level;
} DoviExtMetadataBlockLevel6;
/**
* Creative intent trim passes per target display peak brightness
* For CM v4.0, L8 metadata only is present and used to compute L2
*
* This block can have varying byte lengths: 10, 12, 13, 19, 25
* Depending on the length, the fields parsed default to zero and may not be set.
* Up to (including):
* - 10: ms_weight
* - 12: target_mid_contrast
* - 13: clip_trim
* - 19: saturation_vector_field[0-5]
* - 25: hue_vector_field[0-5]
*/
typedef struct {
uint64_t length;
uint8_t target_display_index;
uint16_t trim_slope;
uint16_t trim_offset;
uint16_t trim_power;
uint16_t trim_chroma_weight;
uint16_t trim_saturation_gain;
uint16_t ms_weight;
uint16_t target_mid_contrast;
uint16_t clip_trim;
uint8_t saturation_vector_field0;
uint8_t saturation_vector_field1;
uint8_t saturation_vector_field2;
uint8_t saturation_vector_field3;
uint8_t saturation_vector_field4;
uint8_t saturation_vector_field5;
uint8_t hue_vector_field0;
uint8_t hue_vector_field1;
uint8_t hue_vector_field2;
uint8_t hue_vector_field3;
uint8_t hue_vector_field4;
uint8_t hue_vector_field5;
} DoviExtMetadataBlockLevel8;
typedef struct {
/**
* Pointer to the list of ExtMetadataBlockLevel8 structs
*/
const DoviExtMetadataBlockLevel8 *const *list;
/**
* List length
*/
size_t len;
} DoviLevel8BlockList;
/**
* Source/mastering display color primaries
*
* This block can have varying byte lengths: 1 or 17
* Depending on the length, the fields parsed default to zero and may not be set.
* Up to (including):
* - 1: source_primary_index
* - 17: source_primary_{red,green,blue,white}_{x,y}
*/
typedef struct {
uint64_t length;
uint8_t source_primary_index;
uint16_t source_primary_red_x;
uint16_t source_primary_red_y;
uint16_t source_primary_green_x;
uint16_t source_primary_green_y;
uint16_t source_primary_blue_x;
uint16_t source_primary_blue_y;
uint16_t source_primary_white_x;
uint16_t source_primary_white_y;
} DoviExtMetadataBlockLevel9;
/**
* Custom target display information
*
* This block can have varying byte lengths: 5 or 21
* Depending on the length, the fields parsed default to zero and may not be set.
* Up to (including):
* - 5: target_primary_index
* - 21: target_primary_{red,green,blue,white}_{x,y}
*/
typedef struct {
uint64_t length;
uint8_t target_display_index;
uint16_t target_max_pq;
uint16_t target_min_pq;
uint8_t target_primary_index;
uint16_t target_primary_red_x;
uint16_t target_primary_red_y;
uint16_t target_primary_green_x;
uint16_t target_primary_green_y;
uint16_t target_primary_blue_x;
uint16_t target_primary_blue_y;
uint16_t target_primary_white_x;
uint16_t target_primary_white_y;
} DoviExtMetadataBlockLevel10;
typedef struct {
/**
* Pointer to the list of ExtMetadataBlockLevel10 structs
*/
const DoviExtMetadataBlockLevel10 *const *list;
/**
* List length
*/
size_t len;
} DoviLevel10BlockList;
/**
* Content type metadata level
*/
typedef struct {
uint8_t content_type;
uint8_t whitepoint;
bool reference_mode_flag;
uint8_t reserved_byte2;
uint8_t reserved_byte3;
} DoviExtMetadataBlockLevel11;
/**
* Metadata level present in CM v4.0
*/
typedef struct {
uint8_t dm_mode;
uint8_t dm_version_index;
} DoviExtMetadataBlockLevel254;
/**
* Metadata level optionally present in CM v2.9.
* Different display modes (calibration/verify/bypass), debugging
*/
typedef struct {
uint8_t dm_run_mode;
uint8_t dm_run_version;
uint8_t dm_debug0;
uint8_t dm_debug1;
uint8_t dm_debug2;
uint8_t dm_debug3;
} DoviExtMetadataBlockLevel255;
/**
* C struct for the list of ext_metadata_block()
*/
typedef struct {
/**
* Number of metadata blocks
*/
uint64_t num_ext_blocks;
const DoviExtMetadataBlockLevel1 *level1;
DoviLevel2BlockList level2;
const DoviExtMetadataBlockLevel3 *level3;
const DoviExtMetadataBlockLevel4 *level4;
const DoviExtMetadataBlockLevel5 *level5;
const DoviExtMetadataBlockLevel6 *level6;
DoviLevel8BlockList level8;
const DoviExtMetadataBlockLevel9 *level9;
DoviLevel10BlockList level10;
const DoviExtMetadataBlockLevel11 *level11;
const DoviExtMetadataBlockLevel254 *level254;
const DoviExtMetadataBlockLevel255 *level255;
} DoviDmData;
/**
* C struct for vdr_dm_data()
*/
typedef struct {
bool compressed;
uint64_t affected_dm_metadata_id;
uint64_t current_dm_metadata_id;
uint64_t scene_refresh_flag;
int16_t ycc_to_rgb_coef0;
int16_t ycc_to_rgb_coef1;
int16_t ycc_to_rgb_coef2;
int16_t ycc_to_rgb_coef3;
int16_t ycc_to_rgb_coef4;
int16_t ycc_to_rgb_coef5;
int16_t ycc_to_rgb_coef6;
int16_t ycc_to_rgb_coef7;
int16_t ycc_to_rgb_coef8;
uint32_t ycc_to_rgb_offset0;
uint32_t ycc_to_rgb_offset1;
uint32_t ycc_to_rgb_offset2;
int16_t rgb_to_lms_coef0;
int16_t rgb_to_lms_coef1;
int16_t rgb_to_lms_coef2;
int16_t rgb_to_lms_coef3;
int16_t rgb_to_lms_coef4;
int16_t rgb_to_lms_coef5;
int16_t rgb_to_lms_coef6;
int16_t rgb_to_lms_coef7;
int16_t rgb_to_lms_coef8;
uint16_t signal_eotf;
uint16_t signal_eotf_param0;
uint16_t signal_eotf_param1;
uint32_t signal_eotf_param2;
uint8_t signal_bit_depth;
uint8_t signal_color_space;
uint8_t signal_chroma_format;
uint8_t signal_full_range_flag;
uint16_t source_min_pq;
uint16_t source_max_pq;
uint16_t source_diagonal;
DoviDmData dm_data;
} DoviVdrDmData;
/**
* Heap allocated list of valid RPU pointers
*/
typedef struct {
DoviRpuOpaque *const *list;
size_t len;
const char *error;
} DoviRpuOpaqueList;
#ifdef __cplusplus
extern "C" {
#endif // __cplusplus
/**
* # Safety
* The pointer to the data must be valid.
*
* Parse a Dolby Vision RPU from unescaped byte buffer.
* Adds an error if the parsing fails.
*/
DoviRpuOpaque *dovi_parse_rpu(const uint8_t *buf, size_t len);
/**
* # Safety
* The pointer to the data must be valid.
*
* Parse a Dolby Vision from a AV1 ITU-T T.35 metadata OBU byte buffer.
* Adds an error if the parsing fails.
*/
DoviRpuOpaque *dovi_parse_itu_t35_dovi_metadata_obu(const uint8_t *buf, size_t len);
/**
* # Safety
* The pointer to the data must be valid.
*
* Parse a Dolby Vision from a (possibly) escaped HEVC UNSPEC 62 NAL unit byte buffer.
* Adds an error if the parsing fails.
*/
DoviRpuOpaque *dovi_parse_unspec62_nalu(const uint8_t *buf, size_t len);
/**
* # Safety
* The pointer to the opaque struct must be valid.
* Avoid using on opaque pointers obtained through `dovi_parse_rpu_bin_file`.
*
* Free the RpuOpaque
*/
void dovi_rpu_free(DoviRpuOpaque *ptr);
/**
* # Safety
* The pointer to the opaque struct must be valid.
*
* Get the last logged error for the RpuOpaque operations.
*
* On invalid parsing, an error is added.
* The user should manually verify if there is an error, as the parsing does not return an error code.
*/
const char *dovi_rpu_get_error(const DoviRpuOpaque *ptr);
/**
* # Safety
* The data pointer should exist, and be allocated by Rust.
*
* Free a Data buffer
*/
void dovi_data_free(const DoviData *data);
/**
* # Safety
* The struct pointer must be valid.
*
* Writes the encoded RPU as a byte buffer.
* If an error occurs in the writing, it is logged to RpuOpaque.error
*/
const DoviData *dovi_write_rpu(DoviRpuOpaque *ptr);
/**
* # Safety
* The struct pointer must be valid.
*
* Writes the encoded RPU, escapes the bytes for HEVC and prepends the buffer with 0x7C01.
* If an error occurs in the writing, it is logged to RpuOpaque.error
*/
const DoviData *dovi_write_unspec62_nalu(DoviRpuOpaque *ptr);
/**
* # Safety
* The struct pointer must be valid.
* The mode must be between 0 and 4.
*
* Converts the RPU to be compatible with a different Dolby Vision profile.
* Possible modes:
* - 0: Don't modify the RPU
* - 1: Converts the RPU to be MEL compatible
* - 2: Converts the RPU to be profile 8.1 compatible. Both luma and chroma mapping curves are set to no-op.
* This mode handles source profiles 5, 7 and 8.
* - 3: Converts to static profile 8.4
* - 4: Converts to profile 8.1 preserving luma and chroma mapping. Old mode 2 behaviour.
*
* If an error occurs, it is logged to RpuOpaque.error.
* Returns 0 if successful, -1 otherwise.
*/
int32_t dovi_convert_rpu_with_mode(DoviRpuOpaque *ptr,
uint8_t mode);
/**
* # Safety
* The pointer to the opaque struct must be valid.
*
* Get the DoVi RPU header struct.
*/
const DoviRpuDataHeader *dovi_rpu_get_header(const DoviRpuOpaque *ptr);
/**
* # Safety
* The pointer to the struct must be valid.
*
* Frees the memory used by the RPU header.
*/
void dovi_rpu_free_header(const DoviRpuDataHeader *ptr);
/**
* # Safety
* The pointer to the opaque struct must be valid.
*
* Get the DoVi RpuDataMapping struct.
*/
const DoviRpuDataMapping *dovi_rpu_get_data_mapping(const DoviRpuOpaque *ptr);
/**
* # Safety
* The pointer to the struct must be valid.
*
* Frees the memory used by the RpuDataMapping.
*/
void dovi_rpu_free_data_mapping(const DoviRpuDataMapping *ptr);
/**
* # Safety
* The pointer to the opaque struct must be valid.
*
* Get the DoVi VdrDmData struct.
*/
const DoviVdrDmData *dovi_rpu_get_vdr_dm_data(const DoviRpuOpaque *ptr);
/**
* # Safety
* The pointer to the struct must be valid.
*
* Frees the memory used by the VdrDmData struct.
*/
void dovi_rpu_free_vdr_dm_data(const DoviVdrDmData *ptr);
/**
* # Safety
* The pointer to the file path must be valid.
*
* Parses an existing RPU binary file.
*
* Returns the heap allocated `DoviRpuList` as a pointer.
* The returned pointer may be null, or the list could be empty if an error occurred.
*/
const DoviRpuOpaqueList *dovi_parse_rpu_bin_file(const char *path);
/**
* # Safety
* The pointer to the struct must be valid.
*
* Frees the memory used by the DoviRpuOpaqueList struct.
*/
void dovi_rpu_list_free(const DoviRpuOpaqueList *ptr);
/**
* # Safety
* The struct pointer must be valid.
*
* Sets the L5 metadata active area offsets.
* If there is no L5 block present, it is created with the offsets.
*/
int32_t dovi_rpu_set_active_area_offsets(DoviRpuOpaque *ptr,
uint16_t left,
uint16_t right,
uint16_t top,
uint16_t bottom);
/**
* # Safety
* The struct pointer must be valid.
*
* Converts the existing reshaping/mapping to become no-op.
*/
int32_t dovi_rpu_remove_mapping(DoviRpuOpaque *ptr);
/**
* # Safety
* The struct pointer must be valid.
*
* Writes the encoded RPU as `itu_t_t35_payload_bytes` for AV1 ITU-T T.35 metadata OBU
* If an error occurs in the writing, it is logged to RpuOpaque.error
*/
const DoviData *dovi_write_av1_rpu_metadata_obu_t35_payload(DoviRpuOpaque *ptr);
/**
* # Safety
* The struct pointer must be valid.
*
* Writes the encoded RPU a complete AV1 `metadata_itut_t35()` OBU
* If an error occurs in the writing, it is logged to RpuOpaque.error
*/
const DoviData *dovi_write_av1_rpu_metadata_obu_t35_complete(DoviRpuOpaque *ptr);
#ifdef __cplusplus
} // extern "C"
#endif // __cplusplus
#endif /* DOVI_H */
@@ -1,407 +0,0 @@
package com.edde746.plezy
import android.content.Intent
import android.graphics.SurfaceTexture
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.app.AppOpsManager
import android.app.PictureInPictureParams
import android.content.Context
import android.content.res.Configuration
import android.util.Log
import android.util.Rational
import android.view.KeyEvent
import android.view.TextureView
import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager
import android.widget.FrameLayout
import androidx.core.content.FileProvider
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterShellArgs
import io.flutter.embedding.android.FlutterTextureView
import io.flutter.embedding.android.RenderMode
import io.flutter.embedding.android.TransparencyMode
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import com.edde746.plezy.exoplayer.ExoPlayerPlugin
import com.edde746.plezy.mpv.MpvPlayerPlugin
import com.edde746.plezy.shared.ThemeHelper
import com.edde746.plezy.watchnext.WatchNextPlugin
import java.io.File
class MainActivity : FlutterActivity() {
companion object {
private const val TAG = "MainActivity"
var usingSkia = false
}
private val PIP_CHANNEL = "com.plezy/pip"
private val EXTERNAL_PLAYER_CHANNEL = "com.plezy/external_player"
private val THEME_CHANNEL = "com.plezy/theme"
private var watchNextPlugin: WatchNextPlugin? = null
// Auto PiP state
private var autoPipReady = false
private var autoPipWidth: Int = 16
private var autoPipHeight: Int = 9
private fun isAndroidTvDevice(): Boolean {
return packageManager.hasSystemFeature("android.software.leanback")
}
override fun onCreate(savedInstanceState: Bundle?) {
// Apply persisted theme color to the window background before anything
// else renders. This prevents a white flash between the native splash
// screen and Flutter's first frame for non-default themes (e.g. OLED).
val prefs = getSharedPreferences("plezy_prefs", Context.MODE_PRIVATE)
val savedTheme = prefs.getString("splash_theme", null)
ThemeHelper.themeColor(savedTheme)?.let { window.decorView.setBackgroundColor(it) }
super.onCreate(savedInstanceState)
// Disable the Android splash screen fade-out animation to avoid
// a flicker before Flutter draws its first frame.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
splashScreen.setOnExitAnimationListener { splashScreenView -> splashScreenView.remove() }
}
// Disable Android's default focus highlight ring that appears when using
// D-pad navigation so the Flutter UI can render its own focus state.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
window.decorView.defaultFocusHighlightEnabled = false
}
// Wrap the content view in a layout that intercepts DPAD key events
// before the IME input stage, which can consume DPAD direction events
// from virtual remotes before they reach Flutter's key handler.
val content = findViewById<ViewGroup>(android.R.id.content)
val wrapper = object : FrameLayout(this) {
override fun dispatchKeyEventPreIme(event: KeyEvent): Boolean {
when (event.keyCode) {
KeyEvent.KEYCODE_DPAD_UP,
KeyEvent.KEYCODE_DPAD_DOWN,
KeyEvent.KEYCODE_DPAD_LEFT,
KeyEvent.KEYCODE_DPAD_RIGHT,
KeyEvent.KEYCODE_DPAD_CENTER -> {
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
if (!imm.isAcceptingText) {
super.dispatchKeyEvent(event)
return true
}
}
}
return super.dispatchKeyEventPreIme(event)
}
}
while (content.childCount > 0) {
val child = content.getChildAt(0)
content.removeViewAt(0)
wrapper.addView(child)
}
content.addView(wrapper, ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT))
// Handle Watch Next deep link from initial launch
handleWatchNextIntent(intent)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
// Handle Watch Next deep link when app is already running
handleWatchNextIntent(intent)
}
private fun handleWatchNextIntent(intent: Intent?) {
val contentId = WatchNextPlugin.handleIntent(intent)
if (contentId != null) {
// Notify the plugin to send event to Flutter
watchNextPlugin?.notifyDeepLink(contentId)
}
}
override fun getFlutterShellArgs(): FlutterShellArgs {
val args = super.getFlutterShellArgs()
usingSkia = shouldDisableImpeller()
if (usingSkia) args.add("--enable-impeller=false")
return args
}
private fun shouldDisableImpeller(): Boolean {
// Android TV devices — weaker GPUs, less Impeller testing
if (packageManager.hasSystemFeature("android.software.leanback")) return true
// Google Tensor SoC (Mali GPU) — Pixel 6+
// SOC_MODEL may return marketing name ("Tensor G2") or internal ID ("GS201")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val soc = Build.SOC_MODEL
if (soc.startsWith("Tensor", ignoreCase = true) ||
soc.startsWith("GS", ignoreCase = true)) return true
}
// NVIDIA Tegra (Shield TV)
if (Build.MANUFACTURER.equals("NVIDIA", ignoreCase = true)) return true
// Huawei/HONOR Kirin SoCs use Mali GPUs
if (Build.MANUFACTURER.equals("Huawei", ignoreCase = true) ||
Build.MANUFACTURER.equals("HONOR", ignoreCase = true)) return true
return false
}
override fun getRenderMode(): RenderMode {
// Use TextureView so Flutter doesn't occupy a SurfaceView layer.
// This allows the libass subtitle SurfaceView to sit between video and Flutter UI.
return RenderMode.texture
}
override fun getTransparencyMode(): TransparencyMode {
// Keep Flutter transparent so video/subtitles are visible below.
return TransparencyMode.transparent
}
override fun onFlutterTextureViewCreated(flutterTextureView: FlutterTextureView) {
val original = flutterTextureView.surfaceTextureListener ?: return
val handler = Handler(Looper.getMainLooper())
var pendingResize: Runnable? = null
var lastWidth = 0
var lastHeight = 0
flutterTextureView.surfaceTextureListener = object : TextureView.SurfaceTextureListener {
override fun onSurfaceTextureAvailable(surface: SurfaceTexture, w: Int, h: Int) {
original.onSurfaceTextureAvailable(surface, w, h)
}
override fun onSurfaceTextureSizeChanged(surface: SurfaceTexture, w: Int, h: Int) {
if (w == lastWidth && h == lastHeight) return
lastWidth = w; lastHeight = h
pendingResize?.let { handler.removeCallbacks(it) }
pendingResize = Runnable {
if (flutterTextureView.isAvailable) {
original.onSurfaceTextureSizeChanged(surface, w, h)
}
}
handler.postDelayed(pendingResize!!, 100)
}
override fun onSurfaceTextureUpdated(surface: SurfaceTexture) {
original.onSurfaceTextureUpdated(surface)
}
override fun onSurfaceTextureDestroyed(surface: SurfaceTexture): Boolean {
pendingResize?.let { handler.removeCallbacks(it) }
pendingResize = null
lastWidth = 0; lastHeight = 0
return original.onSurfaceTextureDestroyed(surface)
}
}
}
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
flutterEngine.plugins.add(MpvPlayerPlugin())
flutterEngine.plugins.add(ExoPlayerPlugin())
// External player: open local video files with proper content:// URIs
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, EXTERNAL_PLAYER_CHANNEL).setMethodCallHandler { call, result ->
when (call.method) {
"openVideo" -> {
val filePath = call.argument<String>("filePath")
val packageName = call.argument<String>("package")
if (filePath == null) {
result.error("INVALID_ARGUMENT", "filePath is required", null)
return@setMethodCallHandler
}
try {
val uri: Uri
val grantRead: Boolean
if (filePath.startsWith("http://") || filePath.startsWith("https://")) {
uri = Uri.parse(filePath)
grantRead = false
} else if (filePath.startsWith("content://")) {
uri = Uri.parse(filePath)
grantRead = true
} else {
val path = if (filePath.startsWith("file://")) filePath.removePrefix("file://") else filePath
uri = FileProvider.getUriForFile(this, "com.edde746.plezy.fileprovider", File(path))
grantRead = true
}
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "video/*")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
if (grantRead) {
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
if (packageName != null) {
setPackage(packageName)
}
}
startActivity(intent)
result.success(true)
} catch (e: android.content.ActivityNotFoundException) {
result.error("APP_NOT_FOUND", "No app found for package: $packageName", null)
} catch (e: Exception) {
result.error("LAUNCH_FAILED", e.message ?: e.javaClass.simpleName, null)
}
}
else -> result.notImplemented()
}
}
// Splash screen theme: persist user's chosen theme for next launch (API 31+)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, THEME_CHANNEL).setMethodCallHandler { call, result ->
when (call.method) {
"getRenderer" -> result.success(if (usingSkia) "Skia" else "Impeller")
"hasCamera" -> result.success(
packageManager.hasSystemFeature(android.content.pm.PackageManager.FEATURE_CAMERA_ANY)
)
"setSplashTheme" -> {
val mode = call.argument<String>("mode")
// Persist for next cold start & update window background now
getSharedPreferences("plezy_prefs", Context.MODE_PRIVATE)
.edit().putString("splash_theme", mode).apply()
ThemeHelper.themeColor(mode)?.let { window.decorView.setBackgroundColor(it) }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val themeId = when (mode) {
"dark" -> R.style.SplashTheme_Dark
"oled" -> R.style.SplashTheme_Oled
"light" -> R.style.SplashTheme_Light
"system" -> android.content.res.Resources.ID_NULL
else -> android.content.res.Resources.ID_NULL
}
splashScreen.setSplashScreenTheme(themeId)
}
result.success(true)
}
else -> result.notImplemented()
}
}
// Register Watch Next plugin and keep reference for deep link handling
watchNextPlugin = WatchNextPlugin()
flutterEngine.plugins.add(watchNextPlugin!!)
MethodChannel( flutterEngine.dartExecutor.binaryMessenger, PIP_CHANNEL ).setMethodCallHandler { call, result ->
when (call.method) {
"isSupported" -> {
result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !isAndroidTvDevice())
}
"enter" -> {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
result.success(mapOf("success" to false, "errorCode" to "android_version"))
return@setMethodCallHandler
}
if (isAndroidTvDevice()) {
result.success(mapOf("success" to false, "errorCode" to "not_supported"))
return@setMethodCallHandler
}
if (!isPipPermissionGranted()) {
result.success(mapOf("success" to false, "errorCode" to "permission_disabled"))
return@setMethodCallHandler
}
try {
val width = call.argument<Int>("width") ?: 16
val height = call.argument<Int>("height") ?: 9
val params = buildPipParams(width, height)
val success = enterPictureInPictureMode(params)
if (success) {
result.success(mapOf("success" to true))
} else {
result.success(mapOf("success" to false, "errorCode" to "failed"))
}
} catch (e: IllegalStateException) {
result.success(mapOf("success" to false, "errorCode" to "not_supported"))
} catch (e: Exception) {
result.success(mapOf("success" to false, "errorCode" to "unknown", "errorMessage" to (e.message ?: "Unknown error")))
}
}
"setAutoPipReady" -> {
if (isAndroidTvDevice()) {
autoPipReady = false
result.success(true)
return@setMethodCallHandler
}
autoPipReady = call.argument<Boolean>("ready") ?: false
autoPipWidth = call.argument<Int>("width") ?: 16
autoPipHeight = call.argument<Int>("height") ?: 9
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
try {
val params = buildPipParams(autoPipWidth, autoPipHeight, autoEnterEnabled = autoPipReady)
setPictureInPictureParams(params)
} catch (e: Exception) {
Log.w(TAG, "Failed to set auto-PiP params", e)
}
}
result.success(true)
}
else -> result.notImplemented()
}
}
}
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean,newConfig: Configuration) {
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
flutterEngine?.let { engine ->
MethodChannel(engine.dartExecutor.binaryMessenger, PIP_CHANNEL).invokeMethod("onPipChanged", isInPictureInPictureMode)
engine.plugins.get(ExoPlayerPlugin::class.java)?.let { plugin ->
(plugin as? ExoPlayerPlugin)?.onPipModeChanged(isInPictureInPictureMode)
}
}
}
override fun onUserLeaveHint() {
super.onUserLeaveHint()
// Auto PiP for API 26-30 (API 31+ uses setAutoEnterEnabled)
if (!isAndroidTvDevice() &&
Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
Build.VERSION.SDK_INT < Build.VERSION_CODES.S &&
autoPipReady && isPipPermissionGranted()) {
try {
// Notify Flutter to prepare video filter before PiP
flutterEngine?.dartExecutor?.binaryMessenger?.let { messenger ->
MethodChannel(messenger, PIP_CHANNEL).invokeMethod("onAutoPipEntering", null)
}
val params = buildPipParams(autoPipWidth, autoPipHeight)
enterPictureInPictureMode(params)
} catch (e: Exception) {
Log.w(TAG, "Failed to enter auto-PiP", e)
}
}
}
private fun isPipPermissionGranted(): Boolean {
val appOpsManager = getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager
return appOpsManager.checkOpNoThrow(
AppOpsManager.OPSTR_PICTURE_IN_PICTURE,
applicationInfo.uid,
packageName
) == AppOpsManager.MODE_ALLOWED
}
private fun buildPipParams(width: Int, height: Int, autoEnterEnabled: Boolean? = null): PictureInPictureParams {
val (w, h) = if (width <= 0 || height <= 0) {
Pair(16, 9)
} else {
val ratio = width.toFloat() / height.toFloat()
when {
ratio < 1f / 2.39f -> Pair(100, 239)
ratio > 2.39f -> Pair(239, 100)
else -> Pair(width, height)
}
}
val builder = PictureInPictureParams.Builder()
.setAspectRatio(Rational(w, h))
if (autoEnterEnabled != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
builder.setAutoEnterEnabled(autoEnterEnabled)
}
return builder.build()
}
}
@@ -1,167 +0,0 @@
package com.edde746.plezy.exoplayer
import android.util.Log
import androidx.media3.common.C
import androidx.media3.extractor.Extractor
import androidx.media3.extractor.ExtractorInput
import androidx.media3.extractor.ExtractorOutput
import androidx.media3.extractor.PositionHolder
import androidx.media3.extractor.SeekMap
import androidx.media3.extractor.SeekPoint
import androidx.media3.extractor.TrackOutput
/**
* Extractor wrapper that enables approximate seeking for MKV files without Cues.
*
* When the underlying extractor reports an [SeekMap.Unseekable] seek map (i.e. the MKV
* has no Cues element), this wrapper replaces it with a proportional byte-position
* estimate and scans for the nearest Cluster boundary after seeking.
*/
@androidx.media3.common.util.UnstableApi
class CuelessSeekExtractorWrapper(
private val delegate: Extractor,
) : Extractor {
companion object {
private const val TAG = "CuelessSeek"
// MKV Cluster element ID: 0x1F43B675 (4-byte EBML Class-D ID)
private val CLUSTER_ID = byteArrayOf(0x1F, 0x43, 0xB6.toByte(), 0x75)
private const val SCAN_BUFFER_SIZE = 8192
// Max bytes to scan for a Cluster boundary before giving up
private const val MAX_SCAN_BYTES = 1024 * 1024 // 1 MB
}
private var inputLength: Long = C.LENGTH_UNSET.toLong()
private var needsClusterResync = false
private var isApproximateSeeking = false
private var pendingSeekTimeUs: Long = C.TIME_UNSET
override fun sniff(input: ExtractorInput): Boolean = delegate.sniff(input)
override fun init(output: ExtractorOutput) {
delegate.init(SeekInterceptingOutput(output))
}
override fun read(input: ExtractorInput, seekPosition: PositionHolder): Int {
if (inputLength == C.LENGTH_UNSET.toLong()) {
inputLength = input.length
}
if (needsClusterResync) {
needsClusterResync = false
return scanForCluster(input, seekPosition)
}
return delegate.read(input, seekPosition)
}
override fun seek(position: Long, timeUs: Long) {
if (isApproximateSeeking && position > 0) {
needsClusterResync = true
pendingSeekTimeUs = timeUs
}
delegate.seek(position, timeUs)
}
override fun release() = delegate.release()
/**
* Scan forward from the current input position to find the next MKV Cluster
* element ID (0x1F43B675). Returns [Extractor.RESULT_SEEK] with the Cluster's
* byte position so ExoPlayer repositions the DataSource there.
*/
private fun scanForCluster(input: ExtractorInput, seekPosition: PositionHolder): Int {
val buffer = ByteArray(SCAN_BUFFER_SIZE)
var totalScanned = 0L
// Carry over last 3 bytes across buffer boundaries to detect split IDs
var carry = ByteArray(0)
while (totalScanned < MAX_SCAN_BYTES) {
val toRead = minOf(SCAN_BUFFER_SIZE, (MAX_SCAN_BYTES - totalScanned).toInt())
val bytesRead: Int
try {
bytesRead = input.read(buffer, 0, toRead)
} catch (_: Exception) {
break
}
if (bytesRead == C.RESULT_END_OF_INPUT) break
// Combine carry + new data for scanning
val scanData = if (carry.isNotEmpty()) carry + buffer.copyOf(bytesRead) else buffer.copyOf(bytesRead)
for (i in 0..scanData.size - 4) {
if (scanData[i] == CLUSTER_ID[0] &&
scanData[i + 1] == CLUSTER_ID[1] &&
scanData[i + 2] == CLUSTER_ID[2] &&
scanData[i + 3] == CLUSTER_ID[3]
) {
// Compute the absolute byte position of this Cluster
val clusterPosition = input.position - bytesRead - carry.size + i
Log.d(TAG, "Found Cluster at byte $clusterPosition (scanned ${totalScanned + i} bytes)")
seekPosition.position = clusterPosition
delegate.seek(clusterPosition, pendingSeekTimeUs)
pendingSeekTimeUs = C.TIME_UNSET
return Extractor.RESULT_SEEK
}
}
// Keep last 3 bytes as carry for next iteration
carry = if (scanData.size >= 3) scanData.copyOfRange(scanData.size - 3, scanData.size) else scanData.copyOf()
totalScanned += bytesRead
}
// Failed to find a Cluster — fall back to position 0
Log.w(TAG, "No Cluster found after scanning $totalScanned bytes, resetting to start")
seekPosition.position = 0
delegate.seek(0, 0)
return Extractor.RESULT_SEEK
}
/**
* ExtractorOutput wrapper that intercepts [seekMap] calls to replace
* [SeekMap.Unseekable] with an approximate proportional SeekMap.
*/
private inner class SeekInterceptingOutput(
private val delegate: ExtractorOutput,
) : ExtractorOutput {
override fun track(id: Int, type: Int): TrackOutput = delegate.track(id, type)
override fun endTracks() = delegate.endTracks()
override fun seekMap(seekMap: SeekMap) {
if (seekMap is SeekMap.Unseekable) {
val durationUs = seekMap.durationUs
if (durationUs != C.TIME_UNSET && durationUs > 0) {
Log.i(TAG, "Replacing Unseekable with approximate SeekMap (duration=${durationUs / 1_000_000}s)")
isApproximateSeeking = true
delegate.seekMap(ApproximateSeekMap(durationUs))
return
}
}
// File has real Cues or unknown duration — pass through
isApproximateSeeking = false
delegate.seekMap(seekMap)
}
}
/**
* Approximate SeekMap that estimates byte positions proportionally.
* Used when the MKV has no Cues but has a known duration.
*/
private inner class ApproximateSeekMap(
private val durationUs: Long,
) : SeekMap {
override fun isSeekable(): Boolean = true
override fun getDurationUs(): Long = durationUs
override fun getSeekPoints(timeUs: Long): SeekMap.SeekPoints {
val length = inputLength
if (length == C.LENGTH_UNSET.toLong() || durationUs <= 0) {
return SeekMap.SeekPoints(SeekPoint(0, 0))
}
val clampedTimeUs = timeUs.coerceIn(0, durationUs)
val position = (clampedTimeUs.toDouble() / durationUs * length).toLong().coerceIn(0, length)
return SeekMap.SeekPoints(SeekPoint(clampedTimeUs, position))
}
}
}
@@ -1,85 +0,0 @@
package com.edde746.plezy.exoplayer
import android.media.MediaCodecInfo
import android.media.MediaCodecList
import android.os.Build
import android.util.Log
enum class DvConversionMode { DISABLED, DV81, HEVC_STRIP }
object DoviBridge {
private const val TAG = "DoviBridge"
private val nativeLoaded: Boolean by lazy {
try {
System.loadLibrary("dovi_bridge")
true
} catch (_: UnsatisfiedLinkError) {
Log.w(TAG, "Native lib not found")
false
}
}
fun isAvailable(): Boolean = nativeLoaded &&
runCatching { nativeIsConversionPathReady() }.getOrDefault(false)
private fun deviceSupportsDvProfile(profile: Int, minApi: Int = 0): Boolean {
try {
if (Build.VERSION.SDK_INT < minApi) return false
val codecList = MediaCodecList(MediaCodecList.REGULAR_CODECS)
return codecList.codecInfos.any { info ->
!info.isEncoder && info.supportedTypes.any { type ->
type.equals("video/dolby-vision", ignoreCase = true) &&
info.getCapabilitiesForType(type).profileLevels.any { it.profile == profile }
}
}
} catch (e: Exception) {
Log.w(TAG, "Failed to query DV profile $profile support", e)
return false
}
}
val deviceSupportsDvProfile7: Boolean by lazy {
deviceSupportsDvProfile(MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheDtr)
.also { Log.i(TAG, "Device DV Profile 7 support: $it") }
}
val deviceSupportsDvProfile8: Boolean by lazy {
deviceSupportsDvProfile(MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheSt, minApi = 27)
.also { Log.i(TAG, "Device DV Profile 8 support: $it") }
}
fun getConversionMode(): DvConversionMode = when {
!isAvailable() -> DvConversionMode.DISABLED
deviceSupportsDvProfile7 -> DvConversionMode.DISABLED // try native first; ExoPlayerCore retries with conversion on failure
deviceSupportsDvProfile8 -> DvConversionMode.DV81
else -> DvConversionMode.HEVC_STRIP
}
/** Get the fallback mode when native DV7 decoding fails. */
fun getDv7FallbackMode(): DvConversionMode = when {
deviceSupportsDvProfile8 -> DvConversionMode.DV81
else -> DvConversionMode.HEVC_STRIP
}
fun convertRpuNalu(payload: ByteArray, mode: Int = 2): ByteArray? {
if (!isAvailable() || payload.isEmpty()) return null
return runCatching { nativeConvertDv7RpuToDv81(payload, mode) }
.onFailure { Log.w(TAG, "RPU conversion failed: ${it.message}") }
.getOrNull()
}
fun getVersion(): String? {
if (!nativeLoaded) return null
return runCatching { nativeGetBridgeVersion() }.getOrNull()
}
@JvmStatic
private external fun nativeConvertDv7RpuToDv81(payload: ByteArray, mode: Int): ByteArray?
@JvmStatic
private external fun nativeIsConversionPathReady(): Boolean
@JvmStatic
private external fun nativeGetBridgeVersion(): String
}
@@ -1,437 +0,0 @@
package com.edde746.plezy.exoplayer
import android.util.Log
import androidx.media3.common.DataReader
import androidx.media3.common.Format
import androidx.media3.common.MimeTypes
import androidx.media3.common.util.ParsableByteArray
import androidx.media3.extractor.TrackOutput
/**
* TrackOutput wrapper that processes DV Profile 7 HEVC samples based on conversion mode:
*
* - DV81: Convert RPU NALs via libdovi to Profile 8.1, present as video/dolby-vision
* with dvhe.08.XX codec string. Preserves dynamic tone mapping metadata.
* - HEVC_STRIP: Strip all DV enhancement layers, present as plain video/hevc.
*
* Two modes of NAL framing (auto-detected):
* - Annex B (MKV path): MatroskaExtractor outputs 00 00 00 01 start codes
* - Length-prefixed (MP4 path): Mp4Extractor outputs 4-byte big-endian lengths
*
* NAL processing:
* - Type 62 (UNSPEC62): DV RPU → convert (DV81) or strip (HEVC_STRIP)
* - Type 63 (UNSPEC63): DV Enhancement Layer → strip
* - nuh_layer_id > 0: Enhancement layer NAL → strip
* - All retained NALs: normalize nuh_layer_id to 0
*
* All buffers are reused across samples to minimize GC pressure on the hot path.
*/
class DoviConvertingTrackOutput(
private val delegate: TrackOutput,
private val dvMode: DvConversionMode = DvConversionMode.HEVC_STRIP,
) : TrackOutput {
companion object {
private const val TAG = "DoviConvertTrack"
private const val NAL_TYPE_UNSPEC62 = 62
private const val NAL_TYPE_UNSPEC63 = 63
private const val LIBDOVI_MODE_TO_81 = 2
private const val INITIAL_BUFFER_SIZE = 256 * 1024
private const val READ_CHUNK = 64 * 1024
private val ANNEX_B_START_CODE = byteArrayOf(0, 0, 0, 1)
}
var conversionActive = false
private set
var strippedNalCount = 0L
private set
var convertedRpuCount = 0L
private set
// Reusable buffers — grown as needed, never shrunk
private var sampleBuf = ByteArray(INITIAL_BUFFER_SIZE)
private var sampleLen = 0
private var outputBuf = ByteArray(INITIAL_BUFFER_SIZE)
private var outputLen = 0
private var readBuf = ByteArray(READ_CHUNK)
private val outputParsable = ParsableByteArray()
private var buffering = false
// Sample counter for periodic logging
private var sampleCount = 0L
override fun format(format: Format) {
if (!conversionActive) {
val codecs = format.codecs
if (codecs != null && codecs.startsWith("dvhe.07")) {
conversionActive = true
Log.i(TAG, "DV Profile 7 detected ($codecs), mode=$dvMode")
Log.i(TAG, "Original format: mime=${format.sampleMimeType}, codecs=$codecs, " +
"initData=${format.initializationData.size} entries " +
"(${format.initializationData.mapIndexed { i, d -> "$i:${d.size}B" }.joinToString()})")
val newFormat = when (dvMode) {
DvConversionMode.DV81 -> {
// Parse DV level from codec string: "dvhe.07.06" → 6
val level = codecs.split('.').getOrNull(2)?.toIntOrNull() ?: 6
val newCodecs = "dvhe.08.%02d".format(level)
val dvConfigRecord = buildDv81ConfigRecord(level)
Log.i(TAG, "DV81: rewriting to $newCodecs, config=${dvConfigRecord.size}B")
format.buildUpon()
.setSampleMimeType(MimeTypes.VIDEO_DOLBY_VISION)
.setCodecs(newCodecs)
.setInitializationData(
if (format.initializationData.isNotEmpty())
listOf(format.initializationData[0], dvConfigRecord)
else
listOf(ByteArray(0), dvConfigRecord)
)
.build()
}
else -> {
// HEVC_STRIP: present as plain HEVC
Log.i(TAG, "HEVC_STRIP: rewriting to video/hevc")
format.buildUpon()
.setSampleMimeType(MimeTypes.VIDEO_H265)
.setCodecs(null)
.setInitializationData(
if (format.initializationData.isNotEmpty())
listOf(format.initializationData[0])
else
emptyList()
)
.build()
}
}
Log.i(TAG, "Rewritten format: mime=${newFormat.sampleMimeType}, " +
"codecs=${newFormat.codecs}, initData=${newFormat.initializationData.size} entries")
delegate.format(newFormat)
return
}
}
delegate.format(format)
}
override fun sampleData(
input: DataReader, length: Int, allowEndOfInput: Boolean, sampleDataPart: Int
): Int {
if (!conversionActive) {
return delegate.sampleData(input, length, allowEndOfInput, sampleDataPart)
}
buffering = true
if (readBuf.size < length) readBuf = ByteArray(length)
val bytesRead = input.read(readBuf, 0, length)
if (bytesRead > 0) {
ensureSampleCapacity(sampleLen + bytesRead)
System.arraycopy(readBuf, 0, sampleBuf, sampleLen, bytesRead)
sampleLen += bytesRead
}
return bytesRead
}
override fun sampleData(data: ParsableByteArray, length: Int, sampleDataPart: Int) {
if (!conversionActive) {
delegate.sampleData(data, length, sampleDataPart)
return
}
buffering = true
ensureSampleCapacity(sampleLen + length)
data.readBytes(sampleBuf, sampleLen, length)
sampleLen += length
}
override fun sampleMetadata(
timeUs: Long, flags: Int, size: Int, offset: Int, cryptoData: TrackOutput.CryptoData?
) {
if (!conversionActive || !buffering) {
delegate.sampleMetadata(timeUs, flags, size, offset, cryptoData)
return
}
buffering = false
val srcLen = sampleLen
sampleLen = 0
val outLen: Int
val outBuf: ByteArray
val success = try {
processNalUnits(srcLen)
true
} catch (e: Exception) {
Log.e(TAG, "NAL processing failed, passing raw sample", e)
false
}
if (success) {
outLen = outputLen
outBuf = outputBuf
} else {
outLen = srcLen
outBuf = sampleBuf
}
// Skip empty samples (all NALs were DV layers) — don't confuse the decoder
if (outLen == 0) return
outputParsable.reset(outBuf, outLen)
delegate.sampleData(outputParsable, outLen, TrackOutput.SAMPLE_DATA_PART_MAIN)
delegate.sampleMetadata(timeUs, flags, outLen, 0, cryptoData)
}
/**
* Process NAL units in sampleBuf[0..dataLen). Auto-detects format:
* - Annex B (00 00 00 01 / 00 00 01 start codes) — used by MatroskaExtractor
* - Length-prefixed (4-byte big-endian length) — used by Mp4Extractor
*
* Result is written to outputBuf[0..outputLen).
*/
private fun processNalUnits(dataLen: Int) {
outputLen = 0
if (dataLen < 4) {
ensureOutputCapacity(dataLen)
System.arraycopy(sampleBuf, 0, outputBuf, 0, dataLen)
outputLen = dataLen
return
}
// Auto-detect: Annex B starts with 00 00 00 01 or 00 00 01
val isAnnexB = (dataLen >= 4 && sampleBuf[0] == 0.toByte() && sampleBuf[1] == 0.toByte() &&
sampleBuf[2] == 0.toByte() && sampleBuf[3] == 1.toByte()) ||
(dataLen >= 3 && sampleBuf[0] == 0.toByte() && sampleBuf[1] == 0.toByte() &&
sampleBuf[2] == 1.toByte())
if (sampleCount == 0L) {
Log.d(TAG, "NAL format detected: ${if (isAnnexB) "Annex B" else "length-prefixed"}, " +
"first bytes: ${sampleBuf.take(8).joinToString(" ") { "%02X".format(it) }}")
}
if (isAnnexB) processAnnexBNals(dataLen) else processLengthPrefixedNals(dataLen)
}
/** Process Annex B formatted NAL units (MKV path). Scans inline, no list allocation. */
private fun processAnnexBNals(dataLen: Int) {
ensureOutputCapacity(dataLen)
var kept = 0
var stripped = 0
// Find first start code
var scEnd = -1
var i = 0
while (i < dataLen - 2) {
if (sampleBuf[i] == 0.toByte() && sampleBuf[i + 1] == 0.toByte()) {
if (i + 3 < dataLen && sampleBuf[i + 2] == 0.toByte() && sampleBuf[i + 3] == 1.toByte()) {
scEnd = i + 4
break
} else if (sampleBuf[i + 2] == 1.toByte()) {
scEnd = i + 3
break
}
}
i++
}
if (scEnd < 0) {
// No start codes found — pass through
System.arraycopy(sampleBuf, 0, outputBuf, 0, dataLen)
outputLen = dataLen
sampleCount++
return
}
var nalStart = scEnd
while (nalStart < dataLen) {
// Find next start code to determine end of current NAL
var nalEnd = dataLen
i = nalStart
while (i < dataLen - 2) {
if (sampleBuf[i] == 0.toByte() && sampleBuf[i + 1] == 0.toByte()) {
if (i + 3 < dataLen && sampleBuf[i + 2] == 0.toByte() && sampleBuf[i + 3] == 1.toByte()) {
nalEnd = i
break
} else if (sampleBuf[i + 2] == 1.toByte()) {
nalEnd = i
break
}
}
i++
}
val nalLen = nalEnd - nalStart
if (nalLen > 0) {
val action = processNalInline(nalStart, nalLen)
if (action == NalAction.KEEP) {
ensureOutputCapacity(outputLen + 4 + nalLen)
System.arraycopy(ANNEX_B_START_CODE, 0, outputBuf, outputLen, 4)
outputLen += 4
System.arraycopy(sampleBuf, nalStart, outputBuf, outputLen, nalLen)
normalizeLayerId(outputBuf, outputLen)
outputLen += nalLen
kept++
} else if (action == NalAction.CONVERT) {
val converted = DoviBridge.convertRpuNalu(
sampleBuf.copyOfRange(nalStart, nalStart + nalLen), LIBDOVI_MODE_TO_81
)
if (converted != null) {
normalizeLayerId(converted, 0)
ensureOutputCapacity(outputLen + 4 + converted.size)
System.arraycopy(ANNEX_B_START_CODE, 0, outputBuf, outputLen, 4)
outputLen += 4
System.arraycopy(converted, 0, outputBuf, outputLen, converted.size)
outputLen += converted.size
convertedRpuCount++
kept++
} else {
strippedNalCount++
stripped++
}
} else {
strippedNalCount++
stripped++
}
}
// Advance past the next start code
if (nalEnd >= dataLen) break
nalStart = if (nalEnd + 3 < dataLen && sampleBuf[nalEnd + 2] == 0.toByte() && sampleBuf[nalEnd + 3] == 1.toByte()) {
nalEnd + 4
} else {
nalEnd + 3
}
}
sampleCount++
if (sampleCount <= 3 || (sampleCount % 500 == 0L)) {
Log.d(TAG, "Sample #$sampleCount (AnnexB): ${dataLen}B -> ${outputLen}B, " +
"kept=$kept stripped=$stripped NALs")
}
}
/** Process length-prefixed NAL units (MP4 path). */
private fun processLengthPrefixedNals(dataLen: Int) {
ensureOutputCapacity(dataLen)
var pos = 0
var kept = 0
var stripped = 0
while (pos + 4 <= dataLen) {
val nalLen = ((sampleBuf[pos].toInt() and 0xFF) shl 24) or
((sampleBuf[pos + 1].toInt() and 0xFF) shl 16) or
((sampleBuf[pos + 2].toInt() and 0xFF) shl 8) or
(sampleBuf[pos + 3].toInt() and 0xFF)
if (nalLen <= 0 || pos + 4 + nalLen > dataLen) {
if (sampleCount < 5) {
Log.w(TAG, "Bad NAL length $nalLen at pos $pos (data.size=$dataLen)")
}
break
}
val nalStart = pos + 4
val action = processNalInline(nalStart, nalLen)
if (action == NalAction.KEEP) {
ensureOutputCapacity(outputLen + 4 + nalLen)
writeInt32BE(outputBuf, outputLen, nalLen)
outputLen += 4
System.arraycopy(sampleBuf, nalStart, outputBuf, outputLen, nalLen)
normalizeLayerId(outputBuf, outputLen)
outputLen += nalLen
kept++
} else if (action == NalAction.CONVERT) {
val converted = DoviBridge.convertRpuNalu(
sampleBuf.copyOfRange(nalStart, nalStart + nalLen), LIBDOVI_MODE_TO_81
)
if (converted != null) {
normalizeLayerId(converted, 0)
ensureOutputCapacity(outputLen + 4 + converted.size)
writeInt32BE(outputBuf, outputLen, converted.size)
outputLen += 4
System.arraycopy(converted, 0, outputBuf, outputLen, converted.size)
outputLen += converted.size
convertedRpuCount++
kept++
} else {
strippedNalCount++
stripped++
}
} else {
strippedNalCount++
stripped++
}
pos += 4 + nalLen
}
sampleCount++
if (sampleCount <= 3 || (sampleCount % 500 == 0L)) {
Log.d(TAG, "Sample #$sampleCount (LenPrefix): ${dataLen}B -> ${outputLen}B, " +
"kept=$kept stripped=$stripped NALs")
}
}
private enum class NalAction { KEEP, STRIP, CONVERT }
/** Classify a NAL at sampleBuf[offset..offset+len) without copying. */
private fun processNalInline(offset: Int, len: Int): NalAction {
if (len < 2) return NalAction.KEEP
val nalType = (sampleBuf[offset].toInt() ushr 1) and 0x3F
val nuhLayerId = ((sampleBuf[offset].toInt() and 1) shl 5) or
((sampleBuf[offset + 1].toInt() ushr 3) and 0x1F)
return when {
nalType == NAL_TYPE_UNSPEC62 && dvMode == DvConversionMode.DV81 -> NalAction.CONVERT
nalType == NAL_TYPE_UNSPEC62 || nalType == NAL_TYPE_UNSPEC63 || nuhLayerId > 0 -> NalAction.STRIP
else -> NalAction.KEEP
}
}
private fun normalizeLayerId(data: ByteArray, offset: Int) {
if (data.size - offset >= 2) {
data[offset] = (data[offset].toInt() and 0xFE).toByte()
data[offset + 1] = (data[offset + 1].toInt() and 0x07).toByte()
}
}
private fun writeInt32BE(buf: ByteArray, offset: Int, value: Int) {
buf[offset] = ((value ushr 24) and 0xFF).toByte()
buf[offset + 1] = ((value ushr 16) and 0xFF).toByte()
buf[offset + 2] = ((value ushr 8) and 0xFF).toByte()
buf[offset + 3] = (value and 0xFF).toByte()
}
private fun ensureSampleCapacity(needed: Int) {
if (sampleBuf.size < needed) {
sampleBuf = sampleBuf.copyOf(maxOf(needed, sampleBuf.size * 2))
}
}
private fun ensureOutputCapacity(needed: Int) {
if (outputBuf.size < needed) {
outputBuf = outputBuf.copyOf(maxOf(needed, outputBuf.size * 2))
}
}
/**
* Build a 24-byte DOVIDecoderConfigurationRecord for DV Profile 8.1.
*
* Binary layout (from Dolby Vision spec):
* byte[0]: dv_version_major = 1
* byte[1]: dv_version_minor = 0
* byte[2]: dv_profile (7 bits) | dv_level MSB (1 bit)
* byte[3]: dv_level low 5 bits (5 bits) | rpu_present (1) | el_present (1) | bl_present (1)
* byte[4]: bl_compatibility_id (4 bits) | md_compression (2 bits) | reserved (2 bits)
* byte[5-23]: reserved (zeros)
*/
private fun buildDv81ConfigRecord(level: Int): ByteArray {
val record = ByteArray(24)
record[0] = 0x01 // dv_version_major = 1
record[1] = 0x00 // dv_version_minor = 0
record[2] = ((8 shl 1) or ((level ushr 5) and 0x01)).toByte() // profile=8 | level MSB
record[3] = (((level and 0x1F) shl 3) or 0x05).toByte() // level low 5 | rpu=1 el=0 bl=1
record[4] = (1 shl 4).toByte() // bl_compatibility_id=1 (HDR10)
// bytes 5-23 remain 0 (reserved)
return record
}
}
@@ -1,60 +0,0 @@
package com.edde746.plezy.exoplayer
import androidx.media3.common.C
import androidx.media3.extractor.Extractor
import androidx.media3.extractor.ExtractorInput
import androidx.media3.extractor.ExtractorOutput
import androidx.media3.extractor.PositionHolder
import androidx.media3.extractor.SeekMap
import androidx.media3.extractor.TrackOutput
/**
* ExtractorOutput wrapper that intercepts video track creation
* to insert DoviConvertingTrackOutput for DV processing.
* Shared by DoviExtractorWrapper for both MP4 and MKV containers.
*/
class DoviExtractorOutputWrapper(
private val delegate: ExtractorOutput,
private val dvMode: DvConversionMode,
private val onVideoTrackWrapped: (DoviConvertingTrackOutput) -> Unit,
) : ExtractorOutput {
override fun track(id: Int, type: Int): TrackOutput {
val original = delegate.track(id, type)
if (type == C.TRACK_TYPE_VIDEO) {
val wrapper = DoviConvertingTrackOutput(original, dvMode)
onVideoTrackWrapped(wrapper)
return wrapper
}
return original
}
override fun endTracks() = delegate.endTracks()
override fun seekMap(seekMap: SeekMap) = delegate.seekMap(seekMap)
}
/**
* Extractor decorator for Mp4/FragmentedMp4 containers.
* Wraps the video TrackOutput with DoviConvertingTrackOutput to perform
* DV Profile 7 → 8.1 conversion via inline NAL processing.
*/
class DoviExtractorWrapper(
private val delegate: Extractor,
private val dvMode: DvConversionMode = DvConversionMode.HEVC_STRIP,
) : Extractor {
@Volatile var doviTrackOutput: DoviConvertingTrackOutput? = null
private set
override fun sniff(input: ExtractorInput): Boolean = delegate.sniff(input)
override fun init(output: ExtractorOutput) {
delegate.init(DoviExtractorOutputWrapper(output, dvMode) { doviTrackOutput = it })
}
override fun read(input: ExtractorInput, seekPosition: PositionHolder): Int =
delegate.read(input, seekPosition)
override fun seek(position: Long, timeUs: Long) = delegate.seek(position, timeUs)
override fun release() = delegate.release()
}
File diff suppressed because it is too large Load Diff
@@ -1,816 +0,0 @@
package com.edde746.plezy.exoplayer
import android.app.Activity
import android.app.ActivityManager
import android.content.ContentResolver
import android.content.Context
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.util.Log
import com.edde746.plezy.mpv.MpvPlayerCore
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
EventChannel.StreamHandler, ActivityAware, ExoPlayerDelegate {
companion object {
private const val TAG = "ExoPlayerPlugin"
private const val METHOD_CHANNEL = "com.plezy/exo_player"
private const val EVENT_CHANNEL = "com.plezy/exo_player/events"
}
private lateinit var methodChannel: MethodChannel
private lateinit var eventChannel: EventChannel
private var eventSink: EventChannel.EventSink? = null
private var playerCore: ExoPlayerCore? = null
private var mpvCore: MpvPlayerCore? = null // MPV fallback player
private var usingMpvFallback: Boolean = false
private var fallbackInProgress: Boolean = false
private var activity: Activity? = null
private var activityBinding: ActivityPluginBinding? = null
private val nameToId = mutableMapOf<String, Int>()
private val mainHandler = Handler(Looper.getMainLooper())
private var configuredBufferSizeBytes: Int? = null
private var sessionGeneration = 0
private var debugLoggingEnabled: Boolean = false
private val pendingMpvProperties = mutableListOf<Pair<String, String>>()
// FlutterPlugin
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
methodChannel = MethodChannel(binding.binaryMessenger, METHOD_CHANNEL)
methodChannel.setMethodCallHandler(this)
eventChannel = EventChannel(binding.binaryMessenger, EVENT_CHANNEL)
eventChannel.setStreamHandler(this)
Log.d(TAG, "Attached to engine")
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
methodChannel.setMethodCallHandler(null)
eventChannel.setStreamHandler(null)
Log.d(TAG, "Detached from engine")
}
// ActivityAware
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
activity = binding.activity
activityBinding = binding
Log.d(TAG, "Attached to activity")
}
override fun onDetachedFromActivity() {
sessionGeneration++
playerCore?.dispose()
playerCore = null
mpvCore?.dispose()
mpvCore = null
usingMpvFallback = false
fallbackInProgress = false
activity = null
activityBinding = null
Log.d(TAG, "Detached from activity")
}
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
activity = binding.activity
activityBinding = binding
Log.d(TAG, "Reattached to activity for config changes")
}
override fun onDetachedFromActivityForConfigChanges() {
sessionGeneration++
fallbackInProgress = false
activity = null
activityBinding = null
Log.d(TAG, "Detached from activity for config changes")
}
// EventChannel.StreamHandler
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
eventSink = events
Log.d(TAG, "Event stream connected")
}
override fun onCancel(arguments: Any?) {
eventSink = null
Log.d(TAG, "Event stream disconnected")
}
// MethodChannel.MethodCallHandler
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"initialize" -> handleInitialize(call, result)
"dispose" -> handleDispose(result)
"open" -> handleOpen(call, result)
"play" -> handlePlay(result)
"pause" -> handlePause(result)
"stop" -> handleStop(result)
"seek" -> handleSeek(call, result)
"setVolume" -> handleSetVolume(call, result)
"setRate" -> handleSetRate(call, result)
"selectAudioTrack" -> handleSelectAudioTrack(call, result)
"selectSubtitleTrack" -> handleSelectSubtitleTrack(call, result)
"addSubtitleTrack" -> handleAddSubtitleTrack(call, result)
"setVisible" -> handleSetVisible(call, result)
"updateFrame" -> handleUpdateFrame(result)
"setVideoFrameRate" -> handleSetVideoFrameRate(call, result)
"clearVideoFrameRate" -> handleClearVideoFrameRate(result)
"requestAudioFocus" -> handleRequestAudioFocus(result)
"abandonAudioFocus" -> handleAbandonAudioFocus(result)
"isInitialized" -> result.success(
if (usingMpvFallback) mpvCore?.isInitialized ?: false
else playerCore?.isInitialized ?: false
)
"getStats" -> handleGetStats(result)
"getPlayerType" -> result.success(if (usingMpvFallback) "mpv" else "exoplayer")
"getHeapSize" -> {
val am = activity?.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager
result.success(am?.largeMemoryClass ?: 0)
}
"setSubtitleStyle" -> handleSetSubtitleStyle(call, result)
"observeProperty" -> handleObserveProperty(call, result)
"setMpvProperty" -> handleSetMpvProperty(call, result)
"setLogLevel" -> {
val level = call.argument<String>("level") ?: "warn"
debugLoggingEnabled = (level == "v" || level == "debug" || level == "trace")
playerCore?.debugLoggingEnabled = debugLoggingEnabled
result.success(null)
}
"triggerFallback" -> {
playerCore?.triggerFallback()
result.success(null)
}
else -> result.notImplemented()
}
}
private fun handleInitialize(call: MethodCall, result: MethodChannel.Result) {
val currentActivity = activity
if (currentActivity == null) {
result.error("NO_ACTIVITY", "Activity not available", null)
return
}
if (playerCore?.isInitialized == true) {
Log.d(TAG, "Already initialized")
result.success(true)
return
}
val bufferSizeBytes = call.argument<Int>("bufferSizeBytes")
val tunnelingEnabled = call.argument<Boolean>("tunnelingEnabled") ?: true
configuredBufferSizeBytes = bufferSizeBytes
currentActivity.runOnUiThread {
sessionGeneration++
if (mpvCore != null || fallbackInProgress) {
mpvCore?.dispose()
mpvCore = null
usingMpvFallback = false
fallbackInProgress = false
}
try {
playerCore = ExoPlayerCore(currentActivity).apply {
delegate = this@ExoPlayerPlugin
this.debugLoggingEnabled = this@ExoPlayerPlugin.debugLoggingEnabled
}
val success = playerCore?.initialize(
bufferSizeBytes = bufferSizeBytes,
tunnelingEnabled = tunnelingEnabled,
) ?: false
// Start hidden
playerCore?.setVisible(false)
Log.d(TAG, "Initialized: $success")
result.success(success)
} catch (e: Exception) {
Log.e(TAG, "Failed to initialize: ${e.message}", e)
result.error("INIT_FAILED", e.message, null)
}
}
}
private fun handleDispose(result: MethodChannel.Result) {
activity?.runOnUiThread {
sessionGeneration++
playerCore?.dispose()
playerCore = null
mpvCore?.dispose()
mpvCore = null
usingMpvFallback = false
fallbackInProgress = false
Log.d(TAG, "Disposed")
result.success(null)
} ?: result.success(null)
}
@Suppress("UNCHECKED_CAST")
private fun handleOpen(call: MethodCall, result: MethodChannel.Result) {
val uri = call.argument<String>("uri")
val headers = call.argument<Map<String, String>>("headers")
val startPositionMs = call.argument<Number>("startPositionMs")?.toLong() ?: 0L
val autoPlay = call.argument<Boolean>("autoPlay") ?: true
val isLive = call.argument<Boolean>("isLive") ?: false
val externalSubtitles = call.argument<List<Map<String, String?>>>("externalSubtitles")
if (uri == null) {
result.error("INVALID_ARGS", "Missing 'uri'", null)
return
}
// Only clear pending MPV properties when MPV is the active backend.
// When ExoPlayer is active, keep them for potential ExoPlayer→MPV fallback.
if (usingMpvFallback) {
pendingMpvProperties.clear()
}
activity?.runOnUiThread {
if (usingMpvFallback) {
// MPV: Build loadfile command with options
val startSeconds = startPositionMs / 1000.0
val options = mutableListOf<String>()
options.add("start=$startSeconds")
if (!autoPlay) options.add("pause=yes")
headers?.forEach { (key, value) ->
options.add("http-header-fields-append=$key: $value")
}
val optionsStr = options.joinToString(",")
// Convert content:// URIs to fdclose:// for MPV (SAF SD card downloads)
val mpvUri = openContentFd(uri)?.let { "fdclose://$it" } ?: uri
mpvCore?.command(arrayOf("loadfile", mpvUri, "replace", "-1", optionsStr))
} else {
playerCore?.open(uri, headers, startPositionMs, autoPlay, isLive, externalSubtitles)
}
result.success(null)
} ?: result.error("NO_ACTIVITY", "Activity not available", null)
}
private fun handlePlay(result: MethodChannel.Result) {
activity?.runOnUiThread {
if (usingMpvFallback) {
mpvCore?.setProperty("pause", "no")
} else {
playerCore?.play()
}
result.success(null)
} ?: result.success(null)
}
private fun handlePause(result: MethodChannel.Result) {
activity?.runOnUiThread {
if (usingMpvFallback) {
mpvCore?.setProperty("pause", "yes")
} else {
playerCore?.pause()
}
result.success(null)
} ?: result.success(null)
}
private fun handleStop(result: MethodChannel.Result) {
activity?.runOnUiThread {
if (usingMpvFallback) {
mpvCore?.command(arrayOf("stop"))
mpvCore?.setVisible(false)
} else {
playerCore?.stop()
}
result.success(null)
} ?: result.success(null)
}
private fun handleSeek(call: MethodCall, result: MethodChannel.Result) {
val positionMs = call.argument<Number>("positionMs")?.toLong()
if (positionMs == null) {
result.error("INVALID_ARGS", "Missing 'positionMs'", null)
return
}
activity?.runOnUiThread {
if (usingMpvFallback) {
val positionSeconds = positionMs / 1000.0
mpvCore?.command(arrayOf("seek", positionSeconds.toString(), "absolute"))
} else {
playerCore?.seekTo(positionMs)
}
result.success(null)
} ?: result.success(null)
}
private fun handleSetVolume(call: MethodCall, result: MethodChannel.Result) {
val volume = call.argument<Number>("volume")?.toFloat()
if (volume == null) {
result.error("INVALID_ARGS", "Missing 'volume'", null)
return
}
activity?.runOnUiThread {
if (usingMpvFallback) {
mpvCore?.setProperty("volume", volume.toString())
} else {
playerCore?.setVolume(volume / 100f) // Convert 0-100 to 0-1
}
result.success(null)
} ?: result.success(null)
}
private fun handleSetRate(call: MethodCall, result: MethodChannel.Result) {
val rate = call.argument<Number>("rate")?.toFloat()
if (rate == null) {
result.error("INVALID_ARGS", "Missing 'rate'", null)
return
}
activity?.runOnUiThread {
if (usingMpvFallback) {
mpvCore?.setProperty("speed", rate.toString())
} else {
playerCore?.setPlaybackSpeed(rate)
}
result.success(null)
} ?: result.success(null)
}
private fun handleSelectAudioTrack(call: MethodCall, result: MethodChannel.Result) {
val trackId = call.argument<String>("trackId")
if (trackId == null) {
result.error("INVALID_ARGS", "Missing 'trackId'", null)
return
}
activity?.runOnUiThread {
if (usingMpvFallback) {
// After fallback, track IDs come from mpv's track-list (already 1-indexed)
mpvCore?.setProperty("aid", trackId)
} else {
playerCore?.selectAudioTrack(trackId)
}
result.success(null)
} ?: result.success(null)
}
private fun handleSelectSubtitleTrack(call: MethodCall, result: MethodChannel.Result) {
val trackId = call.argument<String>("trackId")
// trackId can be null or "no" to disable subtitles
activity?.runOnUiThread {
if (usingMpvFallback) {
mpvCore?.setProperty("sid", trackId ?: "no")
} else {
playerCore?.selectSubtitleTrack(trackId)
}
result.success(null)
} ?: result.success(null)
}
private fun handleAddSubtitleTrack(call: MethodCall, result: MethodChannel.Result) {
val uri = call.argument<String>("uri")
val title = call.argument<String>("title")
val language = call.argument<String>("language")
val mimeType = call.argument<String>("mimeType")
val select = call.argument<Boolean>("select") ?: false
if (uri == null) {
result.error("INVALID_ARGS", "Missing 'uri'", null)
return
}
activity?.runOnUiThread {
if (usingMpvFallback) {
val selectFlag = if (select) "select" else "auto"
mpvCore?.command(arrayOf("sub-add", uri, selectFlag, title ?: "External"))
} else {
playerCore?.addSubtitleTrack(uri, title, language, mimeType, select)
}
result.success(null)
} ?: result.success(null)
}
private fun handleSetVisible(call: MethodCall, result: MethodChannel.Result) {
val visible = call.argument<Boolean>("visible")
if (visible == null) {
result.error("INVALID_ARGS", "Missing 'visible'", null)
return
}
if (usingMpvFallback) {
mpvCore?.setVisible(visible)
} else {
playerCore?.setVisible(visible)
}
result.success(null)
}
private fun handleUpdateFrame(result: MethodChannel.Result) {
if (usingMpvFallback) {
mpvCore?.updateFrame()
} else {
playerCore?.updateFrame()
}
result.success(null)
}
private fun handleSetVideoFrameRate(call: MethodCall, result: MethodChannel.Result) {
val fps = call.argument<Double>("fps")?.toFloat() ?: 0f
val duration = call.argument<Number>("duration")?.toLong() ?: 0L
Log.d(TAG, "setVideoFrameRate: fps=$fps, duration=$duration")
if (usingMpvFallback) {
mpvCore?.setVideoFrameRate(fps, duration)
} else {
playerCore?.setVideoFrameRate(fps, duration)
}
result.success(null)
}
private fun handleClearVideoFrameRate(result: MethodChannel.Result) {
Log.d(TAG, "clearVideoFrameRate")
if (usingMpvFallback) {
mpvCore?.clearVideoFrameRate()
} else {
playerCore?.clearVideoFrameRate()
}
result.success(null)
}
private fun handleRequestAudioFocus(result: MethodChannel.Result) {
Log.d(TAG, "requestAudioFocus")
val granted = if (usingMpvFallback) {
mpvCore?.requestAudioFocus() ?: false
} else {
playerCore?.requestAudioFocus() ?: false
}
result.success(granted)
}
private fun handleAbandonAudioFocus(result: MethodChannel.Result) {
Log.d(TAG, "abandonAudioFocus")
if (usingMpvFallback) {
mpvCore?.abandonAudioFocus()
} else {
playerCore?.abandonAudioFocus()
}
result.success(null)
}
private fun handleObserveProperty(call: MethodCall, result: MethodChannel.Result) {
val name = call.argument<String>("name")
val id = call.argument<Int>("id")
if (name == null || id == null) {
result.error("INVALID_ARGS", "Missing 'name' or 'id'", null)
return
}
nameToId[name] = id
result.success(null)
}
private fun handleSetSubtitleStyle(call: MethodCall, result: MethodChannel.Result) {
val fontSize = call.argument<Number>("fontSize")?.toFloat() ?: 55f
val textColor = call.argument<String>("textColor") ?: "#FFFFFF"
val borderSize = call.argument<Number>("borderSize")?.toFloat() ?: 3f
val borderColor = call.argument<String>("borderColor") ?: "#000000"
val bgColor = call.argument<String>("bgColor") ?: "#000000"
val bgOpacity = call.argument<Number>("bgOpacity")?.toInt() ?: 0
val subtitlePosition = call.argument<Number>("subtitlePosition")?.toInt() ?: 100
if (usingMpvFallback) {
// MPV fallback handles styling via setProperty, no-op here
result.success(null)
return
}
playerCore?.setSubtitleStyle(fontSize, textColor, borderSize, borderColor, bgColor, bgOpacity, subtitlePosition)
result.success(null)
}
private fun handleSetMpvProperty(call: MethodCall, result: MethodChannel.Result) {
val name = call.argument<String>("name")
val value = call.argument<String>("value")
if (name == null || value == null) {
result.error("INVALID_ARGS", "Missing 'name' or 'value'", null)
return
}
if (usingMpvFallback) {
mpvCore?.setProperty(name, value)
} else {
// Store for later application if ExoPlayer falls back to MPV
pendingMpvProperties.add(Pair(name, value))
}
result.success(null)
}
private fun handleGetStats(result: MethodChannel.Result) {
if (usingMpvFallback) {
Thread {
val stats = getMpvStats()
activity?.runOnUiThread { result.success(stats) }
}.start()
} else {
activity?.runOnUiThread {
val coreStats = playerCore?.getStats() ?: emptyMap()
result.success(coreStats + mapOf("playerType" to "exoplayer"))
} ?: result.success(mapOf("playerType" to "unknown"))
}
}
/**
* Get playback stats from MPV when in fallback mode.
* Queries relevant MPV properties and returns them in a map format
* compatible with the performance overlay.
*/
private fun getMpvStats(): Map<String, Any?> {
val mpv = mpvCore ?: return mapOf("playerType" to "mpv")
val hasVideo = mpv.getProperty("video-params/w") != null
val stats = mutableMapOf<String, Any?>(
"playerType" to "mpv",
// Video metrics
"video-codec" to mpv.getProperty("video-codec"),
"video-params/w" to mpv.getProperty("video-params/w"),
"video-params/h" to mpv.getProperty("video-params/h"),
"videoWidth" to mpv.getProperty("dwidth"),
"videoHeight" to mpv.getProperty("dheight"),
"container-fps" to mpv.getProperty("container-fps"),
"estimated-vf-fps" to mpv.getProperty("estimated-vf-fps"),
"video-bitrate" to mpv.getProperty("video-bitrate"),
"hwdec-current" to mpv.getProperty("hwdec-current"),
// Audio metrics
"audio-codec-name" to mpv.getProperty("audio-codec-name"),
"audio-params/samplerate" to mpv.getProperty("audio-params/samplerate"),
"audio-params/hr-channels" to mpv.getProperty("audio-params/hr-channels"),
"audio-bitrate" to mpv.getProperty("audio-bitrate"),
// Performance metrics
"total-avsync-change" to mpv.getProperty("total-avsync-change"),
"cache-speed" to mpv.getProperty("cache-speed"),
"frame-drop-count" to mpv.getProperty("frame-drop-count"),
"decoder-frame-drop-count" to mpv.getProperty("decoder-frame-drop-count"),
"demuxer-cache-duration" to mpv.getProperty("demuxer-cache-duration"),
)
// Only query properties that require an active video track
if (hasVideo) {
stats["display-fps"] = mpv.getProperty("display-fps")
// Color/Format properties
stats["video-params/pixelformat"] = mpv.getProperty("video-params/pixelformat")
stats["video-params/hw-pixelformat"] = mpv.getProperty("video-params/hw-pixelformat")
stats["video-params/colormatrix"] = mpv.getProperty("video-params/colormatrix")
stats["video-params/primaries"] = mpv.getProperty("video-params/primaries")
stats["video-params/gamma"] = mpv.getProperty("video-params/gamma")
// HDR metadata
stats["video-params/max-luma"] = mpv.getProperty("video-params/max-luma")
stats["video-params/min-luma"] = mpv.getProperty("video-params/min-luma")
stats["video-params/max-cll"] = mpv.getProperty("video-params/max-cll")
stats["video-params/max-fall"] = mpv.getProperty("video-params/max-fall")
// Other
stats["video-params/aspect-name"] = mpv.getProperty("video-params/aspect-name")
stats["video-params/rotate"] = mpv.getProperty("video-params/rotate")
}
return stats
}
// PiP Mode handling
fun onPipModeChanged(isInPipMode: Boolean) {
activity?.runOnUiThread {
if (usingMpvFallback) {
mpvCore?.onPipModeChanged(isInPipMode)
} else {
playerCore?.onPipModeChanged(isInPipMode)
}
}
}
// ExoPlayerDelegate
override fun onPropertyChange(name: String, value: Any?) {
val propId = nameToId[name] ?: return
mainHandler.post { eventSink?.success(listOf(propId, value)) }
}
override fun onEvent(name: String, data: Map<String, Any>?) {
val event = mutableMapOf<String, Any>(
"type" to "event",
"name" to name
)
data?.let { event["data"] = it }
mainHandler.post { eventSink?.success(event) }
}
/**
* Opens a content:// URI via ContentResolver and returns the raw FD number,
* or null if the URI is not a content:// scheme or opening fails.
* The returned FD is detached so MPV can own and close it via fdclose://.
*/
private fun openContentFd(
uriString: String,
resolver: ContentResolver? = activity?.contentResolver
): Int? {
if (!uriString.startsWith("content://")) return null
return try {
val uri = Uri.parse(uriString)
val pfd = resolver?.openFileDescriptor(uri, "r") ?: return null
val fd = pfd.detachFd()
Log.d(TAG, "Opened content FD $fd for $uriString")
fd
} catch (e: Exception) {
Log.e(TAG, "Failed to open content FD: ${e.message}", e)
null
}
}
override fun onFormatUnsupported(
uri: String,
headers: Map<String, String>?,
positionMs: Long,
errorMessage: String
): Boolean {
if (usingMpvFallback || fallbackInProgress) {
Log.w(TAG, "Fallback already active/in-progress, ignoring duplicate request")
return true
}
val currentActivity = activity ?: return false
fallbackInProgress = true
Log.i(TAG, "ExoPlayer error, switching to MPV fallback at ${positionMs}ms: $errorMessage")
if (debugLoggingEnabled) {
onEvent("log-message", mapOf(
"prefix" to "fallback", "level" to "warn",
"text" to "Switching to MPV at ${positionMs}ms: $errorMessage"
))
}
currentActivity.runOnUiThread {
try {
// Dispose ExoPlayer
playerCore?.dispose()
playerCore = null
mpvCore?.dispose()
mpvCore = null
usingMpvFallback = false // Clear before handoff
val generation = sessionGeneration
Handler(Looper.getMainLooper()).post {
if (generation != sessionGeneration) {
fallbackInProgress = false
return@post
}
val act = activity
if (act == null) {
fallbackInProgress = false
return@post
}
try {
val core = MpvPlayerCore(act).apply {
delegate = this@ExoPlayerPlugin
}
mpvCore = core // publish so dispose/init can reach it
core.initialize { success ->
if (generation != sessionGeneration) {
if (mpvCore === core) {
core.dispose()
mpvCore = null
}
fallbackInProgress = false
return@initialize
}
if (!success) {
if (mpvCore === core) {
core.dispose()
mpvCore = null
}
fallbackInProgress = false
Log.e(TAG, "Failed to initialize MPV fallback")
onEvent("end-file", mapOf("reason" to "error", "message" to "Fallback failed: $errorMessage"))
return@initialize
}
usingMpvFallback = true
fallbackInProgress = false
// Snapshot pending properties on main thread before clearing
val pendingProps = pendingMpvProperties.toList()
pendingMpvProperties.clear()
// Compute content FD on main thread (needs contentResolver)
val mpvUri = openContentFd(uri, act.contentResolver)
?.let { "fdclose://$it" } ?: uri
// Buffer size for closure
val bufferSize = configuredBufferSizeBytes
if (mpvCore !== core) {
core.dispose()
fallbackInProgress = false
return@initialize
}
// Configure basic MPV properties for Plex playback
core.setProperty("hwdec", "mediacodec,mediacodec-copy")
core.setProperty("vo", "gpu")
core.setProperty("ao", "audiotrack")
// Forward user's buffer config to MPV fallback
if (bufferSize != null && bufferSize > 0) {
core.setProperty("demuxer-max-bytes", bufferSize.toString())
}
// Apply pending MPV properties from Dart
for ((propName, propValue) in pendingProps) {
core.setProperty(propName, propValue)
}
// Setup property observers
core.observeProperty("time-pos", "double")
core.observeProperty("duration", "double")
core.observeProperty("seekable", "flag")
core.observeProperty("pause", "flag")
core.observeProperty("paused-for-cache", "flag")
core.observeProperty("demuxer-cache-time", "double")
core.observeProperty("eof-reached", "flag")
core.observeProperty("track-list", "string")
core.observeProperty("aid", "string")
core.observeProperty("sid", "string")
core.observeProperty("volume", "double")
core.observeProperty("speed", "double")
// Show the MPV surface (internally posts to UI)
core.setVisible(true)
// Load media at the same position
val startSeconds = positionMs / 1000.0
val options = mutableListOf<String>()
options.add("start=$startSeconds")
headers?.forEach { (key, value) ->
options.add("http-header-fields-append=$key: $value")
}
val optionsStr = options.joinToString(",")
core.command(arrayOf("loadfile", mpvUri, "replace", "-1", optionsStr))
// On GPUs without compute shaders, MPV can't do dynamic peak detection
// and spline tone-mapping produces dim/washed-out results with extreme
// static HDR peak metadata. Use reinhard which handles this better.
Thread {
val peakDetection = core.getProperty("hdr-compute-peak")
if (peakDetection == "no") {
Log.i(TAG, "No compute shaders — overriding tone-mapping to reinhard")
core.setProperty("tone-mapping", "reinhard")
core.setProperty("tone-mapping-param", "0.7")
core.setProperty("tone-mapping-mode", "luma")
}
}.start()
// Request audio focus
core.requestAudioFocus()
// Emit backend-switched event on main thread
activity?.runOnUiThread {
onEvent("backend-switched", null)
}
Log.i(TAG, "Successfully switched to MPV fallback")
}
} catch (e: Exception) {
fallbackInProgress = false
Log.e(TAG, "Failed to switch to MPV fallback", e)
onEvent("end-file", mapOf("reason" to "error", "message" to "Fallback failed: ${e.message}"))
}
}
} catch (e: Exception) {
fallbackInProgress = false
Log.e(TAG, "Failed to switch to MPV fallback", e)
onEvent("end-file", mapOf("reason" to "error", "message" to "Fallback failed: ${e.message}"))
}
}
return true // Fallback is being handled
}
}
@@ -1,303 +0,0 @@
package com.edde746.plezy.exoplayer
import android.content.Context
import android.media.AudioDeviceInfo
import android.os.Build
import androidx.annotation.OptIn
import androidx.media3.common.PlaybackParameters
import androidx.media3.common.util.Clock
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.analytics.PlayerId
import androidx.media3.exoplayer.audio.AudioOutput
import androidx.media3.exoplayer.audio.AudioOutputProvider
import androidx.media3.exoplayer.audio.AudioSink
import androidx.media3.exoplayer.audio.AudioTrackAudioOutputProvider
import androidx.media3.exoplayer.audio.DefaultAudioSink
import androidx.media3.exoplayer.audio.DefaultAudioTrackBufferSizeProvider
import androidx.media3.exoplayer.audio.ForwardingAudioSink
import java.nio.ByteBuffer
import java.util.concurrent.atomic.AtomicLong
@OptIn(UnstableApi::class)
class PlezyRenderersFactory(context: Context) : DefaultRenderersFactory(context) {
override fun buildAudioSink(
context: Context,
enableFloatOutput: Boolean,
enableAudioOutputPlaybackParams: Boolean
): AudioSink {
AudioTrackAudioOutputProvider.failOnSpuriousAudioTimestamp = false
val bufferSizeProvider = DefaultAudioTrackBufferSizeProvider.Builder()
.setMinPcmBufferDurationUs(500_000)
.setMaxPcmBufferDurationUs(1_000_000)
.setPcmBufferMultiplicationFactor(4)
.build()
val realProvider = AudioTrackAudioOutputProvider.Builder(context)
.setAudioTrackBufferSizeProvider(bufferSizeProvider)
.build()
// Shared position: RawPositionAudioOutput writes the raw AudioTrack position,
// PositionFixAudioSink reads it to bypass DefaultAudioSink's writtenDuration clamp.
val rawPositionUs = AtomicLong(Long.MIN_VALUE)
val defaultSink = DefaultAudioSink.Builder(context)
.setEnableFloatOutput(enableFloatOutput)
.setEnableAudioOutputPlaybackParameters(enableAudioOutputPlaybackParams)
.setAudioOutputProvider(RawPositionOutputProvider(realProvider, rawPositionUs))
.build()
return PositionFixAudioSink(defaultSink, rawPositionUs)
}
}
/**
* Fixes video stutter with large audio frames (e.g. 120ms Opus).
*
* DefaultAudioSink.getCurrentPositionUs() clamps the playback position to
* writtenDuration via Math.min(positionUs, framesToDurationUs(writtenFrames)).
* With large audio frames, data arrives in bursts and writtenDuration lags behind
* the real AudioTrack playback position between bursts, creating a sawtooth
* position pattern (plateau → jump) that makes the video renderer drop frames.
*
* Fix: when DefaultAudioSink's position falls behind the raw AudioTrack position,
* return startMediaTimeUs + rawPosition instead, giving the video renderer a
* smooth clock to sync against.
*
* Also suppresses Amlogic AudioTrack timestamp discontinuity errors.
*
* The raw AudioTrack position advances at real-time rate regardless of speed
* (AudioTrackPositionTracker reports post-time-stretch output frames). The
* bypass multiplies by speed to convert to media time, matching what
* DefaultAudioSink.applyMediaPositionParameters() does internally. Reference
* points are captured on speed changes for correct mid-playback transitions.
*/
@OptIn(UnstableApi::class)
private class PositionFixAudioSink(
sink: AudioSink,
private val rawPositionUs: AtomicLong
) : ForwardingAudioSink(sink) {
private var startMediaTimeUs = Long.MIN_VALUE
private var suppressedErrorCount = 0
// Speed tracking for position bypass
private var currentSpeed = 1.0f
private var refMediaTimeUs = Long.MIN_VALUE
private var refRawPositionUs = 0L
override fun handleBuffer(
buffer: ByteBuffer,
presentationTimeUs: Long,
encodedAccessUnitCount: Int
): Boolean {
if (startMediaTimeUs == Long.MIN_VALUE) {
startMediaTimeUs = presentationTimeUs
refMediaTimeUs = presentationTimeUs
refRawPositionUs = 0
}
return super.handleBuffer(buffer, presentationTimeUs, encodedAccessUnitCount)
}
override fun setPlaybackParameters(playbackParameters: PlaybackParameters) {
// Capture reference point before speed changes
val rawPos = rawPositionUs.get()
if (rawPos > 0 && refMediaTimeUs != Long.MIN_VALUE) {
refMediaTimeUs = refMediaTimeUs + ((rawPos - refRawPositionUs) * currentSpeed).toLong()
refRawPositionUs = rawPos
}
currentSpeed = playbackParameters.speed
super.setPlaybackParameters(playbackParameters)
}
override fun getCurrentPositionUs(sourceEnded: Boolean): Long {
val delegatePos = super.getCurrentPositionUs(sourceEnded)
if (delegatePos == Long.MIN_VALUE || refMediaTimeUs == Long.MIN_VALUE) {
return delegatePos
}
val rawPos = rawPositionUs.get()
if (rawPos <= 0) return delegatePos
val expectedPos = refMediaTimeUs + ((rawPos - refRawPositionUs) * currentSpeed).toLong()
return if (expectedPos > delegatePos + 30_000) expectedPos else delegatePos
}
// --- Suppress timestamp discontinuity errors ---
override fun setListener(listener: AudioSink.Listener) {
super.setListener(@OptIn(UnstableApi::class) object : AudioSink.Listener {
override fun onPositionDiscontinuity() = listener.onPositionDiscontinuity()
override fun onPositionAdvancing(playoutStartSystemTimeUs: Long) = listener.onPositionAdvancing(playoutStartSystemTimeUs)
override fun onUnderrun(bufferSize: Int, bufferSizeMs: Long, elapsedSinceLastFeedMs: Long) = listener.onUnderrun(bufferSize, bufferSizeMs, elapsedSinceLastFeedMs)
override fun onSkipSilenceEnabledChanged(skipSilenceEnabled: Boolean) = listener.onSkipSilenceEnabledChanged(skipSilenceEnabled)
override fun onOffloadBufferEmptying() = listener.onOffloadBufferEmptying()
override fun onOffloadBufferFull() = listener.onOffloadBufferFull()
override fun onAudioCapabilitiesChanged() = listener.onAudioCapabilitiesChanged()
override fun onAudioTrackInitialized(audioTrackConfig: AudioSink.AudioTrackConfig) = listener.onAudioTrackInitialized(audioTrackConfig)
override fun onAudioTrackReleased(audioTrackConfig: AudioSink.AudioTrackConfig) = listener.onAudioTrackReleased(audioTrackConfig)
override fun onSilenceSkipped() = listener.onSilenceSkipped()
override fun onAudioSessionIdChanged(audioSessionId: Int) = listener.onAudioSessionIdChanged(audioSessionId)
override fun onAudioSinkError(audioSinkError: Exception) {
if (isTimestampDiscontinuity(audioSinkError)) {
suppressedErrorCount++
return
}
listener.onAudioSinkError(audioSinkError)
}
})
}
override fun flush() {
startMediaTimeUs = Long.MIN_VALUE
refMediaTimeUs = Long.MIN_VALUE
refRawPositionUs = 0
rawPositionUs.set(Long.MIN_VALUE)
super.flush()
}
override fun reset() {
startMediaTimeUs = Long.MIN_VALUE
refMediaTimeUs = Long.MIN_VALUE
refRawPositionUs = 0
currentSpeed = 1.0f
rawPositionUs.set(Long.MIN_VALUE)
suppressedErrorCount = 0
super.reset()
}
private fun isTimestampDiscontinuity(e: Exception): Boolean {
val name = e.javaClass.simpleName
val msg = e.message ?: ""
return name == "InvalidAudioTrackTimestampException" ||
name == "UnexpectedDiscontinuityException" ||
msg.contains("timestamp discontinuity", ignoreCase = true)
}
}
// --- AudioOutput wrapping: shares raw position with PositionFixAudioSink ---
// Also implements AudioTrack reuse across seeks to avoid expensive teardown/recreation.
// DefaultAudioSink releases the AudioOutput on every flush (seek), which destroys the
// AudioTrack and creates a new one. On Android TV with tunneled playback, this causes
// 7-10s audio dropout while the hardware pipeline reinitializes (Sony Bravia, etc).
// By flushing instead of releasing and caching the output, we skip the teardown cycle.
@OptIn(UnstableApi::class)
private class RawPositionOutputProvider(
private val delegate: AudioOutputProvider,
private val rawPositionUs: AtomicLong
) : AudioOutputProvider {
private var cachedOutput: RawPositionAudioOutput? = null
private var cachedConfig: AudioOutputProvider.OutputConfig? = null
override fun getFormatSupport(config: AudioOutputProvider.FormatConfig) =
delegate.getFormatSupport(config)
override fun getOutputConfig(config: AudioOutputProvider.FormatConfig) =
delegate.getOutputConfig(config)
override fun getAudioOutput(config: AudioOutputProvider.OutputConfig): AudioOutput {
val cached = cachedOutput
if (cached != null && cachedConfig == config) {
cachedOutput = null
return cached
}
cached?.forceRelease()
cachedOutput = null
val realOutput = delegate.getAudioOutput(config)
cachedConfig = config
return RawPositionAudioOutput(realOutput, rawPositionUs, this)
}
fun returnToCache(output: RawPositionAudioOutput) {
val existing = cachedOutput
if (existing != null && existing !== output) {
existing.forceRelease()
}
cachedOutput = output
}
override fun addListener(listener: AudioOutputProvider.Listener) =
delegate.addListener(listener)
override fun removeListener(listener: AudioOutputProvider.Listener) =
delegate.removeListener(listener)
override fun setClock(clock: Clock) = delegate.setClock(clock)
override fun release() {
cachedOutput?.forceRelease()
cachedOutput = null
cachedConfig = null
delegate.release()
}
}
@OptIn(UnstableApi::class)
private class RawPositionAudioOutput(
private val delegate: AudioOutput,
private val rawPositionUs: AtomicLong,
private val provider: RawPositionOutputProvider
) : AudioOutput {
override fun getPositionUs(): Long {
val pos = delegate.getPositionUs()
rawPositionUs.set(pos)
return pos
}
override fun play() = delegate.play()
override fun pause() = delegate.pause()
@Throws(AudioOutput.WriteException::class)
override fun write(buffer: ByteBuffer, size: Int, presentationTimeUs: Long) =
delegate.write(buffer, size, presentationTimeUs)
override fun flush() {
rawPositionUs.set(Long.MIN_VALUE)
delegate.flush()
}
override fun stop() = delegate.stop()
override fun release() {
rawPositionUs.set(Long.MIN_VALUE)
if (Build.VERSION.SDK_INT >= 25) {
delegate.stop()
delegate.flush()
provider.returnToCache(this)
} else {
delegate.release()
}
}
fun forceRelease() {
rawPositionUs.set(Long.MIN_VALUE)
delegate.release()
}
override fun setVolume(volume: Float) = delegate.setVolume(volume)
override fun isOffloadedPlayback() = delegate.isOffloadedPlayback()
override fun getAudioSessionId() = delegate.getAudioSessionId()
override fun getSampleRate() = delegate.getSampleRate()
override fun getBufferSizeInFrames() = delegate.getBufferSizeInFrames()
override fun getPlaybackParameters() = delegate.getPlaybackParameters()
override fun isStalled() = delegate.isStalled()
override fun addListener(listener: AudioOutput.Listener) = delegate.addListener(listener)
override fun removeListener(listener: AudioOutput.Listener) = delegate.removeListener(listener)
override fun setPlaybackParameters(playbackParameters: PlaybackParameters) =
delegate.setPlaybackParameters(playbackParameters)
override fun setOffloadDelayPadding(delayInFrames: Int, paddingInFrames: Int) =
delegate.setOffloadDelayPadding(delayInFrames, paddingInFrames)
override fun setOffloadEndOfStream() = delegate.setOffloadEndOfStream()
override fun setPlayerId(playerId: PlayerId) = delegate.setPlayerId(playerId)
override fun attachAuxEffect(effectId: Int) = delegate.attachAuxEffect(effectId)
override fun setAuxEffectSendLevel(level: Float) = delegate.setAuxEffectSendLevel(level)
override fun setPreferredDevice(preferredDevice: AudioDeviceInfo?) =
delegate.setPreferredDevice(preferredDevice)
}
@@ -1,123 +0,0 @@
package com.edde746.plezy.exoplayer
import android.util.Log
import androidx.media3.common.DataReader
import androidx.media3.common.Format
import androidx.media3.common.util.ParsableByteArray
import androidx.media3.extractor.TrackOutput
import java.util.zip.DataFormatException
import java.util.zip.Inflater
/**
* TrackOutput wrapper that inflates zlib-compressed sample data (MKV ContentCompAlgo 0).
* Each MKV block is independently zlib-compressed; this wrapper decompresses per-sample
* between sampleData() and sampleMetadata() calls.
*
* All buffers are reused across samples to minimize GC pressure on the hot path.
*/
class ZlibInflatingTrackOutput(
private val delegate: TrackOutput,
) : TrackOutput {
companion object {
private const val TAG = "ZlibTrackOutput"
private const val INITIAL_BUFFER_SIZE = 256 * 1024
private const val INFLATE_CHUNK = 64 * 1024
}
var active = false
private val inflater = Inflater()
// Reusable buffers — grown as needed, never shrunk
private var compressedBuf = ByteArray(INITIAL_BUFFER_SIZE)
private var compressedLen = 0
private var inflateBuf = ByteArray(INITIAL_BUFFER_SIZE)
private var readBuf = ByteArray(INFLATE_CHUNK)
private val outputParsable = ParsableByteArray()
private var buffering = false
override fun format(format: Format) = delegate.format(format)
override fun sampleData(
input: DataReader, length: Int, allowEndOfInput: Boolean, sampleDataPart: Int
): Int {
if (!active) return delegate.sampleData(input, length, allowEndOfInput, sampleDataPart)
buffering = true
if (readBuf.size < length) readBuf = ByteArray(length)
val bytesRead = input.read(readBuf, 0, length)
if (bytesRead > 0) appendCompressed(readBuf, 0, bytesRead)
return bytesRead
}
override fun sampleData(data: ParsableByteArray, length: Int, sampleDataPart: Int) {
if (!active) {
delegate.sampleData(data, length, sampleDataPart)
return
}
buffering = true
ensureCompressedCapacity(compressedLen + length)
data.readBytes(compressedBuf, compressedLen, length)
compressedLen += length
}
override fun sampleMetadata(
timeUs: Long, flags: Int, size: Int, offset: Int, cryptoData: TrackOutput.CryptoData?
) {
if (!active || !buffering) {
delegate.sampleMetadata(timeUs, flags, size, offset, cryptoData)
return
}
buffering = false
val srcLen = compressedLen
compressedLen = 0
val inflatedLen = try {
inflater.reset()
inflater.setInput(compressedBuf, 0, srcLen)
var written = 0
while (!inflater.finished()) {
if (written == inflateBuf.size) growInflateBuf()
val count = inflater.inflate(inflateBuf, written, inflateBuf.size - written)
if (count == 0 && !inflater.finished()) break
written += count
}
written
} catch (e: DataFormatException) {
Log.e(TAG, "Zlib inflate failed (${srcLen}B), passing raw", e)
// Fall back to raw compressed data
ensureInflateCapacity(srcLen)
System.arraycopy(compressedBuf, 0, inflateBuf, 0, srcLen)
srcLen
}
outputParsable.reset(inflateBuf, inflatedLen)
delegate.sampleData(outputParsable, inflatedLen, TrackOutput.SAMPLE_DATA_PART_MAIN)
delegate.sampleMetadata(timeUs, flags, inflatedLen, 0, cryptoData)
}
private fun appendCompressed(src: ByteArray, offset: Int, length: Int) {
ensureCompressedCapacity(compressedLen + length)
System.arraycopy(src, offset, compressedBuf, compressedLen, length)
compressedLen += length
}
private fun ensureCompressedCapacity(needed: Int) {
if (compressedBuf.size < needed) {
compressedBuf = compressedBuf.copyOf(maxOf(needed, compressedBuf.size * 2))
}
}
private fun ensureInflateCapacity(needed: Int) {
if (inflateBuf.size < needed) {
inflateBuf = ByteArray(maxOf(needed, inflateBuf.size * 2))
}
}
private fun growInflateBuf() {
inflateBuf = inflateBuf.copyOf(inflateBuf.size * 2)
}
}
@@ -1,116 +0,0 @@
package com.edde746.plezy.exoplayer
import android.util.Log
import androidx.media3.extractor.ExtractorInput
import androidx.media3.extractor.ExtractorOutput
import androidx.media3.extractor.SeekMap
import androidx.media3.extractor.TrackOutput
import androidx.media3.extractor.mkv.MatroskaExtractor
import io.github.peerless2012.ass.media.AssHandler
import io.github.peerless2012.ass.media.extractor.AssMatroskaExtractor
import androidx.media3.extractor.text.SubtitleParser
/**
* Extends AssMatroskaExtractor to add support for MKV ContentCompAlgo 0 (zlib).
*
* Media3's MatroskaExtractor only supports ContentCompAlgo 3 (header stripping).
* This subclass intercepts the compression algorithm during track header parsing:
* - Tells the parent it's header stripping (algo 3) to avoid the ParserException
* - Wraps TrackOutputs with ZlibInflatingTrackOutput to decompress per-sample data
* - Skips ContentCompSettings for zlib tracks (not applicable)
*/
class ZlibMatroskaExtractor(
subtitleParserFactory: SubtitleParser.Factory,
assHandler: AssHandler,
) : AssMatroskaExtractor(subtitleParserFactory, assHandler) {
companion object {
private const val TAG = "ZlibMkvExtractor"
// Matroska EBML element IDs
private const val ID_SEGMENT = 0x18538067
private const val ID_TRACK_ENTRY = 0xAE
private const val ID_CONTENT_COMPRESSION_ALGORITHM = 0x4254
private const val ID_CONTENT_COMPRESSION_SETTINGS = 0x4255
private val extractorOutputField by lazy {
MatroskaExtractor::class.java.getDeclaredField("extractorOutput").apply {
isAccessible = true
}
}
}
private var zlibOutput: ZlibExtractorOutputWrapper? = null
private var currentTrackUsesZlib = false
override fun startMasterElement(id: Int, contentPosition: Long, contentSize: Long) {
super.startMasterElement(id, contentPosition, contentSize)
// After super installs AssSubtitleExtractorOutput, wrap it with our zlib layer
if (id == ID_SEGMENT && zlibOutput == null) {
val currentOutput = extractorOutputField.get(this) as ExtractorOutput
val wrapper = ZlibExtractorOutputWrapper(currentOutput)
zlibOutput = wrapper
extractorOutputField.set(this, wrapper)
Log.d(TAG, "Installed zlib ExtractorOutput wrapper")
}
}
override fun integerElement(id: Int, value: Long) {
if (id == ID_CONTENT_COMPRESSION_ALGORITHM && value == 0L) {
currentTrackUsesZlib = true
Log.i(TAG, "Track uses ContentCompAlgo 0 (zlib), will inflate samples")
// Tell parent it's header stripping (algo 3) to avoid ParserException
super.integerElement(id, 3)
return
}
super.integerElement(id, value)
}
override fun binaryElement(id: Int, contentSize: Int, input: ExtractorInput) {
if (id == ID_CONTENT_COMPRESSION_SETTINGS && currentTrackUsesZlib) {
// Skip ContentCompSettings for zlib tracks — parent would store these as
// sampleStrippedBytes and prepend them to every sample, corrupting output.
input.skipFully(contentSize)
return
}
super.binaryElement(id, contentSize, input)
}
override fun endMasterElement(id: Int) {
val wasZlib = currentTrackUsesZlib
super.endMasterElement(id)
if (id == ID_TRACK_ENTRY && wasZlib) {
zlibOutput?.activateLast()
currentTrackUsesZlib = false
Log.i(TAG, "Activated zlib inflation for track")
}
}
/**
* ExtractorOutput wrapper that wraps all TrackOutputs with ZlibInflatingTrackOutput.
* Tracks are created inactive; activateLast() enables inflation for the most recently
* created track (called when we know a track uses zlib compression).
*/
private class ZlibExtractorOutputWrapper(
private val delegate: ExtractorOutput,
) : ExtractorOutput {
private var lastCreatedWrapper: ZlibInflatingTrackOutput? = null
override fun track(id: Int, type: Int): TrackOutput {
val original = delegate.track(id, type)
val wrapper = ZlibInflatingTrackOutput(original)
lastCreatedWrapper = wrapper
return wrapper
}
fun activateLast() {
lastCreatedWrapper?.active = true
}
override fun endTracks() = delegate.endTracks()
override fun seekMap(seekMap: SeekMap) = delegate.seekMap(seekMap)
}
}
@@ -1,826 +0,0 @@
package com.edde746.plezy.mpv
import android.app.Activity
import android.graphics.Color
import android.graphics.PixelFormat
import android.media.ImageReader
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.Surface
import android.view.SurfaceHolder
import android.view.SurfaceView
import android.view.View
import android.view.ViewGroup
import android.view.ViewTreeObserver
import com.edde746.plezy.shared.AudioFocusManager
import com.edde746.plezy.shared.FlutterOverlayHelper
import com.edde746.plezy.shared.FrameRateManager
import com.edde746.plezy.shared.PlayerDelegate
import dev.jdtech.mpv.*
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
companion object {
private const val TAG = "MpvPlayerCore"
}
private var surfaceView: SurfaceView? = null
private var surfaceContainer: android.widget.FrameLayout? = null
private var overlayLayoutListener: ViewTreeObserver.OnGlobalLayoutListener? = null
@Volatile private var disposing: Boolean = false
@Volatile private var pendingSurface: Surface? = null
@Volatile private var attachedSurface: Surface? = null
private var placeholderImageReader: ImageReader? = null
@Volatile private var placeholderSurface: Surface? = null
@Volatile private var lastAppliedSurfaceSize: String? = null
@Volatile private var lastKnownSurfaceWidth: Int = 0
@Volatile private var lastKnownSurfaceHeight: Int = 0
var delegate: PlayerDelegate? = null
var isInitialized: Boolean = false
private set
@Volatile private var player: MpvPlayer? = null
private var scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
// Frame rate matching
private var frameRateManager: FrameRateManager? = null
private val handler = Handler(Looper.getMainLooper())
// Audio focus
private var audioFocusManager: AudioFocusManager? = null
@Volatile private var cachedPaused: Boolean = true
@Volatile private var pausedForSurfaceLoss: Boolean = false
@Volatile private var hasAttachedSurface: Boolean = false
@Volatile private var attachedToPlaceholder: Boolean = false
@Volatile private var videoOutputRestoring: Boolean = false
@Volatile private var deferredResumeRequested: Boolean = false
@Volatile private var resumeBlockedByPublicPause: Boolean = false
@Volatile private var videoOutputEpoch: Long = 0L
private val videoOutputMutex = Mutex()
private var pendingVideoOutputDisableJob: Job? = null
private var pendingVideoOutputRefreshJob: Job? = null
private var flutterOverlayApplied = false
private fun ensureFlutterOverlayOnTop() {
if (disposing || flutterOverlayApplied) return
val contentView = activity.findViewById<ViewGroup>(android.R.id.content)
contentView.post {
if (disposing || !isInitialized) return@post
val container = FlutterOverlayHelper.findFlutterContainer(contentView, surfaceContainer)
?: return@post
if (contentView.getChildAt(contentView.childCount - 1) == container) {
flutterOverlayApplied = true
return@post
}
FlutterOverlayHelper.configureFlutterZOrder(contentView, container, zOrderOnTop = true)
flutterOverlayApplied = true
}
}
private fun ensurePlaceholderSurface() {
if (placeholderSurface?.isValid == true) return
placeholderImageReader?.close()
placeholderImageReader = ImageReader.newInstance(1, 1, PixelFormat.RGBA_8888, 2)
placeholderSurface = placeholderImageReader?.surface
Log.d(TAG, "Created MPV placeholder surface")
}
fun initialize(onResult: (Boolean) -> Unit) {
if (isInitialized) {
Log.d(TAG, "Already initialized")
onResult(true)
return
}
try {
disposing = false
cachedPaused = true
pausedForSurfaceLoss = false
pendingSurface = null
attachedSurface = null
attachedToPlaceholder = false
hasAttachedSurface = false
videoOutputRestoring = false
deferredResumeRequested = false
resumeBlockedByPublicPause = false
videoOutputEpoch = 0L
pendingVideoOutputDisableJob?.cancel()
pendingVideoOutputDisableJob = null
lastAppliedSurfaceSize = null
lastKnownSurfaceWidth = 0
lastKnownSurfaceHeight = 0
ensurePlaceholderSurface()
// Initialize audio focus handling
audioFocusManager = AudioFocusManager(
context = activity,
handler = handler,
onPause = {
scope.launch {
try { player?.setProperty("pause", true) }
catch (e: Exception) { Log.w(TAG, "Failed to pause on focus loss", e) }
}
},
onResume = {
requestAutoResume("audio focus gain")
},
isPaused = { cachedPaused }
)
frameRateManager = FrameRateManager(
activity = activity,
handler = handler,
onDisplayChanged = {
requestAutoResume("display change")
}
)
// Create FrameLayout container for video
surfaceContainer = android.widget.FrameLayout(activity).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
setBackgroundColor(Color.BLACK)
}
// Create SurfaceView for video rendering
surfaceView = SurfaceView(activity).apply {
layoutParams = android.widget.FrameLayout.LayoutParams(
android.widget.FrameLayout.LayoutParams.MATCH_PARENT,
android.widget.FrameLayout.LayoutParams.MATCH_PARENT
)
holder.addCallback(this@MpvPlayerCore)
setZOrderOnTop(false)
setZOrderMediaOverlay(false)
}
// Add SurfaceView to container
surfaceContainer!!.addView(surfaceView)
// Insert container at bottom of view hierarchy (behind Flutter)
val contentView = activity.findViewById<ViewGroup>(android.R.id.content)
contentView.addView(surfaceContainer, 0)
// Find FlutterView and set it on top of our video surface
FlutterOverlayHelper.findFlutterContainer(contentView, surfaceContainer)?.let { container ->
FlutterOverlayHelper.configureFlutterZOrder(contentView, container, zOrderOnTop = true)
flutterOverlayApplied = true
}
ensureFlutterOverlayOnTop()
overlayLayoutListener = ViewTreeObserver.OnGlobalLayoutListener {
ensureFlutterOverlayOnTop()
val sv = surfaceView
if (sv != null) applySurfaceSize(sv.width, sv.height)
}
contentView.viewTreeObserver.addOnGlobalLayoutListener(overlayLayoutListener)
Log.d(TAG, "SurfaceView added to content view")
// Create MpvPlayer on background thread via coroutine
scope.launch {
try {
if (disposing) {
onResult(false)
return@launch
}
val p = MpvPlayer.create(activity.applicationContext) {
setOption("vo", "gpu")
setOption("gpu-context", "android")
setOption("opengl-es", "yes")
setOption("vd-lavc-film-grain", "cpu")
setOption("ao", "audiotrack,opensles")
}
if (disposing) {
p.close()
onResult(false)
return@launch
}
player = p
isInitialized = true
refreshVideoOutput("initialize")
// Start collecting events/properties/logs
collectEvents(p)
collectPropertyChanges(p)
collectLogMessages(p)
Log.d(TAG, "Initialized successfully")
onResult(true)
} catch (e: Exception) {
Log.e(TAG, "Failed to initialize native: ${e.message}", e)
onResult(false)
}
}
} catch (e: Exception) {
Log.e(TAG, "Failed to initialize: ${e.message}", e)
onResult(false)
}
}
// Flow collectors
private fun collectEvents(p: MpvPlayer) {
scope.launch(start = CoroutineStart.UNDISPATCHED) {
p.eventFlow.collect { event ->
when (event) {
is MpvEvent.EndFile -> {
val data = event.reason?.let { mapOf("reason" to it.id) }
delegate?.onEvent("end-file", data)
}
is MpvEvent.FileLoaded -> delegate?.onEvent("file-loaded", null)
is MpvEvent.PlaybackRestart -> delegate?.onEvent("playback-restart", null)
else -> {}
}
}
}
}
private fun collectPropertyChanges(p: MpvPlayer) {
scope.launch(start = CoroutineStart.UNDISPATCHED) {
p.propertyFlow.collect { change ->
// Skip None — matches old MPVLib behavior where eventProperty(name)
// with no value was a no-op. Forwarding null would incorrectly clear
// track selections (aid/sid) before the file loads.
if (change is PropertyChange.None) return@collect
val value: Any? = when (change) {
is PropertyChange.Flag -> change.value
is PropertyChange.Int64 -> change.value
is PropertyChange.Double -> change.value
is PropertyChange.Str -> change.value
is PropertyChange.None -> null
}
if (change.name == "pause" && change is PropertyChange.Flag) {
cachedPaused = change.value
}
delegate?.onPropertyChange(change.name, value)
}
}
}
private fun collectLogMessages(p: MpvPlayer) {
scope.launch(start = CoroutineStart.UNDISPATCHED) {
p.logFlow.collect { msg ->
delegate?.onEvent("log-message", mapOf(
"prefix" to msg.prefix,
"level" to msg.level.name.lowercase(),
"text" to msg.text
))
}
}
}
// Audio Focus
fun requestAudioFocus(): Boolean = audioFocusManager?.requestAudioFocus() ?: false
fun abandonAudioFocus() { audioFocusManager?.abandonAudioFocus() }
// SurfaceHolder.Callback
override fun surfaceCreated(holder: SurfaceHolder) {
Log.d(TAG, "Surface created")
if (disposing) return
val surface = holder.surface
pendingSurface = surface.takeIf { it.isValid }
pendingVideoOutputDisableJob?.cancel()
videoOutputEpoch += 1L
rememberCurrentSurfaceSize()
if (player == null) {
Log.d(TAG, "Deferring video output refresh until MPV init completes")
return
}
refreshVideoOutput("surfaceCreated")
}
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
Log.d(TAG, "Surface changed: ${width}x${height}")
rememberSurfaceSize(width, height)
refreshVideoOutput("surfaceChanged")
}
override fun surfaceDestroyed(holder: SurfaceHolder) {
Log.d(TAG, "Surface destroyed")
pendingSurface = null
if (player == null || disposing) return
detachSurfaceInternal(reason = "surfaceDestroyed")
}
private fun rememberSurfaceSize(width: Int, height: Int) {
if (width <= 0 || height <= 0) return
lastKnownSurfaceWidth = width
lastKnownSurfaceHeight = height
}
private fun rememberCurrentSurfaceSize() {
val sv = surfaceView ?: return
rememberSurfaceSize(sv.width, sv.height)
}
private fun currentCandidateSurface(): Surface? =
surfaceView?.holder?.surface?.takeIf { it.isValid }
?: pendingSurface?.takeIf { it.isValid }
private fun hasAttachedRealSurface(): Boolean =
hasAttachedSurface && !attachedToPlaceholder && (attachedSurface?.isValid == true)
private fun hasReadyVideoOutput(): Boolean =
hasAttachedRealSurface() && !videoOutputRestoring
private fun isCurrentVideoOutputEpoch(epoch: Long): Boolean =
!disposing && epoch == videoOutputEpoch
private fun isVideoOutputRefreshCurrent(epoch: Long): Boolean {
if (disposing) return false
if (epoch != videoOutputEpoch) return false
return hasAttachedRealSurface()
}
private fun refreshVideoOutput(reason: String) {
if (disposing) return
rememberCurrentSurfaceSize()
val p = player
val surface = currentCandidateSurface()
if (p == null) {
pendingSurface = surface?.takeIf { it.isValid }
Log.d(TAG, "refreshVideoOutput($reason): player not ready yet")
return
}
if (surface == null || !surface.isValid) {
hasAttachedSurface = false
attachedSurface = null
attachedToPlaceholder = false
pendingSurface = null
lastAppliedSurfaceSize = null
videoOutputRestoring = true
Log.d(TAG, "refreshVideoOutput($reason): no valid surface available")
return
}
val refreshEpoch = videoOutputEpoch
pendingVideoOutputDisableJob?.cancel()
videoOutputRestoring = true
flutterOverlayApplied = false
ensureFlutterOverlayOnTop()
Log.d(TAG, "refreshVideoOutput($reason): scheduling async refresh (epoch=$refreshEpoch)")
pendingVideoOutputRefreshJob = scope.launch(Dispatchers.IO) {
try {
videoOutputMutex.withLock {
if (!isCurrentVideoOutputEpoch(refreshEpoch)) {
Log.d(TAG, "Skipping stale MPV video output refresh ($reason, epoch=$refreshEpoch)")
return@withLock
}
if (!surface.isValid) {
hasAttachedSurface = false
attachedSurface = null
attachedToPlaceholder = false
pendingSurface = null
lastAppliedSurfaceSize = null
videoOutputRestoring = true
Log.d(TAG, "Skipping MPV video output refresh with invalid surface ($reason, epoch=$refreshEpoch)")
return@withLock
}
val needsAttach = !hasAttachedSurface || attachedSurface !== surface
val wasAttachedToPlaceholder = attachedToPlaceholder
val wasPausedForSurfaceLoss = pausedForSurfaceLoss
if (needsAttach) {
p.attachSurface(surface)
attachedSurface = surface
hasAttachedSurface = true
attachedToPlaceholder = false
pendingSurface = null
Log.d(TAG, "refreshVideoOutput($reason): attached surface")
} else {
Log.d(TAG, "refreshVideoOutput($reason): surface already attached, refreshing surface state")
}
if (!isVideoOutputRefreshCurrent(refreshEpoch)) {
Log.d(TAG, "Skipping stale MPV video output refresh after attach ($reason, epoch=$refreshEpoch)")
return@withLock
}
applySurfaceSizeInternal(p, force = true)
if (!isVideoOutputRefreshCurrent(refreshEpoch)) {
Log.d(TAG, "Skipping stale MPV video output refresh after surface size ($reason, epoch=$refreshEpoch)")
return@withLock
}
videoOutputRestoring = false
applyDeferredResumeIfNeeded(p, reason)
if (wasPausedForSurfaceLoss) {
pausedForSurfaceLoss = false
Log.d(TAG, "Cleared surface-loss pause after $reason")
}
if (wasAttachedToPlaceholder) {
Log.d(TAG, "Restored MPV real surface after placeholder ($reason)")
}
Log.d(TAG, "Video output ready after $reason")
}
} catch (e: CancellationException) {
Log.d(TAG, "Canceled pending MPV video output refresh ($reason, epoch=$refreshEpoch)")
} catch (e: Exception) {
Log.w(TAG, "Failed to finalize MPV video output refresh ($reason)", e)
}
}
}
private fun applySurfaceSize(width: Int, height: Int) {
val p = player ?: return
if (disposing || width <= 0 || height <= 0) return
rememberSurfaceSize(width, height)
if (!hasReadyVideoOutput()) return
scope.launch {
try { applySurfaceSizeInternal(p) }
catch (e: Exception) { Log.w(TAG, "Failed to apply surface size to MPV", e) }
}
}
private suspend fun applySurfaceSizeInternal(p: MpvPlayer, force: Boolean = false) {
if (disposing) return
val width = lastKnownSurfaceWidth
val height = lastKnownSurfaceHeight
if (width <= 0 || height <= 0) return
val size = "${width}x${height}"
if (!force && size == lastAppliedSurfaceSize) return
p.setProperty("android-surface-size", size)
lastAppliedSurfaceSize = size
Log.d(TAG, "Applied MPV surface size $size${if (force) " (forced)" else ""}")
}
private fun schedulePlaceholderSurfaceAttach(
p: MpvPlayer,
reason: String,
epoch: Long
) {
pendingVideoOutputDisableJob?.cancel()
pendingVideoOutputDisableJob = scope.launch(Dispatchers.IO) {
try {
videoOutputMutex.withLock {
if (!isCurrentVideoOutputEpoch(epoch)) {
Log.d(TAG, "Skipping stale MPV placeholder attach ($reason, epoch=$epoch)")
return@withLock
}
val wasPaused = try {
p.getFlag("pause") == true
} catch (e: Exception) {
cachedPaused
}
if (!wasPaused) {
try {
p.setProperty("pause", true)
cachedPaused = true
pausedForSurfaceLoss = true
Log.d(TAG, "Paused MPV for surface loss ($reason, epoch=$epoch)")
} catch (e: Exception) {
pausedForSurfaceLoss = false
Log.w(TAG, "Failed to pause MPV before placeholder attach ($reason)", e)
}
} else {
pausedForSurfaceLoss = false
}
val surface = placeholderSurface?.takeIf { it.isValid } ?: run {
Log.w(TAG, "No valid MPV placeholder surface available for $reason")
return@withLock
}
p.attachSurface(surface)
attachedSurface = surface
hasAttachedSurface = true
attachedToPlaceholder = true
lastAppliedSurfaceSize = null
Log.d(TAG, "Attached MPV placeholder surface ($reason, epoch=$epoch)")
}
} catch (e: CancellationException) {
Log.d(TAG, "Canceled pending MPV placeholder attach ($reason, epoch=$epoch)")
} catch (e: Exception) {
Log.w(TAG, "Failed to attach MPV placeholder surface ($reason)", e)
}
}
}
private fun detachSurfaceInternal(reason: String) {
val hadAttachedSurface = hasAttachedSurface || attachedSurface != null
hasAttachedSurface = false
attachedSurface = null
attachedToPlaceholder = false
videoOutputRestoring = true
lastAppliedSurfaceSize = null
val detachEpoch = videoOutputEpoch + 1L
videoOutputEpoch = detachEpoch
val p = player ?: return
if (!hadAttachedSurface) {
Log.d(TAG, "detachSurfaceInternal($reason): no attached surface to clear")
return
}
schedulePlaceholderSurfaceAttach(
p = p,
reason = reason,
epoch = detachEpoch
)
Log.d(TAG, "Cleared MPV surface attachment ($reason, epoch=$detachEpoch)")
}
private fun normalizePauseValue(value: String): Boolean? = when (value.lowercase()) {
"yes", "true", "1" -> true
"no", "false", "0" -> false
else -> null
}
private fun requestAutoResume(reason: String) {
val p = player ?: return
if (disposing) return
if (resumeBlockedByPublicPause) {
deferredResumeRequested = false
Log.d(TAG, "Skipping auto-resume after $reason because playback is explicitly paused")
return
}
if (!hasReadyVideoOutput()) {
deferredResumeRequested = true
Log.d(TAG, "Deferring auto-resume after $reason until video output is ready")
return
}
scope.launch {
try {
if (p.getFlag("pause") == true) {
Log.d(TAG, "Auto-resuming playback after $reason")
p.setProperty("pause", false)
} else {
Log.d(TAG, "Skipping auto-resume after $reason because playback is already running")
}
} catch (e: Exception) {
Log.w(TAG, "Failed to resume after $reason", e)
}
}
}
private suspend fun applyDeferredResumeIfNeeded(p: MpvPlayer, reason: String) {
if (!deferredResumeRequested) return
if (resumeBlockedByPublicPause) {
deferredResumeRequested = false
Log.d(TAG, "Dropping deferred auto-resume after $reason because playback is explicitly paused")
return
}
deferredResumeRequested = false
if (p.getFlag("pause") == true) {
Log.d(TAG, "Applying deferred auto-resume after $reason")
p.setProperty("pause", false)
} else {
Log.d(TAG, "Skipping deferred auto-resume after $reason because playback is already running")
}
}
// Public API
fun setProperty(name: String, value: String) {
if (!isInitialized || disposing) return
if (name == "pause") {
val paused = normalizePauseValue(value)
if (paused == true) {
cachedPaused = true
pausedForSurfaceLoss = false
resumeBlockedByPublicPause = true
deferredResumeRequested = false
Log.d(TAG, "Public pause state updated: paused=true")
} else if (paused == false) {
resumeBlockedByPublicPause = false
if (!hasReadyVideoOutput()) {
deferredResumeRequested = true
Log.d(TAG, "Deferring public resume until video output is ready")
return
}
cachedPaused = false
pausedForSurfaceLoss = false
Log.d(TAG, "Public pause state updated: paused=false")
}
}
scope.launch {
try { player?.setProperty(name, value) }
catch (e: Exception) { Log.w(TAG, "setProperty($name) failed", e) }
}
}
fun getProperty(name: String): String? {
if (!isInitialized || disposing) return null
return try {
runBlocking(Dispatchers.IO) { player?.getString(name) }
} catch (e: Exception) {
null
}
}
fun observeProperty(name: String, format: String) {
val p = player ?: return
if (!isInitialized) return
val fmt = when (format) {
"double" -> PropertyFormat.Double
"flag" -> PropertyFormat.Flag
"string" -> PropertyFormat.String
else -> PropertyFormat.None
}
p.observeProperty(name, fmt)
}
fun command(args: Array<String>) {
if (!isInitialized || disposing || args.isEmpty()) return
scope.launch {
try { player?.command(*args) }
catch (e: Exception) { Log.w(TAG, "command failed", e) }
}
}
fun setVisible(visible: Boolean) {
if (disposing) return
activity.runOnUiThread {
if (disposing) return@runOnUiThread
surfaceContainer?.visibility = if (visible) View.VISIBLE else View.INVISIBLE
if (visible) {
flutterOverlayApplied = false
ensureFlutterOverlayOnTop()
rememberCurrentSurfaceSize()
val surface = currentCandidateSurface()
if (surface != null) {
pendingSurface = surface
refreshVideoOutput("setVisible")
} else {
val sv = surfaceView
if (sv != null) {
applySurfaceSize(sv.width, sv.height)
}
}
}
Log.d(TAG, "setVisible($visible)")
}
}
fun onPipModeChanged(isInPipMode: Boolean) {
// MPV handles aspect ratio internally via its own surface management
}
fun updateFrame() {
if (disposing) return
activity.runOnUiThread {
if (disposing) return@runOnUiThread
flutterOverlayApplied = false
ensureFlutterOverlayOnTop()
rememberCurrentSurfaceSize()
val p = player
if (p == null) {
Log.d(TAG, "updateFrame(): skipping Android MPV surface refresh because player is not ready")
return@runOnUiThread
}
if (!hasReadyVideoOutput()) {
val surface = currentCandidateSurface()
if (surface != null) {
pendingSurface = surface
refreshVideoOutput("updateFrame")
} else {
Log.d(TAG, "updateFrame(): skipping Android MPV surface refresh because no surface is attached")
}
return@runOnUiThread
}
scope.launch {
try {
applySurfaceSizeInternal(p, force = true)
} catch (e: Exception) {
Log.w(TAG, "Failed to update Android MPV surface frame", e)
}
}
}
}
// Frame Rate Matching
fun setVideoFrameRate(fps: Float, videoDurationMs: Long) {
frameRateManager?.setVideoFrameRate(fps, videoDurationMs, surfaceView?.holder?.surface)
}
fun clearVideoFrameRate() {
frameRateManager?.clearVideoFrameRate()
}
// Cleanup
fun dispose(onComplete: (() -> Unit)? = null) {
if (disposing) {
onComplete?.invoke()
return
}
disposing = true
check(Looper.myLooper() == Looper.getMainLooper())
Log.d(TAG, "Disposing")
handler.removeCallbacksAndMessages(null)
// Clean up frame rate and audio focus
frameRateManager?.clearVideoFrameRate()
frameRateManager = null
audioFocusManager?.release()
audioFocusManager = null
// Cancel all coroutines
scope.cancel()
pendingVideoOutputDisableJob?.cancel()
pendingVideoOutputDisableJob = null
pendingVideoOutputRefreshJob?.cancel()
pendingVideoOutputRefreshJob = null
// Clear surface state flags (no native calls on main thread to avoid ANR)
val p = player
if (p != null) {
hasAttachedSurface = false
attachedSurface = null
pausedForSurfaceLoss = false
attachedToPlaceholder = false
videoOutputRestoring = false
lastAppliedSurfaceSize = null
videoOutputEpoch += 1L
}
// Capture locals for deferred cleanup
val sv = surfaceView
val container = surfaceContainer
val contentView = activity.findViewById<ViewGroup>(android.R.id.content)
surfaceContainer = null
surfaceView = null
// Remove layout listener synchronously
overlayLayoutListener?.let { listener ->
contentView.viewTreeObserver.removeOnGlobalLayoutListener(listener)
}
overlayLayoutListener = null
pendingSurface = null
placeholderSurface?.release()
placeholderSurface = null
placeholderImageReader?.close()
placeholderImageReader = null
pausedForSurfaceLoss = false
attachedToPlaceholder = false
videoOutputRestoring = false
deferredResumeRequested = false
resumeBlockedByPublicPause = false
videoOutputEpoch = 0L
pendingVideoOutputDisableJob = null
isInitialized = false
// Detach surface and close player on background thread, then remove views
if (p != null) {
Thread {
try {
// Detach surface BEFORE close to prevent GPU mutex contention with view removal
try {
runBlocking {
p.setProperty("force-window", "no")
p.setProperty("vo", "null")
}
p.detachSurface()
} catch (e: Exception) {
Log.w(TAG, "Failed to detach surface during dispose", e)
}
p.close()
} catch (e: Exception) {
Log.w(TAG, "MPV close failed", e)
}
player = null
Log.d(TAG, "Disposed (native)")
Handler(Looper.getMainLooper()).post {
sv?.holder?.removeCallback(this)
if (container?.parent != null) {
contentView.removeView(container)
}
onComplete?.invoke()
}
}.start()
} else {
// No player — safe to remove views immediately
Handler(Looper.getMainLooper()).postAtFrontOfQueue {
sv?.holder?.removeCallback(this)
if (container?.parent != null) {
contentView.removeView(container)
}
}
onComplete?.invoke()
}
// Reset scope for potential re-initialization
scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
}
}
@@ -1,316 +0,0 @@
package com.edde746.plezy.mpv
import android.app.Activity
import android.net.Uri
import android.util.Log
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
class MpvPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
EventChannel.StreamHandler, ActivityAware, com.edde746.plezy.shared.PlayerDelegate {
companion object {
private const val TAG = "MpvPlayerPlugin"
private const val METHOD_CHANNEL = "com.plezy/mpv_player"
private const val EVENT_CHANNEL = "com.plezy/mpv_player/events"
}
private lateinit var methodChannel: MethodChannel
private lateinit var eventChannel: EventChannel
private var eventSink: EventChannel.EventSink? = null
private var playerCore: MpvPlayerCore? = null
private var activity: Activity? = null
private var activityBinding: ActivityPluginBinding? = null
private val nameToId = mutableMapOf<String, Int>()
private var sessionGeneration = 0
// FlutterPlugin
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
methodChannel = MethodChannel(binding.binaryMessenger, METHOD_CHANNEL)
methodChannel.setMethodCallHandler(this)
eventChannel = EventChannel(binding.binaryMessenger, EVENT_CHANNEL)
eventChannel.setStreamHandler(this)
Log.d(TAG, "Attached to engine")
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
methodChannel.setMethodCallHandler(null)
eventChannel.setStreamHandler(null)
Log.d(TAG, "Detached from engine")
}
// ActivityAware
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
activity = binding.activity
activityBinding = binding
Log.d(TAG, "Attached to activity")
}
override fun onDetachedFromActivity() {
++sessionGeneration
playerCore?.dispose()
playerCore = null
activity = null
activityBinding = null
Log.d(TAG, "Detached from activity")
}
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
activity = binding.activity
activityBinding = binding
Log.d(TAG, "Reattached to activity for config changes")
}
override fun onDetachedFromActivityForConfigChanges() {
activity = null
activityBinding = null
Log.d(TAG, "Detached from activity for config changes")
}
// EventChannel.StreamHandler
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
eventSink = events
Log.d(TAG, "Event stream connected")
}
override fun onCancel(arguments: Any?) {
eventSink = null
Log.d(TAG, "Event stream disconnected")
}
// MethodChannel.MethodCallHandler
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"initialize" -> handleInitialize(result)
"dispose" -> handleDispose(result)
"setProperty" -> handleSetProperty(call, result)
"getProperty" -> handleGetProperty(call, result)
"observeProperty" -> handleObserveProperty(call, result)
"command" -> handleCommand(call, result)
"setVisible" -> handleSetVisible(call, result)
"updateFrame" -> handleUpdateFrame(result)
"setVideoFrameRate" -> handleSetVideoFrameRate(call, result)
"clearVideoFrameRate" -> handleClearVideoFrameRate(result)
"requestAudioFocus" -> handleRequestAudioFocus(result)
"abandonAudioFocus" -> handleAbandonAudioFocus(result)
"openContentFd" -> handleOpenContentFd(call, result)
"isInitialized" -> result.success(playerCore?.isInitialized ?: false)
"setLogLevel" -> result.success(null)
else -> result.notImplemented()
}
}
private fun handleInitialize(result: MethodChannel.Result) {
val currentActivity = activity
if (currentActivity == null) {
result.error("NO_ACTIVITY", "Activity not available", null)
return
}
if (playerCore?.isInitialized == true) {
Log.d(TAG, "Already initialized")
result.success(true)
return
}
currentActivity.runOnUiThread {
try {
// Dispose stale core idempotently
playerCore?.dispose()
playerCore = null
val gen = ++sessionGeneration
val core = MpvPlayerCore(currentActivity).apply {
delegate = this@MpvPlayerPlugin
}
playerCore = core
core.initialize { success ->
if (gen != sessionGeneration || playerCore !== core) {
Log.d(TAG, "Stale init callback (gen=$gen, current=$sessionGeneration)")
result.success(false)
return@initialize
}
// Start hidden - now safe because setVisible operates on the container,
// not the SurfaceView directly (matching ExoPlayer's approach)
core.setVisible(false)
Log.d(TAG, "Initialized: $success")
result.success(success)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to initialize: ${e.message}", e)
result.error("INIT_FAILED", e.message, null)
}
}
}
private fun handleDispose(result: MethodChannel.Result) {
activity?.runOnUiThread {
val core = playerCore
++sessionGeneration
playerCore = null
core?.dispose {
Log.d(TAG, "Disposed")
result.success(null)
} ?: result.success(null)
} ?: result.success(null)
}
private fun handleSetProperty(call: MethodCall, result: MethodChannel.Result) {
val name = call.argument<String>("name")
val value = call.argument<String>("value")
if (name == null || value == null) {
result.error("INVALID_ARGS", "Missing 'name' or 'value'", null)
return
}
playerCore?.setProperty(name, value)
result.success(null)
}
private fun handleGetProperty(call: MethodCall, result: MethodChannel.Result) {
val name = call.argument<String>("name")
if (name == null) {
result.error("INVALID_ARGS", "Missing 'name'", null)
return
}
val value = playerCore?.getProperty(name)
result.success(value)
}
private fun handleObserveProperty(call: MethodCall, result: MethodChannel.Result) {
val name = call.argument<String>("name")
val format = call.argument<String>("format")
val id = call.argument<Int>("id")
if (name == null || format == null || id == null) {
result.error("INVALID_ARGS", "Missing 'name', 'format', or 'id'", null)
return
}
nameToId[name] = id
playerCore?.observeProperty(name, format)
result.success(null)
}
private fun handleCommand(call: MethodCall, result: MethodChannel.Result) {
val args = call.argument<List<String>>("args")
if (args == null) {
result.error("INVALID_ARGS", "Missing 'args'", null)
return
}
playerCore?.command(args.toTypedArray())
result.success(null)
}
private fun handleSetVisible(call: MethodCall, result: MethodChannel.Result) {
val visible = call.argument<Boolean>("visible")
if (visible == null) {
result.error("INVALID_ARGS", "Missing 'visible'", null)
return
}
playerCore?.setVisible(visible)
result.success(null)
}
private fun handleUpdateFrame(result: MethodChannel.Result) {
playerCore?.updateFrame()
result.success(null)
}
private fun handleSetVideoFrameRate(call: MethodCall, result: MethodChannel.Result) {
val fps = call.argument<Double>("fps")?.toFloat() ?: 0f
val duration = call.argument<Number>("duration")?.toLong() ?: 0L
Log.d(TAG, "setVideoFrameRate: fps=$fps, duration=$duration")
playerCore?.setVideoFrameRate(fps, duration)
result.success(null)
}
private fun handleClearVideoFrameRate(result: MethodChannel.Result) {
Log.d(TAG, "clearVideoFrameRate")
playerCore?.clearVideoFrameRate()
result.success(null)
}
private fun handleRequestAudioFocus(result: MethodChannel.Result) {
Log.d(TAG, "requestAudioFocus")
val granted = playerCore?.requestAudioFocus() ?: false
result.success(granted)
}
private fun handleAbandonAudioFocus(result: MethodChannel.Result) {
Log.d(TAG, "abandonAudioFocus")
playerCore?.abandonAudioFocus()
result.success(null)
}
private fun handleOpenContentFd(call: MethodCall, result: MethodChannel.Result) {
val uriString = call.argument<String>("uri")
if (uriString == null) {
result.error("INVALID_ARGS", "Missing 'uri'", null)
return
}
val contentResolver = activity?.contentResolver
if (contentResolver == null) {
result.error("NO_ACTIVITY", "Activity not available", null)
return
}
// Open file descriptor off UI thread to prevent ANR on slow storage
Thread {
try {
val uri = Uri.parse(uriString)
val pfd = contentResolver.openFileDescriptor(uri, "r")
if (pfd == null) {
activity?.runOnUiThread {
result.error("OPEN_FAILED", "Failed to open file descriptor for $uriString", null)
}
return@Thread
}
val fd = pfd.detachFd()
Log.d(TAG, "Opened content FD $fd for $uriString")
activity?.runOnUiThread { result.success(fd) }
} catch (e: Exception) {
Log.e(TAG, "Failed to open content FD: ${e.message}", e)
activity?.runOnUiThread { result.error("OPEN_FAILED", e.message, null) }
}
}.start()
}
// PlayerDelegate
override fun onPropertyChange(name: String, value: Any?) {
val propId = nameToId[name] ?: return
eventSink?.success(listOf(propId, value))
}
override fun onEvent(name: String, data: Map<String, Any>?) {
val event = mutableMapOf<String, Any>(
"type" to "event",
"name" to name
)
data?.let { event["data"] = it }
eventSink?.success(event)
}
}
@@ -1,105 +0,0 @@
package com.edde746.plezy.shared
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.os.Build
import android.os.Handler
import android.util.Log
class AudioFocusManager(
context: Context,
private val handler: Handler,
private val onPause: () -> Unit,
private val onResume: () -> Unit,
private val isPaused: () -> Boolean,
private val log: (String) -> Unit = { Log.d(TAG, it) }
) {
companion object {
private const val TAG = "AudioFocusManager"
}
private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
private var audioFocusRequest: AudioFocusRequest? = null
private var hasAudioFocus: Boolean = false
var wasPlayingBeforeFocusLoss: Boolean = false
private set
private val audioFocusChangeListener = AudioManager.OnAudioFocusChangeListener { focusChange ->
when (focusChange) {
AudioManager.AUDIOFOCUS_GAIN -> {
log("Focus gained")
hasAudioFocus = true
if (wasPlayingBeforeFocusLoss) {
onResume()
wasPlayingBeforeFocusLoss = false
}
}
AudioManager.AUDIOFOCUS_LOSS -> {
log("Focus lost permanently")
hasAudioFocus = false
wasPlayingBeforeFocusLoss = !isPaused()
onPause()
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
log("Focus lost transiently")
hasAudioFocus = false
wasPlayingBeforeFocusLoss = !isPaused()
onPause()
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
log("Focus lost transiently (can duck)")
}
}
}
fun requestAudioFocus(): Boolean {
Log.d(TAG, "Requesting audio focus")
val result = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MOVIE)
.build()
)
.setOnAudioFocusChangeListener(audioFocusChangeListener, handler)
.build()
audioFocusRequest = focusRequest
audioManager.requestAudioFocus(focusRequest)
} else {
@Suppress("DEPRECATION")
audioManager.requestAudioFocus(
audioFocusChangeListener,
AudioManager.STREAM_MUSIC,
AudioManager.AUDIOFOCUS_GAIN
)
}
hasAudioFocus = (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED)
Log.d(TAG, "Audio focus request result: $result, granted: $hasAudioFocus")
return hasAudioFocus
}
fun abandonAudioFocus() {
Log.d(TAG, "Abandoning audio focus")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
audioFocusRequest?.let { audioManager.abandonAudioFocusRequest(it) }
audioFocusRequest = null
} else {
@Suppress("DEPRECATION")
audioManager.abandonAudioFocus(audioFocusChangeListener)
}
hasAudioFocus = false
wasPlayingBeforeFocusLoss = false
}
fun release() {
abandonAudioFocus()
}
}
@@ -1,59 +0,0 @@
package com.edde746.plezy.shared
import android.graphics.PixelFormat
import android.view.SurfaceView
import android.view.TextureView
import android.view.View
import android.view.ViewGroup
object FlutterOverlayHelper {
/**
* Find the FlutterView container in the content view hierarchy.
* First tries to match by class name (works in debug builds),
* then falls back to picking the last ViewGroup with children
* (needed for release builds where FlutterView may be obfuscated).
*/
fun findFlutterContainer(contentView: ViewGroup, excludeView: View? = null): ViewGroup? {
// First pass: look for FlutterView by name (debug builds)
for (i in 0 until contentView.childCount) {
val child = contentView.getChildAt(i)
if (child is ViewGroup && child.javaClass.name.contains("FlutterView")) {
return child
}
}
// Fallback for release (FlutterView may be obfuscated): pick the last ViewGroup
// that is not our video container and has children.
for (i in contentView.childCount - 1 downTo 0) {
val child = contentView.getChildAt(i)
if (child is ViewGroup && child != excludeView && child.childCount > 0) {
return child
}
}
return null
}
/**
* Configure z-ordering so the Flutter UI renders above the video surface.
*
* @param zOrderOnTop true for MPV (Flutter needs to be fully on top),
* false for ExoPlayer (subtitle layer sits between video and Flutter)
*/
fun configureFlutterZOrder(contentView: ViewGroup, container: ViewGroup, zOrderOnTop: Boolean) {
contentView.bringChildToFront(container)
for (j in 0 until container.childCount) {
val flutterChild = container.getChildAt(j)
if (flutterChild is SurfaceView) {
flutterChild.setZOrderOnTop(zOrderOnTop)
flutterChild.setZOrderMediaOverlay(true)
flutterChild.holder.setFormat(PixelFormat.TRANSLUCENT)
break
} else if (flutterChild is TextureView) {
flutterChild.isOpaque = false
break
}
}
}
}
@@ -1,184 +0,0 @@
package com.edde746.plezy.shared
import android.app.Activity
import android.content.Context
import android.hardware.display.DisplayManager
import android.os.Build
import android.os.Handler
import android.util.Log
import android.view.Surface
import android.view.WindowManager
import androidx.annotation.RequiresApi
import java.math.BigDecimal
import java.math.RoundingMode
class FrameRateManager(
private val activity: Activity,
private val handler: Handler,
private val onDisplayChanged: () -> Unit,
private val log: (String) -> Unit = { Log.d(TAG, it) }
) {
companion object {
private const val TAG = "FrameRateManager"
private const val SHORT_VIDEO_LENGTH_MS = 300000L // 5 minutes
}
private var currentVideoFps: Float = 0f
private var displayListener: DisplayManager.DisplayListener? = null
private fun getDisplayManager(): DisplayManager {
return activity.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
}
fun setVideoFrameRate(fps: Float, videoDurationMs: Long, surface: Surface?) {
currentVideoFps = fps
if (fps <= 0f) {
Log.d(TAG, "setVideoFrameRate: Invalid fps ($fps), skipping")
return
}
log("fps=$fps, duration=${videoDurationMs}ms, API=${Build.VERSION.SDK_INT}")
when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
if (surface == null) {
Log.d(TAG, "setVideoFrameRate: Surface not available")
return
}
setFrameRateS(fps, surface, videoDurationMs)
}
// API R's Surface.setFrameRate() only supports seamless switching (no
// CHANGE_FRAME_RATE_ALWAYS), so 60→24Hz won't switch. Fall through to
// preferredDisplayModeId which directly sets the display mode.
Build.VERSION.SDK_INT >= Build.VERSION_CODES.M -> setFrameRateM(fps)
}
}
fun clearVideoFrameRate() {
Log.d(TAG, "clearVideoFrameRate")
currentVideoFps = 0f
displayListener?.let {
getDisplayManager().unregisterDisplayListener(it)
displayListener = null
}
// Restore default display mode on API M (preferredDisplayModeId persists)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
activity.window?.attributes?.let { attrs ->
attrs.preferredDisplayModeId = 0
activity.window?.attributes = attrs
}
}
}
private fun registerDisplayListener() {
displayListener?.let {
getDisplayManager().unregisterDisplayListener(it)
}
displayListener = object : DisplayManager.DisplayListener {
override fun onDisplayAdded(displayId: Int) = Unit
override fun onDisplayRemoved(displayId: Int) = Unit
override fun onDisplayChanged(displayId: Int) {
handler.postDelayed({
onDisplayChanged()
}, 2000L)
getDisplayManager().unregisterDisplayListener(this)
displayListener = null
}
}
getDisplayManager().registerDisplayListener(displayListener, handler)
}
@RequiresApi(Build.VERSION_CODES.S)
private fun setFrameRateS(fps: Float, surface: Surface, videoDurationMs: Long) {
Log.d(TAG, "setFrameRateS: fps=$fps, duration=${videoDurationMs}ms")
if (videoDurationMs < SHORT_VIDEO_LENGTH_MS) {
Log.d(TAG, "Short video, using seamless-only switching")
surface.setFrameRate(
fps,
Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE,
Surface.CHANGE_FRAME_RATE_ONLY_IF_SEAMLESS
)
return
}
var seamless = false
activity.display?.mode?.alternativeRefreshRates?.let { refreshRates ->
for (rate in refreshRates) {
if (fps.toString().startsWith(rate.toString()) ||
rate.toString().startsWith(fps.toString()) ||
rate % fps == 0f) {
seamless = true
break
}
}
}
if (seamless) {
log("Seamless switch available for ${fps}fps")
surface.setFrameRate(
fps,
Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE,
Surface.CHANGE_FRAME_RATE_ALWAYS
)
registerDisplayListener()
} else {
val userPreference = getDisplayManager().matchContentFrameRateUserPreference
if (userPreference == DisplayManager.MATCH_CONTENT_FRAMERATE_ALWAYS) {
Log.d(TAG, "User preference allows non-seamless switch")
surface.setFrameRate(
fps,
Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE,
Surface.CHANGE_FRAME_RATE_ALWAYS
)
registerDisplayListener()
} else {
Log.d(TAG, "Non-seamless switch not allowed, using seamless-only")
surface.setFrameRate(
fps,
Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE,
Surface.CHANGE_FRAME_RATE_ONLY_IF_SEAMLESS
)
}
}
}
@RequiresApi(Build.VERSION_CODES.M)
private fun setFrameRateM(fps: Float) {
Log.d(TAG, "setFrameRateM: fps=$fps")
val wm = activity.getSystemService(Context.WINDOW_SERVICE) as WindowManager
@Suppress("DEPRECATION")
val display = wm.defaultDisplay ?: return
display.supportedModes?.let { supportedModes ->
val currentMode = display.mode
var modeToUse = currentMode
for (mode in supportedModes) {
if (mode.physicalHeight != currentMode.physicalHeight ||
mode.physicalWidth != currentMode.physicalWidth) {
continue
}
if (BigDecimal(fps.toString()).setScale(1, RoundingMode.FLOOR) ==
BigDecimal(mode.refreshRate.toString()).setScale(1, RoundingMode.FLOOR)) {
modeToUse = mode
break
} else if ((mode.refreshRate % fps).let { it < 0.1f || (fps - it) < 0.1f }) {
modeToUse = mode
break
}
}
if (modeToUse != currentMode) {
Log.d(TAG, "Switching to mode ${modeToUse.modeId} (${modeToUse.refreshRate}Hz)")
activity.window?.attributes?.let { attrs ->
attrs.preferredDisplayModeId = modeToUse.modeId
activity.window?.attributes = attrs
}
registerDisplayListener()
}
}
}
}
@@ -1,6 +0,0 @@
package com.edde746.plezy.shared
interface PlayerDelegate {
fun onPropertyChange(name: String, value: Any?)
fun onEvent(name: String, data: Map<String, Any>?)
}
@@ -1,12 +0,0 @@
package com.edde746.plezy.shared
import android.graphics.Color
object ThemeHelper {
fun themeColor(mode: String?): Int? = when (mode) {
"oled" -> Color.BLACK
"dark" -> Color.parseColor("#0E0F12")
"light" -> Color.parseColor("#F7F7F8")
else -> null
}
}
@@ -1,182 +0,0 @@
package com.edde746.plezy.watchnext
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.tvprovider.media.tv.TvContractCompat
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.util.concurrent.Executors
/**
* Flutter plugin for Android TV Watch Next integration.
* Syncs Plex "On Deck" content to the Android TV launcher's Watch Next row.
*/
class WatchNextPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
companion object {
private const val TAG = "WatchNextPlugin"
private const val METHOD_CHANNEL = "com.plezy/watch_next"
private var pendingDeepLink: String? = null
/**
* Parse a Watch Next deep link intent.
* Returns the content ID if this was a Watch Next intent, null otherwise.
*/
fun handleIntent(intent: Intent?): String? {
val data = intent?.data ?: return null
if (data.scheme == "plezy" && data.authority == "play") {
return data.getQueryParameter("content_id")
}
return null
}
}
private lateinit var methodChannel: MethodChannel
private var applicationContext: Context? = null
private var watchNextProvider: WatchNextProvider? = null
private val ioExecutor by lazy { Executors.newSingleThreadExecutor() }
private val mainHandler = Handler(Looper.getMainLooper())
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
applicationContext = binding.applicationContext
watchNextProvider = WatchNextProvider(binding.applicationContext)
methodChannel = MethodChannel(binding.binaryMessenger, METHOD_CHANNEL)
methodChannel.setMethodCallHandler(this)
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
methodChannel.setMethodCallHandler(null)
applicationContext = null
watchNextProvider = null
ioExecutor.shutdown()
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"isSupported" -> handleIsSupported(result)
"sync" -> handleSync(call, result)
"clear" -> handleClear(result)
"remove" -> handleRemove(call, result)
"getInitialDeepLink" -> handleGetInitialDeepLink(result)
else -> result.notImplemented()
}
}
private fun handleIsSupported(result: MethodChannel.Result) {
val context = applicationContext
if (context == null) {
result.success(false)
return
}
result.success(context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK))
}
private fun handleSync(call: MethodCall, result: MethodChannel.Result) {
val provider = watchNextProvider
if (provider == null) {
result.error("NOT_INITIALIZED", "WatchNextProvider not initialized", null)
return
}
val itemsData = call.argument<List<Map<String, Any?>>>("items")
if (itemsData == null) {
result.error("INVALID_ARGS", "Missing 'items' argument", null)
return
}
val items = itemsData.mapNotNull { parseWatchNextItem(it) }
executeOnIo(result) { provider.syncWatchNextPrograms(items) }
}
private fun handleClear(result: MethodChannel.Result) {
val provider = watchNextProvider
if (provider == null) {
result.error("NOT_INITIALIZED", "WatchNextProvider not initialized", null)
return
}
executeOnIo(result) { provider.clearAll() }
}
private fun handleRemove(call: MethodCall, result: MethodChannel.Result) {
val provider = watchNextProvider
if (provider == null) {
result.error("NOT_INITIALIZED", "WatchNextProvider not initialized", null)
return
}
val contentId = call.argument<String>("contentId")
if (contentId == null) {
result.error("INVALID_ARGS", "Missing 'contentId' argument", null)
return
}
executeOnIo(result) { provider.removeItem(contentId) }
}
private fun executeOnIo(result: MethodChannel.Result, block: () -> Any?) {
try {
ioExecutor.execute {
try {
val value = block()
mainHandler.post { result.success(value) }
} catch (e: Exception) {
Log.e(TAG, "IO operation failed: ${e.message}", e)
mainHandler.post { result.error("IO_ERROR", e.message, null) }
}
}
} catch (e: java.util.concurrent.RejectedExecutionException) {
result.error("SHUTDOWN", "Plugin is shutting down", null)
}
}
private fun handleGetInitialDeepLink(result: MethodChannel.Result) {
val contentId = pendingDeepLink
pendingDeepLink = null
result.success(contentId)
}
private fun parseWatchNextItem(data: Map<String, Any?>): WatchNextProvider.WatchNextItem? {
val contentId = data["contentId"] as? String ?: return null
val title = data["title"] as? String ?: return null
val typeString = data["type"] as? String ?: "movie"
val type = when (typeString.lowercase()) {
"episode" -> TvContractCompat.WatchNextPrograms.TYPE_TV_EPISODE
"movie" -> TvContractCompat.WatchNextPrograms.TYPE_MOVIE
else -> TvContractCompat.WatchNextPrograms.TYPE_MOVIE
}
return WatchNextProvider.WatchNextItem(
contentId = contentId,
title = title,
episodeTitle = data["episodeTitle"] as? String,
description = data["description"] as? String,
posterUri = data["posterUri"] as? String,
type = type,
duration = (data["duration"] as? Number)?.toLong() ?: 0L,
lastPlaybackPosition = (data["lastPlaybackPosition"] as? Number)?.toLong() ?: 0L,
lastEngagementTime = (data["lastEngagementTime"] as? Number)?.toLong() ?: System.currentTimeMillis(),
seriesTitle = data["seriesTitle"] as? String,
seasonNumber = (data["seasonNumber"] as? Number)?.toInt(),
episodeNumber = (data["episodeNumber"] as? Number)?.toInt()
)
}
/**
* Store a deep link content ID for delivery to Flutter.
* Called from MainActivity on intent receipt.
*/
fun notifyDeepLink(contentId: String) {
pendingDeepLink = contentId
try {
methodChannel.invokeMethod("onWatchNextTap", mapOf("contentId" to contentId))
} catch (e: Exception) {
Log.d(TAG, "Method channel not ready, stored as pending deep link")
}
}
}
@@ -1,167 +0,0 @@
package com.edde746.plezy.watchnext
import android.content.ContentProviderOperation
import android.content.ContentUris
import android.content.Context
import android.net.Uri
import android.util.Log
import androidx.tvprovider.media.tv.TvContractCompat
import androidx.tvprovider.media.tv.WatchNextProgram
/**
* Wraps Android TvProvider API for Watch Next row integration.
* Manages WatchNextProgram entries for Plex "On Deck" content.
*/
class WatchNextProvider(private val context: Context) {
companion object {
private const val TAG = "WatchNextProvider"
}
data class WatchNextItem(
val contentId: String,
val title: String,
val episodeTitle: String?,
val description: String?,
val posterUri: String?,
val type: Int,
val duration: Long,
val lastPlaybackPosition: Long,
val lastEngagementTime: Long,
val seriesTitle: String?,
val seasonNumber: Int?,
val episodeNumber: Int?
)
/**
* Sync items to Watch Next row.
* Uses applyBatch to delete + insert in a single transaction so the
* launcher receives one content-change notification with the full set.
*/
fun syncWatchNextPrograms(items: List<WatchNextItem>): Boolean {
return try {
val ops = ArrayList<ContentProviderOperation>()
ops.add(
ContentProviderOperation.newDelete(
TvContractCompat.WatchNextPrograms.CONTENT_URI
).build()
)
for (item in items) {
val program = buildProgram(item)
ops.add(
ContentProviderOperation.newInsert(
TvContractCompat.WatchNextPrograms.CONTENT_URI
).withValues(program.toContentValues()).build()
)
}
context.contentResolver.applyBatch(TvContractCompat.AUTHORITY, ops)
Log.d(TAG, "Synced ${items.size} Watch Next entries")
true
} catch (e: Exception) {
Log.e(TAG, "Failed to sync Watch Next programs", e)
false
}
}
fun clearAll(): Boolean {
return try {
context.contentResolver.delete(
TvContractCompat.WatchNextPrograms.CONTENT_URI,
null,
null
)
true
} catch (e: Exception) {
Log.e(TAG, "Failed to clear Watch Next entries", e)
false
}
}
fun removeItem(contentId: String): Boolean {
return try {
val cursor = context.contentResolver.query(
TvContractCompat.WatchNextPrograms.CONTENT_URI,
arrayOf(
TvContractCompat.WatchNextPrograms._ID,
TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_ID
),
null,
null,
null
)
cursor?.use {
val idIndex = it.getColumnIndex(TvContractCompat.WatchNextPrograms._ID)
val providerIdIndex = it.getColumnIndex(TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_ID)
if (idIndex < 0 || providerIdIndex < 0) return false
while (it.moveToNext()) {
if (it.getString(providerIdIndex) == contentId) {
val id = it.getLong(idIndex)
val deleteUri = ContentUris.withAppendedId(
TvContractCompat.WatchNextPrograms.CONTENT_URI,
id
)
context.contentResolver.delete(deleteUri, null, null)
return true
}
}
}
false
} catch (e: Exception) {
Log.e(TAG, "Failed to remove Watch Next item: $contentId", e)
false
}
}
private fun buildProgram(item: WatchNextItem): WatchNextProgram {
val watchNextType = if (item.lastPlaybackPosition > 0)
TvContractCompat.WatchNextPrograms.WATCH_NEXT_TYPE_CONTINUE
else
TvContractCompat.WatchNextPrograms.WATCH_NEXT_TYPE_NEXT
val builder = WatchNextProgram.Builder()
.setType(item.type)
.setWatchNextType(watchNextType)
.setTitle(item.title)
.setInternalProviderId(item.contentId)
.setLastEngagementTimeUtcMillis(item.lastEngagementTime)
item.description?.let { builder.setDescription(it) }
item.posterUri?.let { uri ->
try {
builder.setPosterArtUri(Uri.parse(uri))
builder.setPosterArtAspectRatio(TvContractCompat.PreviewPrograms.ASPECT_RATIO_16_9)
} catch (e: Exception) {
Log.w(TAG, "Failed to parse poster URI: $uri", e)
}
}
if (item.duration > 0) {
builder.setDurationMillis(item.duration.toInt())
if (item.lastPlaybackPosition > 0) {
builder.setLastPlaybackPositionMillis(item.lastPlaybackPosition.toInt())
}
}
if (item.type == TvContractCompat.WatchNextPrograms.TYPE_TV_EPISODE) {
item.episodeTitle?.let { builder.setEpisodeTitle(it) }
item.seasonNumber?.let { builder.setSeasonNumber(it) }
item.episodeNumber?.let { builder.setEpisodeNumber(it) }
}
val intentUri = Uri.Builder()
.scheme("plezy")
.authority("play")
.appendQueryParameter("content_id", item.contentId)
.build()
builder.setIntentUri(intentUri)
return builder.build()
}
}
@@ -0,0 +1,5 @@
package com.edde746.plezy
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

@@ -1,23 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<vector xmlns:aapt="http://schemas.android.com/aapt"
xmlns:android="http://schemas.android.com/apk/res/android"
android:height="108dp"
android:width="108dp"
android:viewportHeight="911.63"
android:viewportWidth="911.63">
<group android:translateX="276.65" android:translateY="223.88">
<path android:pathData="M335.65,192.66L68.01,38.14C37.78,20.69,0,42.5,0,77.41v309.04c0,34.91,37.78,56.72,68.01,39.27l267.64-154.52c30.23-17.45,30.23-61.08,0-78.54ZM255.53,276.8c-19.1,17.66-38.75,26.49-58.4,26.49s-39.29-8.83-58.39-26.49c-14.39-13.3-28.55-20.05-42.11-20.05s-27.72,6.75-42.1,20.05c-4.87,4.5-12.46,4.2-16.96-0.67-4.5-4.86-4.2-12.45 0.67-16.95,38.2-35.33,78.59-35.33,116.79,0,14.38,13.3,28.55,20.04,42.1,20.04s27.72-6.74,42.11-20.04c4.86-4.5,12.46-4.21,16.95,0.66,4.5,4.87,4.21,12.46-0.66,16.96ZM255.53,204.04c-19.1,17.67-38.75,26.5-58.4,26.5s-39.29-8.83-58.39-26.5c-14.39-13.3-28.55-20.04-42.11-20.04s-27.72,6.74-42.1,20.04c-4.87,4.5-12.46,4.21-16.96-0.66s-4.2-12.46,0.67-16.96c38.2-35.32,78.59-35.32,116.79,0,14.38,13.3,28.55,20.05,42.1,20.05s27.72-6.75,42.11-20.05c4.86-4.5,12.46-4.2,16.95,0.67,4.5,4.86,4.21,12.45-0.66,16.95Z">
<aapt:attr name="android:fillColor">
<gradient
android:type="linear"
android:startX="22.67"
android:startY="425.72"
android:endX="201.83"
android:endY="115.4">
<item android:color="#FFAB543A" android:offset="0.0" />
<item android:color="#FFFF7E57" android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
</group>
</vector>
@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:height="108dp"
android:width="108dp"
android:viewportHeight="911.63"
android:viewportWidth="911.63">
<group android:translateX="276.65" android:translateY="223.88">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M335.65,192.66L68.01,38.14C37.78,20.69,0,42.5,0,77.41v309.04c0,34.91,37.78,56.72,68.01,39.27l267.64-154.52c30.23-17.45,30.23-61.08,0-78.54ZM255.53,276.8c-19.1,17.66-38.75,26.49-58.4,26.49s-39.29-8.83-58.39-26.49c-14.39-13.3-28.55-20.05-42.11-20.05s-27.72,6.75-42.1,20.05c-4.87,4.5-12.46,4.2-16.96-0.67-4.5-4.86-4.2-12.45 0.67-16.95,38.2-35.33,78.59-35.33,116.79,0,14.38,13.3,28.55,20.04,42.1,20.04s27.72-6.74,42.11-20.04c4.86-4.5,12.46-4.21,16.95,0.66,4.5,4.87,4.21,12.46-0.66,16.96ZM255.53,204.04c-19.1,17.67-38.75,26.5-58.4,26.5s-39.29-8.83-58.39-26.5c-14.39-13.3-28.55-20.04-42.11-20.04s-27.72,6.74-42.1,20.04c-4.87,4.5-12.46,4.21-16.96-0.66s-4.2-12.46,0.67-16.96c38.2-35.32,78.59-35.32,116.79,0,14.38,13.3,28.55,20.05,42.1,20.05s27.72-6.75,42.11-20.05c4.86-4.5,12.46-4.2,16.95,0.67,4.5,4.86,4.21,12.45-0.66,16.95Z" />
</group>
</vector>
@@ -1,3 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<inset xmlns:android="http://schemas.android.com/apk/res/android"
android:drawable="@drawable/ic_launcher_foreground" />
@@ -1,6 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
<foreground>
<inset
android:drawable="@drawable/ic_launcher_foreground"
android:inset="16%" />
</foreground>
<monochrome>
<inset
android:drawable="@mipmap/ic_launcher_monochrome"
android:inset="16%" />
</monochrome>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

@@ -1,39 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">@color/splash_background</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/splash_icon</item>
<item name="android:defaultFocusHighlightEnabled">false</item>
<item name="android:colorControlHighlight">@android:color/transparent</item>
</style>
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">@color/splash_background</item>
<item name="android:defaultFocusHighlightEnabled">false</item>
<item name="android:colorControlHighlight">@android:color/transparent</item>
</style>
<style name="SplashTheme.Dark" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">@color/splash_dark</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/splash_icon</item>
<item name="android:defaultFocusHighlightEnabled">false</item>
<item name="android:colorControlHighlight">@android:color/transparent</item>
</style>
<style name="SplashTheme.Oled" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">@color/splash_oled</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/splash_icon</item>
<item name="android:defaultFocusHighlightEnabled">false</item>
<item name="android:colorControlHighlight">@android:color/transparent</item>
</style>
<style name="SplashTheme.Light" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">@color/splash_light</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/splash_icon</item>
<item name="android:defaultFocusHighlightEnabled">false</item>
<item name="android:colorControlHighlight">@android:color/transparent</item>
</style>
<style name="SplashTheme.System" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">@color/splash_background</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/splash_icon</item>
<item name="android:defaultFocusHighlightEnabled">false</item>
<item name="android:colorControlHighlight">@android:color/transparent</item>
</style>
</resources>
@@ -1,8 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#1a1a1a</color>
<color name="splash_background">#0E0F12</color>
<color name="splash_dark">#0E0F12</color>
<color name="splash_oled">#000000</color>
<color name="splash_light">#F7F7F8</color>
</resources>
@@ -2,10 +2,6 @@
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">@color/splash_background</item>
<!-- Disable Android's default green focus highlight that appears during d-pad/keyboard navigation -->
<item name="android:defaultFocusHighlightEnabled">false</item>
<item name="android:colorControlHighlight">@android:color/transparent</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
@@ -14,31 +10,6 @@
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">@color/splash_background</item>
<!-- Disable Android's default green focus highlight that appears during d-pad/keyboard navigation -->
<item name="android:defaultFocusHighlightEnabled">false</item>
<item name="android:colorControlHighlight">@android:color/transparent</item>
</style>
<!-- Splash theme variants for setSplashScreenTheme -->
<style name="SplashTheme.Dark" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">@color/splash_dark</item>
<item name="android:defaultFocusHighlightEnabled">false</item>
<item name="android:colorControlHighlight">@android:color/transparent</item>
</style>
<style name="SplashTheme.Oled" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">@color/splash_oled</item>
<item name="android:defaultFocusHighlightEnabled">false</item>
<item name="android:colorControlHighlight">@android:color/transparent</item>
</style>
<style name="SplashTheme.Light" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">@color/splash_light</item>
<item name="android:defaultFocusHighlightEnabled">false</item>
<item name="android:colorControlHighlight">@android:color/transparent</item>
</style>
<style name="SplashTheme.System" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">@color/splash_background</item>
<item name="android:defaultFocusHighlightEnabled">false</item>
<item name="android:colorControlHighlight">@android:color/transparent</item>
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -1,39 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">@color/splash_background</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/splash_icon</item>
<item name="android:defaultFocusHighlightEnabled">false</item>
<item name="android:colorControlHighlight">@android:color/transparent</item>
</style>
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">@color/splash_background</item>
<item name="android:defaultFocusHighlightEnabled">false</item>
<item name="android:colorControlHighlight">@android:color/transparent</item>
</style>
<style name="SplashTheme.Dark" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">@color/splash_dark</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/splash_icon</item>
<item name="android:defaultFocusHighlightEnabled">false</item>
<item name="android:colorControlHighlight">@android:color/transparent</item>
</style>
<style name="SplashTheme.Oled" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">@color/splash_oled</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/splash_icon</item>
<item name="android:defaultFocusHighlightEnabled">false</item>
<item name="android:colorControlHighlight">@android:color/transparent</item>
</style>
<style name="SplashTheme.Light" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">@color/splash_light</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/splash_icon</item>
<item name="android:defaultFocusHighlightEnabled">false</item>
<item name="android:colorControlHighlight">@android:color/transparent</item>
</style>
<style name="SplashTheme.System" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">@color/splash_background</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/splash_icon</item>
<item name="android:defaultFocusHighlightEnabled">false</item>
<item name="android:colorControlHighlight">@android:color/transparent</item>
</style>
</resources>
@@ -1,8 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#ffffff</color>
<color name="splash_background">#F7F7F8</color>
<color name="splash_dark">#0E0F12</color>
<color name="splash_oled">#000000</color>
<color name="splash_light">#F7F7F8</color>
</resources>
+1 -30
View File
@@ -2,10 +2,6 @@
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">@color/splash_background</item>
<!-- Disable Android's default green focus highlight that appears during d-pad/keyboard navigation -->
<item name="android:defaultFocusHighlightEnabled">false</item>
<item name="android:colorControlHighlight">@android:color/transparent</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
@@ -14,31 +10,6 @@
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">@color/splash_background</item>
<!-- Disable Android's default green focus highlight that appears during d-pad/keyboard navigation -->
<item name="android:defaultFocusHighlightEnabled">false</item>
<item name="android:colorControlHighlight">@android:color/transparent</item>
</style>
<!-- Splash theme variants for setSplashScreenTheme -->
<style name="SplashTheme.Dark" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">@color/splash_dark</item>
<item name="android:defaultFocusHighlightEnabled">false</item>
<item name="android:colorControlHighlight">@android:color/transparent</item>
</style>
<style name="SplashTheme.Oled" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">@color/splash_oled</item>
<item name="android:defaultFocusHighlightEnabled">false</item>
<item name="android:colorControlHighlight">@android:color/transparent</item>
</style>
<style name="SplashTheme.Light" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">@color/splash_light</item>
<item name="android:defaultFocusHighlightEnabled">false</item>
<item name="android:colorControlHighlight">@android:color/transparent</item>
</style>
<style name="SplashTheme.System" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">@color/splash_background</item>
<item name="android:defaultFocusHighlightEnabled">false</item>
<item name="android:colorControlHighlight">@android:color/transparent</item>
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -1,14 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<!-- App-internal files directory (getApplicationDocumentsDirectory on Android) -->
<files-path name="app_files" path="." />
<!-- External files directory (fallback) -->
<external-files-path name="external_files" path="." />
<!-- External storage root (for custom download paths on shared storage) -->
<external-path name="external" path="." />
<!-- Cache directories -->
<cache-path name="cache" path="." />
<external-cache-path name="external_cache" path="." />
<!-- Filesystem root (ultimate fallback for any absolute path) -->
<root-path name="root" path="" />
</paths>
+43 -27
View File
@@ -4,44 +4,60 @@ Dotenv.load("../../.env")
default_platform(:android)
platform :android do
desc "Build and deploy to Google Play Store"
lane :release do
# Get version from pubspec.yaml
desc "Deploy a new version to the Google Play"
lane :deploy do
gradle(task: "clean bundleRelease")
# Get version code from pubspec.yaml
pubspec_path = "#{ENV['PWD']}/../pubspec.yaml"
pubspec_content = File.read(pubspec_path)
version_match = pubspec_content.match(/version:\s*(.+)\+(\d+)/)
if version_match
version_name = version_match[1].strip
version_code = version_match[2].to_i
UI.message("Using version: #{version_name}+#{version_code}")
UI.message("Using version code: #{version_code}")
else
UI.user_error!("Could not extract version from pubspec.yaml")
UI.user_error!("Could not extract version code from pubspec.yaml")
end
debug_info_dir = "./debug-info/#{version_name}+#{version_code}"
git_commit = `git rev-parse --short HEAD`.strip
# Build the Flutter app
sh("cd #{ENV['PWD']}/.. && flutter build appbundle --dart-define=ENABLE_IN_APP_REVIEW=true --dart-define=ENABLE_SENTRY=true --dart-define=GIT_COMMIT=#{git_commit} --dart-define=SENTRY_ENVIRONMENT=play-store --obfuscate --split-debug-info=#{debug_info_dir}/aab")
upload_to_play_store(
track: "production",
release_status: "completed",
version_code: version_code,
skip_upload_changelogs: true,
skip_upload_metadata: true,
skip_upload_images: true,
skip_upload_screenshots: true
)
end
desc "Build release AAB"
lane :build do
# Navigate to Flutter project root and build
sh("cd #{ENV['PWD']}/.. && flutter build appbundle")
end
desc "Build and deploy to Google Play Store"
lane :release do
# Build the Flutter app
sh("cd #{ENV['PWD']}/.. && flutter build appbundle")
# Get version code from pubspec.yaml
pubspec_path = "#{ENV['PWD']}/../pubspec.yaml"
pubspec_content = File.read(pubspec_path)
version_match = pubspec_content.match(/version:\s*(.+)\+(\d+)/)
if version_match
version_code = version_match[2].to_i
UI.message("Using version code: #{version_code}")
else
UI.user_error!("Could not extract version code from pubspec.yaml")
end
# Upload to Play Store
upload_to_play_store(
track: "internal",
release_status: "draft",
version_code: version_code,
aab: "../build/app/outputs/bundle/release/app-release.aab"
)
# Build universal APK for Amazon Appstore (doesn't accept AABs, excludes x86 via env var)
sh("cd #{ENV['PWD']}/.. && AMAZON=1 flutter build apk --release --dart-define=ENABLE_SENTRY=true --dart-define=GIT_COMMIT=#{git_commit} --dart-define=SENTRY_ENVIRONMENT=amazon --obfuscate --split-debug-info=#{debug_info_dir}/apk")
upload_to_amazon_appstore(
apk_paths: [
"../build/app/outputs/flutter-apk/app-release.apk"
],
overwrite_upload: true,
overwrite_upload_mode: 'reuse',
changes_not_sent_for_review: true
)
end
end
end
-5
View File
@@ -1,5 +0,0 @@
# Autogenerated by fastlane
#
# Ensure this file is checked in to source control!
gem 'fastlane-plugin-amazon_appstore'
+1 -1
View File
@@ -18,7 +18,7 @@ pluginManagement {
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.9.1" apply false
id("com.android.application") version "8.7.3" apply false
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.
Binary file not shown.
-82
View File
@@ -1,82 +0,0 @@
<svg xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:cc="http://creativecommons.org/ns#" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="display:inline;enable-background:new" version="1.0" id="svg11300" viewBox="8 28 112 88">
<title id="title4162">Adwaita Icon Template</title>
<defs id="defs3">
<linearGradient id="linearGradient1965">
<stop style="stop-color:#62a0ea;stop-opacity:1" offset="0" id="stop1961"/>
<stop style="stop-color:#1a5fb4;stop-opacity:1" offset="1" id="stop1963"/>
</linearGradient>
<linearGradient id="linearGradient1959">
<stop style="stop-color:#ed333b;stop-opacity:1" offset="0" id="stop1955"/>
<stop style="stop-color:#1a5fb4;stop-opacity:0" offset="1" id="stop1957"/>
</linearGradient>
<radialGradient r="225" fy="-1136.1257" fx="58.953751" cy="-1136.1257" cx="58.953751" gradientTransform="matrix(0.27963724,0,0,0.19167088,14.08685,422.59065)" gradientUnits="userSpaceOnUse" id="radialGradient2319" xlink:href="#linearGradient1965"/>
<radialGradient r="225" fy="-173.6787" fx="312.7467" cy="-173.6787" cx="312.7467" gradientTransform="matrix(0.60444443,0,0,0.35619377,-109.038,345.86327)" gradientUnits="userSpaceOnUse" id="radialGradient2282" xlink:href="#linearGradient1959"/>
</defs>
<metadata id="metadata4">
<rdf:RDF>
<cc:Work rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
<dc:creator>
<cc:Agent>
<dc:title>GNOME Design Team</dc:title>
</cc:Agent>
</dc:creator>
<dc:source/>
<cc:license rdf:resource="http://creativecommons.org/licenses/by-sa/4.0/"/>
<dc:title>Adwaita Icon Template</dc:title>
<dc:subject>
<rdf:Bag/>
</dc:subject>
<dc:date/>
<dc:rights>
<cc:Agent>
<dc:title/>
</cc:Agent>
</dc:rights>
<dc:publisher>
<cc:Agent>
<dc:title/>
</cc:Agent>
</dc:publisher>
<dc:identifier/>
<dc:relation/>
<dc:language/>
<dc:coverage/>
<dc:description/>
<dc:contributor>
<cc:Agent>
<dc:title/>
</cc:Agent>
</dc:contributor>
</cc:Work>
<cc:License rdf:about="http://creativecommons.org/licenses/by-sa/4.0/">
<cc:permits rdf:resource="http://creativecommons.org/ns#Reproduction"/>
<cc:permits rdf:resource="http://creativecommons.org/ns#Distribution"/>
<cc:requires rdf:resource="http://creativecommons.org/ns#Notice"/>
<cc:requires rdf:resource="http://creativecommons.org/ns#Attribution"/>
<cc:permits rdf:resource="http://creativecommons.org/ns#DerivativeWorks"/>
<cc:requires rdf:resource="http://creativecommons.org/ns#ShareAlike"/>
</cc:License>
</rdf:RDF>
</metadata>
<g transform="translate(0,-172)" style="display:inline" id="layer1">
<g style="display:inline" id="layer9">
<path id="rect1792-1-3" d="m 8,200 v 88 h 8 v -8 h 8 v 8 h 80 v -8 h 8 v 8 h 8 v -88 h -8 v 8 h -8 v -8 H 24 v 8 h -8 v -8 z m 8,16 h 8 v 8 h -8 z m 88,0 h 8 v 8 h -8 z m -88,16 h 8 v 8 h -8 z m 88,0 h 8 v 8 h -8 z m -88,16 h 8 v 8 h -8 z m 88,0 h 8 v 8 h -8 z m -88,16 h 8 v 8 h -8 z m 88,0 h 8 v 8 h -8 z" style="display:inline;opacity:1;vector-effect:none;fill:url(#radialGradient2319);fill-opacity:1;stroke:none;stroke-width:0.33206606;stroke-linecap:butt;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:0.33206605, 0.66413209;stroke-dashoffset:0;stroke-opacity:0.94881891;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal;enable-background:new"/>
<path id="rect1794-0-6" d="m 8,200 v 88 h 8 v -8 h 8 v 8 h 80 v -8 h 8 v 8 h 8 v -88 h -8 v 8 h -8 v -8 H 24 v 8 h -8 v -8 z m 8,16 h 8 v 8 h -8 z m 88,0 h 8 v 8 h -8 z m -88,16 h 8 v 8 h -8 z m 88,0 h 8 v 8 h -8 z m -88,16 h 8 v 8 h -8 z m 88,0 h 8 v 8 h -8 z m -88,16 h 8 v 8 h -8 z m 88,0 h 8 v 8 h -8 z" style="display:inline;opacity:1;vector-effect:none;fill:url(#radialGradient2282);fill-opacity:1;stroke:none;stroke-width:0.33206606;stroke-linecap:butt;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:0.33206605, 0.66413209;stroke-dashoffset:0;stroke-opacity:0.94881891;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal;enable-background:new"/>
<path id="path2574-9" d="m 63.999999,218 a 20,20 0 0 0 -20,20 20,20 0 0 0 20,20 20,20 0 0 0 20,-20 20,20 0 0 0 -20,-20 z m -7.90625,10 h 1.8125 0.1875 c 0.34982,-0.003 0.69558,0.0774 1,0.25 l 14,8 c 0.6214,0.34377 0.9375,1.0469 0.9375,1.75 0,0.7031 -0.3161,1.40622 -0.9375,1.75 l -14,8 c -0.30442,0.1724 -0.65018,0.252 -1,0.25 h -0.1875 -1.8125 z" style="display:inline;opacity:0.15;vector-effect:none;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.33206606;stroke-linecap:butt;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:0.33206605, 0.66413209;stroke-dashoffset:0;stroke-opacity:0.94881891;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal;enable-background:new"/>
<g transform="translate(-9e-7,8)" style="display:inline;fill:#ffffff;enable-background:new" id="g2594">
<path style="opacity:1;vector-effect:none;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.33206606;stroke-linecap:butt;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:0.33206605, 0.66413209;stroke-dashoffset:0;stroke-opacity:0.94881891;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal" d="m 64,208 a 20,20 0 0 0 -20,20 20,20 0 0 0 20,20 20,20 0 0 0 20,-20 20,20 0 0 0 -20,-20 z m -7.90625,10 h 1.8125 0.1875 c 0.34982,-0.003 0.69558,0.0774 1,0.25 l 14,8 c 0.6214,0.34377 0.9375,1.0469 0.9375,1.75 0,0.7031 -0.3161,1.40622 -0.9375,1.75 l -14,8 c -0.30442,0.1724 -0.65018,0.252 -1,0.25 h -0.1875 -1.8125 z" id="path2574"/>
</g>
<g transform="translate(-9e-7,10)" style="display:inline;opacity:0.15;fill:#000000;enable-background:new" id="g2572">
<rect style="display:inline;opacity:1;vector-effect:none;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.33206606;stroke-linecap:butt;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:0.33206605, 0.66413209;stroke-dashoffset:0;stroke-opacity:0.94881891;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal;enable-background:new" id="rect2327-2" width="64" height="4" x="32" y="260" rx="2" ry="2"/>
<rect style="display:inline;opacity:1;vector-effect:none;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.24904953;stroke-linecap:butt;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:0.24904954, 0.49809907;stroke-dashoffset:0;stroke-opacity:0.94881891;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal;enable-background:new" id="rect2327-4-6" width="12" height="12" x="40" y="256" rx="6" ry="6"/>
</g>
<g transform="translate(-9e-7,8)" id="g2719" style="display:inline;enable-background:new">
<rect style="opacity:1;vector-effect:none;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.33206606;stroke-linecap:butt;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:0.33206605, 0.66413209;stroke-dashoffset:0;stroke-opacity:0.94881891;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal" id="rect2327" width="64" height="4" x="32" y="260" rx="2" ry="2"/>
<rect style="display:inline;opacity:1;vector-effect:none;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.24904953;stroke-linecap:butt;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:0.24904954, 0.49809907;stroke-dashoffset:0;stroke-opacity:0.94881891;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal;enable-background:new" id="rect2327-4" width="12" height="12" x="40" y="256" rx="6" ry="6"/>
</g>
<path id="path1927" d="m 16,213 v 3 h 8 v -3 z m 88,0 v 3 h 8 v -3 z m -88,16 v 3 h 8 v -3 z m 88,0 v 3 h 8 v -3 z m -88,16 v 3 h 8 v -3 z m 88,0 v 3 h 8 v -3 z m -88,16 v 3 h 8 v -3 z m 88,0 v 3 h 8 v -3 z m -88,16 v 3 h 8 v -3 z m 88,0 v 3 h 8 v -3 z m -96,8 v 3 h 8 v -3 z m 16,0 v 3 h 80 v -3 z m 88,0 v 3 h 8 v -3 z" style="display:inline;opacity:0.25;vector-effect:none;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.33206606;stroke-linecap:butt;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:0.33206605, 0.66413209;stroke-dashoffset:0;stroke-opacity:0.94881891;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal;enable-background:new"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

-46
View File
@@ -1,46 +0,0 @@
<svg xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:cc="http://creativecommons.org/ns#" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" id="svg2" version="1.1" inkscape:version="0.91 r13725" sodipodi:docname="mpv-gradient.svg" viewBox="4.05 4.05 55.9 55.9">
<defs id="defs4">
<linearGradient inkscape:collect="always" id="linearGradient4430">
<stop style="stop-color:#65376e;stop-opacity:1" offset="0" id="stop4432"/>
<stop style="stop-color:#4c2354;stop-opacity:1" offset="1" id="stop4434"/>
</linearGradient>
<linearGradient id="linearGradient4418" inkscape:collect="always">
<stop id="stop4420" offset="0" style="stop-color:#e6e2e8;stop-opacity:1"/>
<stop id="stop4422" offset="1" style="stop-color:#aaa3ad;stop-opacity:1"/>
</linearGradient>
<linearGradient inkscape:collect="always" id="linearGradient4402">
<stop style="stop-color:#320f38;stop-opacity:1" offset="0" id="stop4404"/>
<stop style="stop-color:#5a2963;stop-opacity:1" offset="1" id="stop4406"/>
</linearGradient>
<linearGradient inkscape:collect="always" id="linearGradient4392">
<stop style="stop-color:#6b3c74;stop-opacity:1" offset="0" id="stop4394"/>
<stop style="stop-color:#461b4d;stop-opacity:1" offset="1" id="stop4396"/>
</linearGradient>
<linearGradient inkscape:collect="always" id="linearGradient4382">
<stop style="stop-color:#fafafa;stop-opacity:1" offset="0" id="stop4384"/>
<stop style="stop-color:#bababa;stop-opacity:1" offset="1" id="stop4386"/>
</linearGradient>
<linearGradient inkscape:collect="always" xlink:href="#linearGradient4382" id="linearGradient5278" gradientUnits="userSpaceOnUse" gradientTransform="translate(-86.873198,699.81912)" x1="97.187729" y1="305.67371" x2="140.38228" y2="335.64926"/>
<linearGradient inkscape:collect="always" xlink:href="#linearGradient4392" id="linearGradient5280" gradientUnits="userSpaceOnUse" gradientTransform="translate(-32.973108,701.2155)" x1="50.828064" y1="298.31949" x2="78.197021" y2="339.65219"/>
<linearGradient inkscape:collect="always" xlink:href="#linearGradient4402" id="linearGradient5282" gradientUnits="userSpaceOnUse" gradientTransform="translate(-31.48364,700.09839)" x1="53.620815" y1="305.76682" x2="77.824654" y2="331.73938"/>
<linearGradient inkscape:collect="always" xlink:href="#linearGradient4418" id="linearGradient5284" gradientUnits="userSpaceOnUse" gradientTransform="translate(-87.152474,700.0053)" x1="110.31366" y1="312.28323" x2="126.88398" y2="329.41211"/>
<linearGradient inkscape:collect="always" xlink:href="#linearGradient4430" id="linearGradient5286" gradientUnits="userSpaceOnUse" gradientTransform="translate(-87.152474,700.0053)" x1="115.97468" y1="317.41763" x2="119.37984" y2="322.43457"/>
</defs>
<sodipodi:namedview id="base" pagecolor="#ffffff" bordercolor="#666666" borderopacity="1.0" inkscape:pageopacity="0.0" inkscape:pageshadow="2" inkscape:zoom="5.3710484" inkscape:cx="-19.490294" inkscape:cy="18.643164" inkscape:document-units="px" inkscape:current-layer="layer1" showgrid="false" units="px" inkscape:window-width="1920" inkscape:window-height="1016" inkscape:window-x="0" inkscape:window-y="27" inkscape:window-maximized="1"/>
<metadata id="metadata7">
<rdf:RDF>
<cc:Work rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
<dc:title/>
</cc:Work>
</rdf:RDF>
</metadata>
<g inkscape:label="Layer 1" inkscape:groupmode="layer" id="layer1" transform="translate(0,-988.3622)">
<circle style="opacity:1;fill:url(#linearGradient5278);fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.10161044;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:1;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:0.99215686" id="path4380-0" cx="32" cy="1020.3622" r="27.949194"/>
<circle style="opacity:1;fill:url(#linearGradient5280);fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0988237;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:1;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:0.99215686" id="path4390-3" cx="32.727058" cy="1019.5079" r="25.950588"/>
<circle style="opacity:1;fill:url(#linearGradient5282);fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.1;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:1;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:0.99215686" id="path4400-3" cx="34.224396" cy="1017.7957" r="20"/>
<path style="fill:url(#linearGradient5284);fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" d="m 44.481446,1020.4807 a 12.848894,12.848894 0 0 1 -12.84889,12.8489 12.848894,12.848894 0 0 1 -12.8489,-12.8489 12.848894,12.848894 0 0 1 12.8489,-12.8489 12.848894,12.848894 0 0 1 12.84889,12.8489 z" id="path4412-5" inkscape:connector-curvature="0"/>
<path style="fill:url(#linearGradient5286);fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" d="m 28.374316,1014.709 0,11.4502 9.21608,-5.8647 z" id="path4426-2" inkscape:connector-curvature="0"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 5.5 KiB

-21
View File
@@ -1,21 +0,0 @@
<svg xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg" version="1.0" id="Layer_1" x="0px" y="0px" style="enable-background:new 0 0 898 630.4;" xml:space="preserve" sodipodi:docname="mx_player.svg" inkscape:version="1.4 (e7c3feb1, 2024-10-09)" viewBox="74 224 182.4 182.4"><defs id="defs10"/><sodipodi:namedview id="namedview10" pagecolor="#ffffff" bordercolor="#000000" borderopacity="0.25" inkscape:showpageshadow="2" inkscape:pageopacity="0.0" inkscape:pagecheckerboard="0" inkscape:deskcolor="#d1d1d1" inkscape:zoom="0.41160677" inkscape:cx="170.06523" inkscape:cy="99.609634" inkscape:window-width="1472" inkscape:window-height="843" inkscape:window-x="0" inkscape:window-y="34" inkscape:window-maximized="1" inkscape:current-layer="Layer_1"/>
<style type="text/css" id="style1">
.st0{fill:#3B8BEF;}
.st1{fill:#010101;}
.st2{fill:#020202;}
.st3{fill:#040303;}
.st4{fill:#040404;}
.st5{fill:#060606;}
.st6{fill:#FFFFFF;}
</style>
<path class="st0" d="M256.4,315.3c0,50.3-40.8,91.1-91.1,91.1c-0.1,0-0.2,0-0.4,0c-51.2-0.2-91.2-42.1-90.9-91.8 c0.3-49.3,40.4-90.8,91.6-90.6C215.9,224.2,256.5,265.1,256.4,315.3z M137.2,317.5v37.8c0,0.5,0,1,0,1.5c0.1,0.9,0.8,1.5,1.7,1.5 c0,0,0.1,0,0.1,0c0.5-0.1,0.9-0.3,1.3-0.5l8.8-5l57.8-33.1c0.4-0.2,0.8-0.5,1.2-0.8c0.8-0.6,0.9-1.6,0.4-2.4 c-0.1-0.1-0.2-0.3-0.4-0.4c-0.3-0.2-0.6-0.4-0.9-0.5l-66.4-38c-0.5-0.3-0.9-0.5-1.4-0.8c-0.8-0.4-1.8-0.1-2.2,0.8 c-0.1,0.2-0.2,0.4-0.2,0.7c0,0.5,0,1,0,1.5L137.2,317.5z" id="path1"/>
<path class="st6" d="M137.2,317.5v-37.9c0-0.5,0-1,0-1.5c0.1-0.9,0.9-1.6,1.8-1.5c0.2,0,0.4,0.1,0.6,0.1c0.5,0.2,1,0.5,1.4,0.8 l66.4,38c0.3,0.2,0.6,0.3,0.9,0.5c0.8,0.6,0.9,1.6,0.4,2.4c-0.1,0.1-0.2,0.3-0.4,0.4c-0.4,0.3-0.8,0.6-1.2,0.8l-57.8,33.1l-8.8,5 c-0.4,0.3-0.9,0.5-1.4,0.6c-0.9,0.1-1.7-0.5-1.8-1.3c0,0,0-0.1,0-0.1c0-0.5,0-1,0-1.5L137.2,317.5z" id="path10"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 194 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-276.65 -223.88 911.63 911.63"><defs><linearGradient id="linear-gradient" x1="22.67" y1="425.72" x2="201.83" y2="115.4" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ab543a"/><stop offset="1" stop-color="#ff7e57"/></linearGradient></defs><path fill="url(#linear-gradient)" d="M335.65,192.66L68.01,38.14C37.78,20.69,0,42.5,0,77.41v309.04c0,34.91,37.78,56.72,68.01,39.27l267.64-154.52c30.23-17.45,30.23-61.08,0-78.54ZM255.53,276.8c-19.1,17.66-38.75,26.49-58.4,26.49s-39.29-8.83-58.39-26.49c-14.39-13.3-28.55-20.05-42.11-20.05s-27.72,6.75-42.1,20.05c-4.87,4.5-12.46,4.2-16.96-.67-4.5-4.86-4.2-12.45.67-16.95,38.2-35.33,78.59-35.33,116.79,0,14.38,13.3,28.55,20.04,42.1,20.04s27.72-6.74,42.11-20.04c4.86-4.5,12.46-4.21,16.95.66,4.5,4.87,4.21,12.46-.66,16.96ZM255.53,204.04c-19.1,17.67-38.75,26.5-58.4,26.5s-39.29-8.83-58.39-26.5c-14.39-13.3-28.55-20.04-42.11-20.04s-27.72,6.74-42.1,20.04c-4.87,4.5-12.46,4.21-16.96-.66s-4.2-12.46.67-16.96c38.2-35.32,78.59-35.32,116.79,0,14.38,13.3,28.55,20.05,42.1,20.05s27.72-6.75,42.11-20.05c4.86-4.5,12.46-4.2,16.95.67,4.5,4.86,4.21,12.45-.66,16.95Z"/></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

-3
View File
@@ -1,3 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid meet" viewBox="0 0 575 289.83" width="575" height="289.83"><defs><path d="M575 24.91C573.44 12.15 563.97 1.98 551.91 0C499.05 0 76.18 0 23.32 0C10.11 2.17 0 14.16 0 28.61C0 51.84 0 237.64 0 260.86C0 276.86 12.37 289.83 27.64 289.83C79.63 289.83 495.6 289.83 547.59 289.83C561.65 289.83 573.26 278.82 575 264.57C575 216.64 575 48.87 575 24.91Z" id="d1pwhf9wy2"></path><path d="M69.35 58.24L114.98 58.24L114.98 233.89L69.35 233.89L69.35 58.24Z" id="g5jjnq26yS"></path><path d="M201.2 139.15C197.28 112.38 195.1 97.5 194.67 94.53C192.76 80.2 190.94 67.73 189.2 57.09C185.25 57.09 165.54 57.09 130.04 57.09L130.04 232.74L170.01 232.74L170.15 116.76L186.97 232.74L215.44 232.74L231.39 114.18L231.54 232.74L271.38 232.74L271.38 57.09L211.77 57.09L201.2 139.15Z" id="i3Prh1JpXt"></path><path d="M346.71 93.63C347.21 95.87 347.47 100.95 347.47 108.89C347.47 115.7 347.47 170.18 347.47 176.99C347.47 188.68 346.71 195.84 345.2 198.48C343.68 201.12 339.64 202.43 333.09 202.43C333.09 190.9 333.09 98.66 333.09 87.13C338.06 87.13 341.45 87.66 343.25 88.7C345.05 89.75 346.21 91.39 346.71 93.63ZM367.32 230.95C372.75 229.76 377.31 227.66 381.01 224.67C384.7 221.67 387.29 217.52 388.77 212.21C390.26 206.91 391.14 196.38 391.14 180.63C391.14 174.47 391.14 125.12 391.14 118.95C391.14 102.33 390.49 91.19 389.48 85.53C388.46 79.86 385.93 74.71 381.88 70.09C377.82 65.47 371.9 62.15 364.12 60.13C356.33 58.11 343.63 57.09 321.54 57.09C319.27 57.09 307.93 57.09 287.5 57.09L287.5 232.74L342.78 232.74C355.52 232.34 363.7 231.75 367.32 230.95Z" id="a4ov9rRGQm"></path><path d="M464.76 204.7C463.92 206.93 460.24 208.06 457.46 208.06C454.74 208.06 452.93 206.98 452.01 204.81C451.09 202.65 450.64 197.72 450.64 190C450.64 185.36 450.64 148.22 450.64 143.58C450.64 135.58 451.04 130.59 451.85 128.6C452.65 126.63 454.41 125.63 457.13 125.63C459.91 125.63 463.64 126.76 464.6 129.03C465.55 131.3 466.03 136.15 466.03 143.58C466.03 146.58 466.03 161.58 466.03 188.59C465.74 197.84 465.32 203.21 464.76 204.7ZM406.68 231.21L447.76 231.21C449.47 224.5 450.41 220.77 450.6 220.02C454.32 224.52 458.41 227.9 462.9 230.14C467.37 232.39 474.06 233.51 479.24 233.51C486.45 233.51 492.67 231.62 497.92 227.83C503.16 224.05 506.5 219.57 507.92 214.42C509.34 209.26 510.05 201.42 510.05 190.88C510.05 185.95 510.05 146.53 510.05 141.6C510.05 131 509.81 124.08 509.34 120.83C508.87 117.58 507.47 114.27 505.14 110.88C502.81 107.49 499.42 104.86 494.98 102.98C490.54 101.1 485.3 100.16 479.26 100.16C474.01 100.16 467.29 101.21 462.81 103.28C458.34 105.35 454.28 108.49 450.64 112.7C450.64 108.89 450.64 89.85 450.64 55.56L406.68 55.56L406.68 231.21Z" id="fk968BpsX"></path></defs><g><g><g><use xlink:href="#d1pwhf9wy2" opacity="1" fill="#f6c700" fill-opacity="1"></use><g><use xlink:href="#d1pwhf9wy2" opacity="1" fill-opacity="0" stroke="#000000" stroke-width="1" stroke-opacity="0"></use></g></g><g><use xlink:href="#g5jjnq26yS" opacity="1" fill="#000000" fill-opacity="1"></use><g><use xlink:href="#g5jjnq26yS" opacity="1" fill-opacity="0" stroke="#000000" stroke-width="1" stroke-opacity="0"></use></g></g><g><use xlink:href="#i3Prh1JpXt" opacity="1" fill="#000000" fill-opacity="1"></use><g><use xlink:href="#i3Prh1JpXt" opacity="1" fill-opacity="0" stroke="#000000" stroke-width="1" stroke-opacity="0"></use></g></g><g><use xlink:href="#a4ov9rRGQm" opacity="1" fill="#000000" fill-opacity="1"></use><g><use xlink:href="#a4ov9rRGQm" opacity="1" fill-opacity="0" stroke="#000000" stroke-width="1" stroke-opacity="0"></use></g></g><g><use xlink:href="#fk968BpsX" opacity="1" fill="#000000" fill-opacity="1"></use><g><use xlink:href="#fk968BpsX" opacity="1" fill-opacity="0" stroke="#000000" stroke-width="1" stroke-opacity="0"></use></g></g></g></g></svg>

Before

Width:  |  Height:  |  Size: 3.9 KiB

-20
View File
@@ -1,20 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg id="svg3390" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" height="141.25" viewBox="0 0 138.75 141.25" width="138.75" version="1.1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/">
<metadata id="metadata3396">
<rdf:RDF>
<cc:Work rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
<dc:title/>
</cc:Work>
</rdf:RDF>
</metadata>
<g id="layer1" fill="#f93208">
<path id="path3412" d="m20.154 40.829c-28.149 27.622-13.657 61.011-5.734 71.931 35.254 41.954 92.792 25.339 111.89-5.9071 4.7608-8.2027 22.554-53.467-23.976-78.009z"/>
<path id="path3471" d="m39.613 39.265 4.7778-8.8607 28.406-5.0384 11.119 9.2082z"/>
</g>
<g id="layer2">
<path id="path3437" d="m39.436 8.5696 8.9682-5.2826 6.7569 15.479c3.7925-6.3226 13.79-16.316 24.939-4.6684-4.7281 1.2636-7.5161 3.8553-7.7397 8.4768 15.145-4.1697 31.343 3.2127 33.539 9.0911-10.951-4.314-27.695 10.377-41.771 2.334 0.009 15.045-12.617 16.636-19.902 17.076 2.077-4.996 5.591-9.994 1.474-14.987-7.618 8.171-13.874 10.668-33.17 4.668 4.876-1.679 14.843-11.39 24.448-11.425-6.775-2.467-12.29-2.087-17.814-1.475 2.917-3.961 12.149-15.197 28.625-8.476z" fill="#02902e"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="145" height="140"><path fill="#0fc755" d="M47.4 35.342c-13.607-7.935-12.32-25.203 2.097-31.88 26.124-6.531 29.117 13.78 22.652 30.412-6.542 24.11 18.095 23.662 19.925 10.067 3.605-18.412 19.394-26.695 31.67-16.359 12.598 12.135 7.074 36.581-17.827 34.187-16.03-1.545-19.552 19.585.839 21.183 32.228 1.915 42.49 22.167 31.04 35.865-15.993 15.15-37.691-4.439-45.512-19.505-6.8-9.307-17.321.11-13.423 6.502 12.983 19.465 2.923 31.229-10.906 30.62-13.37-.85-20.96-9.06-13.214-29.15 3.897-12.481-8.595-15.386-16.57-5.45-11.707 19.61-28.865 13.68-33.976 4.19-3.243-7.621-2.921-25.846 24.119-23.696 16.688 4.137 11.776-12.561-.63-13.633-9.245-.443-30.501-7.304-22.86-24.54 7.34-11.056 24.958-11.768 33.348 6.293 3.037 4.232 8.361 11.042 18.037 5.033 3.51-5.197 1.21-13.9-8.809-20.135z"/></svg>

Before

Width:  |  Height:  |  Size: 833 B

-46
View File
@@ -1,46 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg id="svg3880" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" height="108.75" viewBox="0 0 143.75 108.75" width="143.75" version="1.1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/">
<metadata id="metadata3886">
<rdf:RDF>
<cc:Work rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
<dc:title/>
</cc:Work>
</rdf:RDF>
</metadata>
<g id="g3998" transform="matrix(.0042962 .96345 -.96345 .0042962 135.21 .16894)"></g>
<g id="layer4">
<path id="path3462" d="m96.641 2.9657c28.149 1.101 27.459 97.814 0.825 97.194l-74.45-9.973c-25.51-7.211-25.922-69.313-0.534-76.178z" fill="#07a23b"/>
<g fill="#fff">
<path id="path3505" d="m85.419 8.8789-63.171 8.9751c-4.2681 3.3648-6.9679 8.2192-7.5687 9.3296l65.017-6.963c1.3226-3.3762 4.3015-9.5395 5.7202-11.342z"/>
<path id="path3551" d="m78.042 28.008-65.329 5.5498c-2.494 4.757-3.169 10.65-3.3147 15.17l66.739-1.5147c0.0074-3.2891 0.55003-13.318 1.9052-19.206z"/>
<path id="path3553" d="m85.595 94.456-63.251-8.403c-4.2975-3.326-7.0398-8.1557-7.6503-9.2607l65.082 6.3737c1.3522 3.3644 4.3852 9.4999 5.8189 11.29z"/>
<path id="path3555" d="m78.051 75.394-65.375-4.957c-2.536-4.734-3.2627-10.621-3.4481-15.14l66.749 0.9101c0.03629 3.2889 0.66694 13.312 2.0737 19.188z"/>
</g>
</g>
<g id="layer5">
<path id="path4031" d="m100.36 10.836c-13.099 0.685-19.878 48.223-11.732 71.195l21.342-4.561c8.39-24.044 1.28-66.986-9.61-66.634z" fill="#03621e"/>
</g>
<g id="layer6" fill="#fdd600">
<path id="path4136" d="m99.087 78.942a6.1255 6.1255 0 0 1 -6.1238 6.1255 6.1255 6.1255 0 0 1 -6.1273 -6.122 6.1255 6.1255 0 0 1 6.1202 -6.1291 6.1255 6.1255 0 0 1 6.1309 6.1184"/>
<path id="path4174" d="m112.96 75.406a5.5952 5.5952 0 0 1 -5.5936 5.5952 5.5952 5.5952 0 0 1 -5.5968 -5.592 5.5952 5.5952 0 0 1 5.5903 -5.5985 5.5952 5.5952 0 0 1 5.6001 5.5887"/>
<path id="path4176" d="m120.39 74.743a4.9323 4.9323 0 0 1 -4.9309 4.9323 4.9323 4.9323 0 0 1 -4.9337 -4.9294 4.9323 4.9323 0 0 1 4.928 -4.9352 4.9323 4.9323 0 0 1 4.9366 4.9266"/>
<path id="path4178" d="m124.1 78.942a3.7391 3.7391 0 0 1 -3.738 3.7391 3.7391 3.7391 0 0 1 -3.7402 -3.7369 3.7391 3.7391 0 0 1 3.7358 -3.7412 3.7391 3.7391 0 0 1 3.7423 3.7347"/>
<path id="path4180" d="m131.08 83.14a5.0207 5.0207 0 0 1 -5.0192 5.0207 5.0207 5.0207 0 0 1 -5.0222 -5.0178 5.0207 5.0207 0 0 1 5.0163 -5.0236 5.0207 5.0207 0 0 1 5.0251 5.0148"/>
<path id="path4182" d="m135.86 91.67a5.5952 5.5952 0 0 1 -5.5936 5.5952 5.5952 5.5952 0 0 1 -5.5968 -5.592 5.5952 5.5952 0 0 1 5.5903 -5.5985 5.5952 5.5952 0 0 1 5.6001 5.5887"/>
<path id="path4184" d="m140.36 97.327a3.9158 3.9158 0 0 1 -3.9147 3.9158 3.9158 3.9158 0 0 1 -3.917 -3.9136 3.9158 3.9158 0 0 1 3.9124 -3.9181 3.9158 3.9158 0 0 1 3.9192 3.9113"/>
<path id="path4186" d="m140.45 99.625a4.181 4.181 0 0 1 -4.1798 4.181 4.181 4.181 0 0 1 -4.1822 -4.1786 4.181 4.181 0 0 1 4.1773 -4.1834 4.181 4.181 0 0 1 4.1846 4.1761"/>
<path id="path4188" d="m134.44 100.55a4.8439 4.8439 0 0 1 -4.8425 4.8439 4.8439 4.8439 0 0 1 -4.8453 -4.8411 4.8439 4.8439 0 0 1 4.8397 -4.8467 4.8439 4.8439 0 0 1 4.8481 4.8383"/>
<path id="path4190" d="m126.84 100.24a4.0042 4.0042 0 0 1 -4.0031 4.0042 4.0042 4.0042 0 0 1 -4.0054 -4.0019 4.0042 4.0042 0 0 1 4.0007 -4.0065 4.0042 4.0042 0 0 1 4.0077 3.9996"/>
<path id="path4192" d="m125.43 97.636a5.1091 5.1091 0 0 1 -5.1076 5.1091 5.1091 5.1091 0 0 1 -5.1106 -5.1061 5.1091 5.1091 0 0 1 5.1046 -5.112 5.1091 5.1091 0 0 1 5.1135 5.1031"/>
<path id="path4194" d="m117.12 98.078a5.1091 5.1091 0 0 1 -5.1076 5.1091 5.1091 5.1091 0 0 1 -5.1106 -5.1061 5.1091 5.1091 0 0 1 5.1046 -5.112 5.1091 5.1091 0 0 1 5.1135 5.1031"/>
<path id="path4196" d="m110.49 97.459a3.6065 3.6065 0 0 1 -3.6054 3.6065 3.6065 3.6065 0 0 1 -3.6075 -3.6044 3.6065 3.6065 0 0 1 3.6033 -3.6086 3.6065 3.6065 0 0 1 3.6096 3.6023"/>
<path id="path4198" d="m105.72 96.929a4.0484 4.0484 0 0 1 -4.0472 4.0484 4.0484 4.0484 0 0 1 -4.0496 -4.0461 4.0484 4.0484 0 0 1 4.0449 -4.0508 4.0484 4.0484 0 0 1 4.052 4.0437"/>
<path id="path4200" d="m94.71 80.271c2.1568-1.7217 5.4319-2.8842 9.5881-3.6062l11.579 0.61872 15.203 13.612-2.0329 9.6343-27.047-2.5633-4.7288 1.4584-11.183-17.899z"/>
</g>
<g id="layer7">
<path id="path4203" d="m85.913 71.627c3.2472 12.036 7.0507 22.57 12.64 28.284l-9.9879-1.591s-5.5685-25.456-4.8614-25.544c0.70711-0.08839 2.2097-1.149 2.2097-1.149z" fill="#09a339"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 4.6 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="106.25" height="140"><path fill="#fa3106" d="M2.727 39.537c-.471-21.981 100.88-25.089 100.88-.42L92.91 117.56c-7.605 26.86-72.064 27.007-79.07.21z"/><g fill="#fff"><path d="M8.809 51.911l9.018 66.639c3.472 4.515 8.498 7.384 9.648 8.022l-6.921-68.576c-3.498-1.41-9.881-4.579-11.745-6.083zM28.629 59.776l5.453 68.898c4.926 2.652 11.04 3.391 15.73 3.566l-1.258-70.366c-3.414-.024-13.82-.642-19.925-2.098zM97.632 52.121l-9.019 66.643c-3.472 4.515-8.498 7.384-9.647 8.022l6.92-68.583c3.5-1.41 9.882-4.579 11.746-6.082zM77.812 59.986l-5.453 68.898c-4.926 2.652-11.04 3.391-15.73 3.566l1.258-70.366c3.414-.024 13.82-.642 19.925-2.098z"/></g><g fill="#ffd600"><circle cx="13.213" cy="31.252" r="6.816"/><circle cx="22.022" cy="27.687" r="6.607"/><circle cx="30.359" cy="19.769" r="5.925"/><circle cx="34.973" cy="15.155" r="6.03"/><circle cx="45.093" cy="17.095" r="4.929"/><circle cx="51.123" cy="9.597" r="6.24"/><circle cx="61.19" cy="9.387" r="6.554"/><circle cx="67.954" cy="13.635" r="4.929"/><circle cx="76.081" cy="17.672" r="5.925"/><circle cx="78.913" cy="22.706" r="4.352"/><circle cx="83.475" cy="26.324" r="5.243"/><circle cx="88.194" cy="34.398" r="5.768"/><path d="M87.355 35.447c5.79 2.799 1.352-2.213 10.696 2.097-9.574 15.338-74.774 16.892-90.291.525l-.21-3.985L38.59 16.99l22.863-6.606 15.52 9.962z"/></g></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 185.04 133.4"><defs><linearGradient id="linear-gradient" y1="66.7" x2="185.04" y2="66.7" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#90cea1"/><stop offset="0.56" stop-color="#3cbec9"/><stop offset="1" stop-color="#00b3e5"/></linearGradient></defs><title>Asset 4</title><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path fill="url(#linear-gradient)" d="M51.06,66.7h0A17.67,17.67,0,0,1,68.73,49h-.1A17.67,17.67,0,0,1,86.3,66.7h0A17.67,17.67,0,0,1,68.63,84.37h.1A17.67,17.67,0,0,1,51.06,66.7Zm82.67-31.33h32.9A17.67,17.67,0,0,0,184.3,17.7h0A17.67,17.67,0,0,0,166.63,0h-32.9A17.67,17.67,0,0,0,116.06,17.7h0A17.67,17.67,0,0,0,133.73,35.37Zm-113,98h63.9A17.67,17.67,0,0,0,102.3,115.7h0A17.67,17.67,0,0,0,84.63,98H20.73A17.67,17.67,0,0,0,3.06,115.7h0A17.67,17.67,0,0,0,20.73,133.37Zm83.92-49h6.25L125.5,49h-8.35l-8.9,23.2h-.1L99.4,49H90.5Zm32.45,0h7.8V49h-7.8Zm22.2,0h24.95V77.2H167.1V70h15.35V62.8H167.1V56.2h16.25V49h-24ZM10.1,35.4h7.8V6.9H28V0H0V6.9H10.1ZM39,35.4h7.8V20.1H61.9V35.4h7.8V0H61.9V13.2H46.75V0H39Zm41.25,0h25V28.2H88V21h15.35V13.8H88V7.2h16.25V0h-24Zm-79,49H9V57.25h.1l9,27.15H24l9.3-27.15h.1V84.4h7.8V49H29.45l-8.2,23.1h-.1L13,49H1.2Zm112.09,49H126a24.59,24.59,0,0,0,7.56-1.15,19.52,19.52,0,0,0,6.35-3.37,16.37,16.37,0,0,0,4.37-5.5A16.91,16.91,0,0,0,146,115.8a18.5,18.5,0,0,0-1.68-8.25,15.1,15.1,0,0,0-4.52-5.53A18.55,18.55,0,0,0,133.07,99,33.54,33.54,0,0,0,125,98H113.29Zm7.81-28.2h4.6a17.43,17.43,0,0,1,4.67.62,11.68,11.68,0,0,1,3.88,1.88,9,9,0,0,1,2.62,3.18,9.87,9.87,0,0,1,1,4.52,11.92,11.92,0,0,1-1,5.08,8.69,8.69,0,0,1-2.67,3.34,10.87,10.87,0,0,1-4,1.83,21.57,21.57,0,0,1-5,.55H121.1Zm36.14,28.2h14.5a23.11,23.11,0,0,0,4.73-.5,13.38,13.38,0,0,0,4.27-1.65,9.42,9.42,0,0,0,3.1-3,8.52,8.52,0,0,0,1.2-4.68,9.16,9.16,0,0,0-.55-3.2,7.79,7.79,0,0,0-1.57-2.62,8.38,8.38,0,0,0-2.45-1.85,10,10,0,0,0-3.18-1v-.1a9.28,9.28,0,0,0,4.43-2.82,7.42,7.42,0,0,0,1.67-5,8.34,8.34,0,0,0-1.15-4.65,7.88,7.88,0,0,0-3-2.73,12.9,12.9,0,0,0-4.17-1.3,34.42,34.42,0,0,0-4.63-.32h-13.2Zm7.8-28.8h5.3a10.79,10.79,0,0,1,1.85.17,5.77,5.77,0,0,1,1.7.58,3.33,3.33,0,0,1,1.23,1.13,3.22,3.22,0,0,1,.47,1.82,3.63,3.63,0,0,1-.42,1.8,3.34,3.34,0,0,1-1.13,1.2,4.78,4.78,0,0,1-1.57.65,8.16,8.16,0,0,1-1.78.2H165Zm0,14.15h5.9a15.12,15.12,0,0,1,2.05.15,7.83,7.83,0,0,1,2,.55,4,4,0,0,1,1.58,1.17,3.13,3.13,0,0,1,.62,2,3.71,3.71,0,0,1-.47,1.95,4,4,0,0,1-1.23,1.3,4.78,4.78,0,0,1-1.67.7,8.91,8.91,0,0,1-1.83.2h-7Z"/></g></g></svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 MiB

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 MiB

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 MiB

After

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 MiB

After

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 MiB

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

@@ -1,36 +0,0 @@
// This is free and unencumbered software released into the public domain.
// Anyone is free to copy, modify, publish, use, compile, sell, or
// distribute this software, either in source code form or as a compiled
// binary, for any purpose, commercial or non-commercial, and by any
// means.
// In jurisdictions that recognize copyright laws, the author or authors
// of this software dedicate any and all copyright interest in the
// software to the public domain. We make this dedication for the benefit
// of the public at large and to the detriment of our heirs and
// successors. We intend this dedication to be an overt act of
// relinquishment in perpetuity of all present and future rights to this
// software under copyright law.
// 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 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.
// For more information, please refer to <https://unlicense.org>
//!DESC Anime4K-v4.0-AutoDownscalePre-x2
//!HOOK MAIN
//!BIND HOOKED
//!BIND NATIVE
//!WHEN OUTPUT.w NATIVE.w / 2.0 < OUTPUT.h NATIVE.h / 2.0 < * OUTPUT.w NATIVE.w / 1.2 > OUTPUT.h NATIVE.h / 1.2 > * *
//!WIDTH OUTPUT.w
//!HEIGHT OUTPUT.h
vec4 hook() {
return HOOKED_tex(HOOKED_pos);
}

Some files were not shown because too many files have changed in this diff Show More