Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3a35d56d9 |
@@ -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
|
||||
@@ -1,3 +0,0 @@
|
||||
*.sh text eol=lf
|
||||
*.Dockerfile text eol=lf
|
||||
Dockerfile text eol=lf
|
||||
@@ -1 +0,0 @@
|
||||
github: edde746
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
@@ -32,7 +32,6 @@ migrate_working_dir/
|
||||
.pub-cache/
|
||||
.pub/
|
||||
/build/
|
||||
/debug-info/
|
||||
|
||||
# Symbolication related
|
||||
app.*.symbols
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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))) }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
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>
|
||||
|
||||
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -1,5 +0,0 @@
|
||||
# Autogenerated by fastlane
|
||||
#
|
||||
# Ensure this file is checked in to source control!
|
||||
|
||||
gem 'fastlane-plugin-amazon_appstore'
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 7.8 KiB |
@@ -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 |
|
Before Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 16 KiB |
@@ -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 |
@@ -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 |
|
Before Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 194 KiB |
@@ -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 |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 21 KiB |
@@ -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 |
@@ -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 +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 |
@@ -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 +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 +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 |
|
Before Width: | Height: | Size: 3.8 MiB After Width: | Height: | Size: 2.7 MiB |
|
Before Width: | Height: | Size: 2.4 MiB After Width: | Height: | Size: 2.8 MiB |
|
Before Width: | Height: | Size: 2.6 MiB After Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 4.4 MiB After Width: | Height: | Size: 3.4 MiB |
|
Before Width: | Height: | Size: 3.4 MiB After Width: | Height: | Size: 3.5 MiB |
|
Before Width: | Height: | Size: 3.0 MiB After Width: | Height: | Size: 2.5 MiB |
|
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);
|
||||
}
|
||||