Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
977414bc3c |
@@ -1,9 +0,0 @@
|
||||
BasedOnStyle: Google
|
||||
AlignAfterOpenBracket: AlwaysBreak
|
||||
ColumnLimit: 120
|
||||
IndentWidth: 2
|
||||
ContinuationIndentWidth: 4
|
||||
DerivePointerAlignment: false
|
||||
LineEnding: LF
|
||||
PointerAlignment: Left
|
||||
SortIncludes: true
|
||||
@@ -1,7 +0,0 @@
|
||||
**
|
||||
!.maestro/
|
||||
!.maestro/jellyfin-demo/
|
||||
!.maestro/jellyfin-demo/**
|
||||
!scripts/
|
||||
!scripts/maestro_fixtures.py
|
||||
!scripts/maestro_real_jellyfin.py
|
||||
@@ -1,28 +0,0 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.{c,cc,cpp,h,hpp,m,mm}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
[*.dart]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
[*.{kt,kts,java}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
ij_kotlin_code_style_defaults = KOTLIN_OFFICIAL
|
||||
ktlint_code_style = android_studio
|
||||
ktlint_standard_max-line-length = disabled
|
||||
ktlint_standard_no-wildcard-imports = disabled
|
||||
ktlint_standard_property-naming = disabled
|
||||
|
||||
[*.swift]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
@@ -1,17 +0,0 @@
|
||||
*.sh text eol=lf
|
||||
*.Dockerfile text eol=lf
|
||||
Dockerfile text eol=lf
|
||||
*.bat text eol=lf
|
||||
|
||||
# Byte-exact generated Dart checked by scripts/check_codegen.py.
|
||||
lib/data/ducet_order.dart text eol=lf
|
||||
lib/data/hid_key_labels.dart text eol=lf
|
||||
lib/data/iso_639_data.dart text eol=lf
|
||||
lib/**/*.g.dart text eol=lf
|
||||
lib/**/*.freezed.dart text eol=lf
|
||||
|
||||
# Byte-exact wakelock inputs hashed in packages/wakelock_plus/provenance.json.
|
||||
packages/wakelock_plus/pigeons/messages.dart text eol=lf
|
||||
packages/wakelock_plus/android/src/main/kotlin/dev/fluttercommunity/plus/wakelock/WakelockPlusMessages.g.kt text eol=lf
|
||||
packages/wakelock_plus/ios/wakelock_plus/Sources/wakelock_plus/include/wakelock_plus/messages.g.h text eol=lf
|
||||
packages/wakelock_plus/ios/wakelock_plus/Sources/wakelock_plus/messages.g.m text eol=lf
|
||||
@@ -1,21 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -uo pipefail
|
||||
|
||||
if [ "${SKIP_HOOKS:-}" = "1" ]; then
|
||||
echo "[pre-commit] skipped (SKIP_HOOKS=1)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
GIT_DIR="$(git rev-parse --git-dir)"
|
||||
if [ -f "$GIT_DIR/MERGE_MSG" ] || [ -f "$GIT_DIR/MERGE_HEAD" ] || [ -f "$GIT_DIR/REVERT_HEAD" ]; then
|
||||
echo "[pre-commit] skipped (merge/revert in progress)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if git diff --cached --quiet; then
|
||||
echo "[pre-commit] nothing staged — skipping checks"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
ROOT="$(git rev-parse --show-toplevel)"
|
||||
exec "$ROOT/scripts/ci_checks.sh"
|
||||
@@ -1,2 +0,0 @@
|
||||
github: edde746
|
||||
liberapay: edde746
|
||||
@@ -1,123 +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
|
||||
- tvOS
|
||||
- Linux
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: media-server
|
||||
attributes:
|
||||
label: Media Server
|
||||
description: Are you using Jellyfin or Plex?
|
||||
multiple: true
|
||||
options:
|
||||
- Jellyfin
|
||||
- Plex
|
||||
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
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- 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,82 +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...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- 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
|
||||
- tvOS
|
||||
- Linux
|
||||
- All platforms
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: media-server
|
||||
attributes:
|
||||
label: Media Server
|
||||
description: Which media server(s) is this for?
|
||||
multiple: true
|
||||
options:
|
||||
- Jellyfin
|
||||
- Plex
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- 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,46 +0,0 @@
|
||||
name: Set up Flutter from git
|
||||
description: >-
|
||||
Clone the pinned Flutter SDK from its release tag and put it on PATH, for
|
||||
runners without a published archive. Flutter ships no windows-arm64 SDK, so
|
||||
subosito/flutter-action cannot resolve the release for arm64 (no arm64 entry
|
||||
in the stable manifest) and `channel: master` would clone master HEAD, whose
|
||||
engine is not the patched revision install-patched-engine.ps1 asserts
|
||||
(4c525dac). This file is the only place that pin lives: the tag is fetched so
|
||||
the SDK reports its own version, then verified against the immutable commit
|
||||
and against the version Flutter itself reports, so a moved tag fails the job
|
||||
instead of quietly changing SDKs.
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Clone Flutter from its immutable commit
|
||||
shell: pwsh
|
||||
run: |
|
||||
$version = "3.44.0"
|
||||
$expectedCommit = "559ffa3f75e7402d65a8def9c28389a9b2e6fe42"
|
||||
$root = "$env:RUNNER_TEMP\flutter"
|
||||
git init $root
|
||||
git -C $root remote add origin https://github.com/flutter/flutter.git
|
||||
git -C $root fetch --depth 1 origin "refs/tags/${version}:refs/tags/${version}"
|
||||
git -C $root checkout --detach "refs/tags/$version"
|
||||
$actualCommit = git -C $root rev-parse HEAD
|
||||
if ($LASTEXITCODE -ne 0 -or $actualCommit -ne $expectedCommit) {
|
||||
throw "Flutter $version resolved to $actualCommit, expected $expectedCommit"
|
||||
}
|
||||
"$root\bin" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8
|
||||
& "$root\bin\flutter.bat" --version
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Unable to bootstrap the Flutter SDK"
|
||||
}
|
||||
$versionOutput = & "$root\bin\flutter.bat" --version --machine
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Unable to resolve the Flutter SDK version"
|
||||
}
|
||||
$versionJson = $versionOutput -join "`n"
|
||||
if ([string]::IsNullOrWhiteSpace($versionJson)) {
|
||||
throw "Flutter did not report machine-readable version JSON"
|
||||
}
|
||||
$reportedVersion = ($versionJson | ConvertFrom-Json).frameworkVersion
|
||||
if ($reportedVersion -ne $version) {
|
||||
throw "Flutter reported version $reportedVersion, expected $version"
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: monthly
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,594 +0,0 @@
|
||||
name: CI - Sanity Checks
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
# Keep untrusted code on the read-only pull_request event. Never use pull_request_target here.
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
# Only place this workflow names the SDK; .github/actions/setup-flutter-git pins the same release.
|
||||
FLUTTER_VERSION: "3.44.0"
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Code Analysis
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
pub-cache: false
|
||||
|
||||
- name: Cache Pub dependencies
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: |
|
||||
~/.pub-cache
|
||||
key: ${{ runner.os }}-pub-v3-${{ hashFiles('**/pubspec.yaml', '**/pubspec.lock') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
|
||||
flutter pub get
|
||||
|
||||
- name: Install wakelock_plus development dependencies
|
||||
working-directory: packages/wakelock_plus
|
||||
run: flutter pub get --enforce-lockfile --no-example
|
||||
|
||||
- name: Verify generated files committed
|
||||
run: scripts/codegen.sh --check
|
||||
|
||||
- name: Verify translation hygiene
|
||||
run: python3 scripts/clean_translations.py --check --strict
|
||||
|
||||
- name: Verify workflow and script guards
|
||||
run: bash scripts/ci_guard_checks.sh
|
||||
|
||||
- name: Verify formatting
|
||||
run: |
|
||||
paths=(lib)
|
||||
[ ! -d test ] || paths+=(test)
|
||||
find "${paths[@]}" -name "*.dart" ! -name "*.g.dart" ! -name "*.freezed.dart" -type f -print0 |
|
||||
xargs -0 -r dart format --output=none --set-exit-if-changed
|
||||
|
||||
- name: Verify icon consistency
|
||||
run: dart run scripts/check_icon_consistency.dart
|
||||
|
||||
- name: Analyze code
|
||||
run: dart run scripts/check_analyzer.dart
|
||||
|
||||
- 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
pub-cache: false
|
||||
|
||||
- name: Cache Pub dependencies
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: |
|
||||
~/.pub-cache
|
||||
key: ${{ runner.os }}-pub-v3-${{ hashFiles('**/pubspec.yaml', '**/pubspec.lock') }}
|
||||
|
||||
- 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
|
||||
scripts/run_tests.sh
|
||||
else
|
||||
echo "No tests found, skipping test execution"
|
||||
fi
|
||||
|
||||
- name: Install wakelock_plus test dependencies
|
||||
working-directory: packages/wakelock_plus
|
||||
run: flutter pub get --enforce-lockfile
|
||||
|
||||
- name: Run wakelock_plus VM tests
|
||||
working-directory: packages/wakelock_plus
|
||||
run: flutter test test/wakelock_plus_linux_plugin_test.dart
|
||||
|
||||
- name: Run wakelock_plus Chrome tests
|
||||
working-directory: packages/wakelock_plus
|
||||
run: flutter test --platform chrome --dart-define=WEB_PLUGIN_TESTS=true test/wakelock_plus_web_plugin_test.dart
|
||||
|
||||
# The vendored atomic-write patch has to keep the upstream contract, not
|
||||
# just the new behaviour. Upstream's own suites are the check for that.
|
||||
- name: Run the vendored desktop preference store tests
|
||||
run: |
|
||||
for pkg in shared_preferences_linux shared_preferences_windows; do
|
||||
(cd "packages/$pkg" && flutter pub get --enforce-lockfile && flutter test)
|
||||
done
|
||||
|
||||
android-test:
|
||||
name: Android JVM and Native Tests
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: "21"
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
pub-cache: false
|
||||
|
||||
- name: Cache Pub dependencies
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: |
|
||||
~/.pub-cache
|
||||
key: ${{ runner.os }}-pub-v3-${{ hashFiles('**/pubspec.yaml', '**/pubspec.lock') }}
|
||||
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-
|
||||
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Configure Android local properties
|
||||
run: printf 'flutter.sdk=%s\nsdk.dir=%s\n' "$FLUTTER_ROOT" "$ANDROID_HOME" > android/local.properties
|
||||
|
||||
- name: Configure Android host native tests
|
||||
run: |
|
||||
cmake -S android/app/src/test/cpp -B build/android-host-tests \
|
||||
-DCMAKE_BUILD_TYPE=Debug
|
||||
|
||||
- name: Build Android host native tests
|
||||
run: cmake --build build/android-host-tests --parallel 2
|
||||
|
||||
- name: Run Android host native tests
|
||||
run: |
|
||||
ctest --test-dir build/android-host-tests \
|
||||
--output-on-failure --no-tests=error
|
||||
|
||||
- name: Run Android JVM unit tests
|
||||
working-directory: android
|
||||
run: ./gradlew :app:testDebugUnitTest :saf_util:testDebugUnitTest :libass:testDebugUnitTest -x :app:compileFlutterBuildDebug --continue
|
||||
|
||||
- name: Check Android API compatibility
|
||||
working-directory: android
|
||||
run: ./gradlew :app:lintDebug
|
||||
|
||||
native-format:
|
||||
name: Native Formatting
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: "17"
|
||||
|
||||
- name: Verify native formatting
|
||||
run: scripts/format_native.sh --check
|
||||
|
||||
- name: Verify Linux native acquisition integrity
|
||||
run: bash linux/packaging/build-libmpv_test.sh
|
||||
|
||||
linux-native-test:
|
||||
name: Linux native reliability (${{ matrix.sanitizer }})
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- sanitizer: address
|
||||
lifecycle_sanitizers: ON
|
||||
- sanitizer: thread
|
||||
lifecycle_sanitizers: OFF
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
pub-cache: false
|
||||
|
||||
- name: Install Linux native test dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
clang cmake ninja-build pkg-config libgtk-3-dev liblzma-dev \
|
||||
libstdc++-12-dev libmpv-dev libepoxy-dev libcurl4-openssl-dev libevdev-dev
|
||||
|
||||
- name: Prepare Flutter Linux configuration
|
||||
run: |
|
||||
flutter pub get --enforce-lockfile --no-example
|
||||
flutter build linux --debug --config-only --no-pub
|
||||
|
||||
- name: Configure Linux native reliability tests
|
||||
run: |
|
||||
cmake -S linux -B build/linux-native-${{ matrix.sanitizer }} -G Ninja \
|
||||
-DCMAKE_BUILD_TYPE=Debug \
|
||||
-DPLEZY_BUILD_MPV_PLAYER_LIFECYCLE_TESTS=ON \
|
||||
-DPLEZY_MPV_LIFECYCLE_SANITIZERS=${{ matrix.lifecycle_sanitizers }} \
|
||||
-DPLEZY_BUILD_MPV_RELIABILITY_TESTS=ON \
|
||||
-DPLEZY_MPV_RELIABILITY_SANITIZER=${{ matrix.sanitizer }}
|
||||
|
||||
- name: Build Linux native reliability tests
|
||||
run: |
|
||||
cmake --build build/linux-native-${{ matrix.sanitizer }} --parallel 2 --target \
|
||||
mpv_player_lifecycle_test \
|
||||
mpv_property_result_contract_test \
|
||||
mpv_gpu_bootstrap_test
|
||||
|
||||
- name: Run Linux native reliability tests
|
||||
run: |
|
||||
ctest --test-dir build/linux-native-${{ matrix.sanitizer }} \
|
||||
--output-on-failure --no-tests=error
|
||||
|
||||
apple-native-test:
|
||||
name: Apple native reliability (${{ matrix.platform }})
|
||||
runs-on: macos-26
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: iOS
|
||||
project_directory: ios
|
||||
workspace: ios/Runner.xcworkspace
|
||||
simulator_runtime: iOS
|
||||
simulator_platform: iOS
|
||||
static_destination: ""
|
||||
- platform: macOS
|
||||
project_directory: macos
|
||||
workspace: macos/Runner.xcworkspace
|
||||
simulator_runtime: ""
|
||||
simulator_platform: ""
|
||||
static_destination: platform=macOS
|
||||
- platform: tvOS
|
||||
project_directory: tvos
|
||||
workspace: tvos/Runner.xcworkspace
|
||||
simulator_runtime: tvOS
|
||||
simulator_platform: tvOS
|
||||
static_destination: ""
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
pub-cache: false
|
||||
|
||||
- name: Install locked Dart dependencies
|
||||
run: flutter pub get --enforce-lockfile --no-example
|
||||
|
||||
- name: Record committed CocoaPods lockfile
|
||||
if: matrix.platform != 'tvOS'
|
||||
env:
|
||||
PODFILE_LOCK: ${{ matrix.project_directory }}/Podfile.lock
|
||||
run: |
|
||||
test -f "$PODFILE_LOCK"
|
||||
shasum -a 256 "$PODFILE_LOCK" > "$RUNNER_TEMP/plezy-podfile-lock.sha256"
|
||||
|
||||
- name: Prepare iOS Flutter build settings
|
||||
if: matrix.platform == 'iOS'
|
||||
run: flutter build ios --config-only --simulator --debug --no-pub
|
||||
|
||||
- name: Prepare macOS Flutter build settings
|
||||
if: matrix.platform == 'macOS'
|
||||
run: flutter build macos --config-only --debug --no-pub
|
||||
|
||||
- name: Prepare tvOS Flutter engine
|
||||
if: matrix.platform == 'tvOS'
|
||||
run: tvos/scripts/fetch_engine.sh
|
||||
|
||||
- name: Verify Flutter configuration preserved CocoaPods lockfile
|
||||
if: matrix.platform != 'tvOS'
|
||||
run: shasum -a 256 --check "$RUNNER_TEMP/plezy-podfile-lock.sha256"
|
||||
|
||||
- name: Install locked CocoaPods dependencies
|
||||
if: matrix.platform != 'tvOS'
|
||||
working-directory: ${{ matrix.project_directory }}
|
||||
run: pod install --deployment
|
||||
|
||||
- name: Install tvOS CocoaPods dependencies
|
||||
if: matrix.platform == 'tvOS'
|
||||
run: tvos/scripts/pod_install.sh
|
||||
|
||||
- name: Verify tvOS project wiring
|
||||
if: matrix.platform == 'tvOS'
|
||||
run: ruby tvos/scripts/test_wire_mpv.rb
|
||||
|
||||
- name: Select Apple test destination
|
||||
env:
|
||||
SIMULATOR_RUNTIME: ${{ matrix.simulator_runtime }}
|
||||
SIMULATOR_PLATFORM: ${{ matrix.simulator_platform }}
|
||||
STATIC_DESTINATION: ${{ matrix.static_destination }}
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
destination = os.environ["STATIC_DESTINATION"]
|
||||
if not destination:
|
||||
runtime_name = os.environ["SIMULATOR_RUNTIME"]
|
||||
payload = json.loads(
|
||||
subprocess.check_output(
|
||||
["xcrun", "simctl", "list", "devices", "available", "-j"],
|
||||
text=True,
|
||||
)
|
||||
)
|
||||
devices = [
|
||||
device
|
||||
for runtime, candidates in payload["devices"].items()
|
||||
if f".{runtime_name}-" in runtime
|
||||
for device in candidates
|
||||
if device.get("isAvailable", False)
|
||||
]
|
||||
if not devices:
|
||||
raise SystemExit(f"no available {runtime_name} simulator")
|
||||
destination = (
|
||||
f"platform={os.environ['SIMULATOR_PLATFORM']} Simulator,"
|
||||
f"id={devices[0]['udid']}"
|
||||
)
|
||||
with open(os.environ["GITHUB_ENV"], "a", encoding="utf-8") as output:
|
||||
output.write(f"APPLE_TEST_DESTINATION={destination}\n")
|
||||
PY
|
||||
|
||||
- name: Run Apple native reliability tests
|
||||
run: |
|
||||
xcodebuild test \
|
||||
-workspace "${{ matrix.workspace }}" \
|
||||
-scheme Runner \
|
||||
-configuration Debug \
|
||||
-destination "$APPLE_TEST_DESTINATION" \
|
||||
-disableAutomaticPackageResolution \
|
||||
CODE_SIGNING_ALLOWED=NO \
|
||||
COMPILER_INDEX_STORE_ENABLE=NO
|
||||
|
||||
windows-native-test:
|
||||
name: Windows native reliability (${{ matrix.arch }})
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: x64
|
||||
runner: windows-latest
|
||||
flutter_setup: action
|
||||
- arch: arm64
|
||||
runner: windows-11-arm
|
||||
flutter_setup: git
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install 7-Zip
|
||||
if: matrix.arch == 'arm64'
|
||||
shell: pwsh
|
||||
run: choco install 7zip -y
|
||||
|
||||
- name: Setup Flutter
|
||||
if: matrix.flutter_setup == 'action'
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
pub-cache: false
|
||||
|
||||
- name: Set up Flutter from its pinned commit
|
||||
if: matrix.flutter_setup == 'git'
|
||||
uses: ./.github/actions/setup-flutter-git
|
||||
|
||||
- name: Install locked Dart dependencies
|
||||
shell: pwsh
|
||||
run: flutter pub get --enforce-lockfile --no-example
|
||||
|
||||
# Windows replacement semantics (`MoveFileExW` with
|
||||
# MOVEFILE_REPLACE_EXISTING) cannot be proven on the POSIX runners, and a
|
||||
# memory file system proves nothing about either. The vendored
|
||||
# shared_preferences_windows store write is the fix for #1732, so exercise
|
||||
# it here, on a real NTFS volume, against the real backend.
|
||||
- name: Run the vendored Windows preference store tests
|
||||
shell: pwsh
|
||||
run: flutter test test/services/prefs_store_atomic_write_windows_test.dart
|
||||
|
||||
- name: Install patched Flutter engine
|
||||
shell: pwsh
|
||||
run: |
|
||||
flutter precache --windows
|
||||
.\windows\tool\install-patched-engine.ps1
|
||||
|
||||
- name: Prepare Flutter Windows configuration
|
||||
shell: pwsh
|
||||
run: flutter build windows --debug --config-only --no-pub
|
||||
|
||||
- name: Configure Windows native reliability tests
|
||||
shell: pwsh
|
||||
run: |
|
||||
$buildDir = "build/windows/${{ matrix.arch }}"
|
||||
cmake -S windows -B $buildDir `
|
||||
-DPLEZY_BUILD_MPV_PROPERTY_CONTRACT_TESTS=ON `
|
||||
-DPLEZY_BUILD_DISPLAY_RECOVERY_TESTS=ON
|
||||
|
||||
- name: Build Windows native reliability tests
|
||||
shell: pwsh
|
||||
run: |
|
||||
$buildDir = "build/windows/${{ matrix.arch }}"
|
||||
cmake --build $buildDir --config Debug --parallel 2 --target `
|
||||
mpv_property_result_contract_test `
|
||||
mpv_player_property_contract_test `
|
||||
display_mode_manager_test
|
||||
|
||||
- name: Run Windows native reliability tests
|
||||
shell: pwsh
|
||||
run: |
|
||||
$buildDir = "build/windows/${{ matrix.arch }}"
|
||||
ctest --test-dir "$buildDir/runner" -C Debug --output-on-failure --no-tests=error
|
||||
|
||||
dependency-check:
|
||||
name: Dependency Validation
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
pub-cache: false
|
||||
|
||||
- name: Cache Pub dependencies
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: |
|
||||
~/.pub-cache
|
||||
key: ${{ runner.os }}-pub-v3-${{ hashFiles('**/pubspec.yaml', '**/pubspec.lock') }}
|
||||
|
||||
- name: Verify dependencies
|
||||
run: |
|
||||
flutter clean
|
||||
flutter pub get
|
||||
flutter pub outdated
|
||||
|
||||
server:
|
||||
name: Server checks
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version-file: server/go.mod
|
||||
cache-dependency-path: server/go.sum
|
||||
|
||||
- name: Run server checks
|
||||
run: scripts/ci_server_checks.sh
|
||||
|
||||
|
||||
website:
|
||||
name: Website checks
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||
with:
|
||||
bun-version: "1.3.14"
|
||||
|
||||
- name: Run website checks
|
||||
run: scripts/ci_website_checks.sh
|
||||
@@ -1,324 +0,0 @@
|
||||
name: E2E - Maestro
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
# Keep untrusted code on the read-only pull_request event. Never use pull_request_target here.
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: maestro-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
android-maestro:
|
||||
name: Android Maestro suites
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 150
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
FLUTTER_VERSION: "3.44.0"
|
||||
MAESTRO_VERSION: "2.6.1"
|
||||
MAESTRO_SHA256: "3440825f514f537c6a96bcf5de995780c2a4a7f83a43208fdc95d4f1fecfad3b"
|
||||
MAESTRO_CLI_NO_ANALYTICS: "1"
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Enable KVM
|
||||
run: |
|
||||
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \
|
||||
| sudo tee /etc/udev/rules.d/99-kvm4all.rules
|
||||
sudo udevadm control --reload-rules
|
||||
sudo udevadm trigger --name-match=kvm
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
with:
|
||||
channel: stable
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
pub-cache: false
|
||||
|
||||
- name: Cache Pub dependencies
|
||||
id: pub-cache
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: ~/.pub-cache
|
||||
key: ${{ runner.os }}-pub-v3-${{ hashFiles('**/pubspec.yaml', '**/pubspec.lock') }}
|
||||
|
||||
- name: Cache Gradle
|
||||
id: gradle-cache
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-e2e-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-e2e-
|
||||
${{ runner.os }}-gradle-
|
||||
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Build debug APK
|
||||
run: flutter build apk --debug --dart-define=PLEZY_MAESTRO_E2E=true
|
||||
|
||||
- name: Cache Maestro CLI
|
||||
id: maestro-cache
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: |
|
||||
~/.maestro/bin
|
||||
~/.maestro/lib
|
||||
key: ${{ runner.os }}-maestro-${{ env.MAESTRO_VERSION }}
|
||||
|
||||
- name: Install verified Maestro CLI
|
||||
if: steps.maestro-cache.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
archive="$RUNNER_TEMP/maestro.zip"
|
||||
staging="$RUNNER_TEMP/maestro-install"
|
||||
curl --fail --location --retry 3 \
|
||||
--output "$archive" \
|
||||
"https://github.com/mobile-dev-inc/maestro/releases/download/cli-${MAESTRO_VERSION}/maestro.zip"
|
||||
echo "${MAESTRO_SHA256} ${archive}" | sha256sum --check -
|
||||
rm -rf "$staging" "$HOME/.maestro/bin" "$HOME/.maestro/lib"
|
||||
mkdir -p "$staging" "$HOME/.maestro"
|
||||
unzip -q "$archive" -d "$staging"
|
||||
mv "$staging/maestro/bin" "$HOME/.maestro/bin"
|
||||
mv "$staging/maestro/lib" "$HOME/.maestro/lib"
|
||||
|
||||
- name: Add Maestro to PATH
|
||||
run: |
|
||||
echo "$HOME/.maestro/bin" >> "$GITHUB_PATH"
|
||||
"$HOME/.maestro/bin/maestro" --version
|
||||
|
||||
- name: Setup Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
|
||||
|
||||
- name: Build deterministic Jellyfin image for pull request
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
||||
with:
|
||||
context: .
|
||||
file: .maestro/jellyfin-demo/Dockerfile
|
||||
load: true
|
||||
tags: plezy-jellyfin-demo:local
|
||||
cache-from: type=gha,scope=maestro-jellyfin
|
||||
|
||||
- name: Build and cache deterministic Jellyfin image
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
||||
with:
|
||||
context: .
|
||||
file: .maestro/jellyfin-demo/Dockerfile
|
||||
load: true
|
||||
tags: plezy-jellyfin-demo:local
|
||||
cache-from: type=gha,scope=maestro-jellyfin
|
||||
cache-to: type=gha,mode=max,scope=maestro-jellyfin
|
||||
|
||||
- name: Cache Android 15 AVD
|
||||
id: api35-avd-cache
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: |
|
||||
~/.android/avd/maestro-api35.avd
|
||||
~/.android/avd/maestro-api35.ini
|
||||
key: ${{ runner.os }}-avd-v1-api35-x86_64-pixel_6
|
||||
|
||||
- name: Generate Android 15 AVD snapshot
|
||||
if: steps.api35-avd-cache.outputs.cache-hit != 'true'
|
||||
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2
|
||||
with:
|
||||
api-level: 35
|
||||
arch: x86_64
|
||||
profile: pixel_6
|
||||
avd-name: maestro-api35
|
||||
force-avd-creation: false
|
||||
disable-animations: false
|
||||
emulator-options: >-
|
||||
-no-window -gpu swiftshader_indirect -noaudio
|
||||
-no-boot-anim -camera-back none
|
||||
script: echo "Generated Android 15 AVD snapshot."
|
||||
|
||||
- name: Cache Android 9 AVD
|
||||
id: api28-avd-cache
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: |
|
||||
~/.android/avd/maestro-api28.avd
|
||||
~/.android/avd/maestro-api28.ini
|
||||
key: ${{ runner.os }}-avd-v1-api28-x86-pixel_2-playstore
|
||||
|
||||
- name: Generate Android 9 AVD snapshot
|
||||
if: steps.api28-avd-cache.outputs.cache-hit != 'true'
|
||||
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2
|
||||
with:
|
||||
api-level: 28
|
||||
target: google_apis_playstore
|
||||
arch: x86
|
||||
profile: pixel_2
|
||||
avd-name: maestro-api28
|
||||
force-avd-creation: false
|
||||
disable-animations: false
|
||||
emulator-options: >-
|
||||
-no-window -gpu swiftshader_indirect -noaudio
|
||||
-no-boot-anim -camera-back none
|
||||
script: echo "Generated Android 9 AVD snapshot."
|
||||
|
||||
- name: Run Android 15 instrumentation
|
||||
id: api35-instrumentation
|
||||
continue-on-error: true
|
||||
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2
|
||||
with:
|
||||
api-level: 35
|
||||
arch: x86_64
|
||||
profile: pixel_6
|
||||
avd-name: maestro-api35-instrumentation
|
||||
force-avd-creation: false
|
||||
disable-animations: true
|
||||
emulator-options: >-
|
||||
-no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio
|
||||
-no-boot-anim -camera-back none
|
||||
script: python3 scripts/run_maestro_ci.py android-15-instrumentation
|
||||
|
||||
- name: Run Android R8 reachability
|
||||
id: api35-r8
|
||||
continue-on-error: true
|
||||
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2
|
||||
with:
|
||||
api-level: 35
|
||||
arch: x86_64
|
||||
profile: pixel_6
|
||||
avd-name: maestro-api35-instrumentation
|
||||
force-avd-creation: false
|
||||
disable-animations: true
|
||||
emulator-options: >-
|
||||
-no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio
|
||||
-no-boot-anim -camera-back none
|
||||
script: python3 scripts/run_maestro_ci.py android-r8-reachability
|
||||
|
||||
- name: Run Android 15 suites
|
||||
id: api35
|
||||
continue-on-error: true
|
||||
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2
|
||||
env:
|
||||
MAESTRO_SKIP_BUILD: "1"
|
||||
MAESTRO_SKIP_JELLYFIN_BUILD: "1"
|
||||
MAESTRO_DEVICE_ID: emulator-5554
|
||||
with:
|
||||
api-level: 35
|
||||
arch: x86_64
|
||||
profile: pixel_6
|
||||
avd-name: maestro-api35
|
||||
force-avd-creation: false
|
||||
disable-animations: true
|
||||
emulator-options: >-
|
||||
-no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio
|
||||
-no-boot-anim -camera-back none
|
||||
script: python3 scripts/run_maestro_ci.py android-15
|
||||
|
||||
- name: Run Android 9 legacy playback
|
||||
id: api28
|
||||
continue-on-error: true
|
||||
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2
|
||||
env:
|
||||
MAESTRO_SKIP_BUILD: "1"
|
||||
MAESTRO_SKIP_JELLYFIN_BUILD: "1"
|
||||
MAESTRO_DEVICE_ID: emulator-5554
|
||||
with:
|
||||
api-level: 28
|
||||
target: google_apis_playstore
|
||||
arch: x86
|
||||
profile: pixel_2
|
||||
avd-name: maestro-api28
|
||||
force-avd-creation: false
|
||||
disable-animations: true
|
||||
emulator-options: >-
|
||||
-no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio
|
||||
-no-boot-anim -camera-back none
|
||||
script: python3 scripts/run_maestro_ci.py android-9
|
||||
# Fork PRs may restore trusted caches, but only trusted events publish cache entries.
|
||||
- name: Save Pub dependency cache
|
||||
if: github.event_name != 'pull_request' && steps.pub-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: ~/.pub-cache
|
||||
key: ${{ steps.pub-cache.outputs.cache-primary-key }}
|
||||
|
||||
- name: Save Gradle cache
|
||||
if: github.event_name != 'pull_request' && steps.gradle-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ steps.gradle-cache.outputs.cache-primary-key }}
|
||||
|
||||
- name: Save Maestro CLI cache
|
||||
if: github.event_name != 'pull_request' && steps.maestro-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: |
|
||||
~/.maestro/bin
|
||||
~/.maestro/lib
|
||||
key: ${{ steps.maestro-cache.outputs.cache-primary-key }}
|
||||
|
||||
- name: Save Android 15 AVD cache
|
||||
if: github.event_name != 'pull_request' && steps.api35-avd-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: |
|
||||
~/.android/avd/maestro-api35.avd
|
||||
~/.android/avd/maestro-api35.ini
|
||||
key: ${{ steps.api35-avd-cache.outputs.cache-primary-key }}
|
||||
|
||||
- name: Save Android 9 AVD cache
|
||||
if: github.event_name != 'pull_request' && steps.api28-avd-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: |
|
||||
~/.android/avd/maestro-api28.avd
|
||||
~/.android/avd/maestro-api28.ini
|
||||
key: ${{ steps.api28-avd-cache.outputs.cache-primary-key }}
|
||||
|
||||
- name: Upload Maestro diagnostics
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: maestro-android-diagnostics
|
||||
path: |
|
||||
build/maestro
|
||||
build/maestro-offline
|
||||
build/maestro-real-jellyfin
|
||||
build/maestro-media
|
||||
build/maestro-tv
|
||||
build/maestro-recovery
|
||||
build/maestro-legacy
|
||||
~/.maestro/tests
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
- name: Report suite failures
|
||||
if: ${{ always() && (steps.api35-instrumentation.outcome != 'success' || steps.api35-r8.outcome != 'success' || steps.api35.outcome != 'success' || steps.api28.outcome != 'success') }}
|
||||
run: |
|
||||
echo "Android 15 instrumentation outcome: ${{ steps.api35-instrumentation.outcome }}"
|
||||
echo "Android R8 reachability outcome: ${{ steps.api35-r8.outcome }}"
|
||||
echo "Android 15 outcome: ${{ steps.api35.outcome }}"
|
||||
echo "Android 9 outcome: ${{ steps.api28.outcome }}"
|
||||
exit 1
|
||||
@@ -1,88 +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: Require the protected main branch
|
||||
run: |
|
||||
if [[ "$GITHUB_REF" != "refs/heads/main" ]]; then
|
||||
echo "Release automation may only run from refs/heads/main." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: main
|
||||
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: |
|
||||
CURRENT_BUILD=$(python3 scripts/pubspec_version.py --build-number pubspec.yaml)
|
||||
NEW_BUILD=$((10#$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 --fail-with-body -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,139 +0,0 @@
|
||||
name: Update Packages
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: Published release tag to update packages for
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
resolve-release:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
tag: ${{ steps.release.outputs.tag }}
|
||||
steps:
|
||||
- name: Require the default branch for manual runs
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
run: |
|
||||
if [[ "$GITHUB_REF" != "refs/heads/${{ github.event.repository.default_branch }}" ]]; then
|
||||
echo "Manual package updates may only run from the default branch." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Resolve published release tag
|
||||
id: release
|
||||
env:
|
||||
EVENT_TAG: ${{ github.event.release.tag_name }}
|
||||
INPUT_TAG: ${{ inputs.release_tag }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [ "$GITHUB_EVENT_NAME" = "release" ]; then
|
||||
REQUESTED_TAG="$EVENT_TAG"
|
||||
else
|
||||
REQUESTED_TAG="$INPUT_TAG"
|
||||
fi
|
||||
|
||||
if [ -z "$REQUESTED_TAG" ]; then
|
||||
echo "A release tag is required." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
RELEASE=$(gh release view "$REQUESTED_TAG" --repo "$GITHUB_REPOSITORY" \
|
||||
--json tagName,isDraft,publishedAt)
|
||||
RESOLVED_TAG=$(jq -r '.tagName' <<< "$RELEASE")
|
||||
IS_DRAFT=$(jq -r '.isDraft' <<< "$RELEASE")
|
||||
PUBLISHED_AT=$(jq -r '.publishedAt // empty' <<< "$RELEASE")
|
||||
|
||||
if [ "$RESOLVED_TAG" != "$REQUESTED_TAG" ] || \
|
||||
[ "$IS_DRAFT" != "false" ] || [ -z "$PUBLISHED_AT" ]; then
|
||||
echo "Tag '$REQUESTED_TAG' is not an exact published release tag." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "tag=$RESOLVED_TAG" >> "$GITHUB_OUTPUT"
|
||||
|
||||
update-homebrew:
|
||||
needs: resolve-release
|
||||
runs-on: macos-latest
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
RELEASE_TAG: ${{ needs.resolve-release.outputs.tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Download and hash release
|
||||
id: release
|
||||
run: |
|
||||
curl --fail-with-body -L -o plezy-macos.dmg \
|
||||
"https://github.com/edde746/plezy/releases/download/$RELEASE_TAG/plezy-macos.dmg"
|
||||
SHA256=$(shasum -a 256 plezy-macos.dmg | cut -d' ' -f1)
|
||||
echo "sha256=$SHA256" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Update cask
|
||||
run: |
|
||||
sed -i '' "s/version \".*\"/version \"$RELEASE_TAG\"/" 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 $RELEASE_TAG"
|
||||
git push origin "HEAD:$BRANCH"
|
||||
else
|
||||
echo "No cask changes to commit."
|
||||
fi
|
||||
|
||||
update-winget:
|
||||
needs: resolve-release
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: vedantmgoyal9/winget-releaser@4ffc7888bffd451b357355dc214d43bb9f23917e # v2
|
||||
with:
|
||||
identifier: edde746.Plezy
|
||||
installers-regex: 'plezy-windows-installer\.exe$'
|
||||
release-tag: ${{ needs.resolve-release.outputs.tag }}
|
||||
token: ${{ secrets.WINGET_TOKEN }}
|
||||
|
||||
update-appcast-branch:
|
||||
needs: resolve-release
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
RELEASE_TAG: ${{ needs.resolve-release.outputs.tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
|
||||
- name: Download appcast.xml from release
|
||||
run: |
|
||||
gh release download "$RELEASE_TAG" \
|
||||
--pattern "appcast.xml"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Push to appcast branch
|
||||
run: |
|
||||
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 $RELEASE_TAG")
|
||||
git push origin "$COMMIT:refs/heads/appcast" --force
|
||||
-79
@@ -1,79 +0,0 @@
|
||||
# Miscellaneous
|
||||
*.class
|
||||
*.log
|
||||
*.pyc
|
||||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.build/
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
.swiftpm/
|
||||
migrate_working_dir/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.idea/
|
||||
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
#.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
**/doc/api/
|
||||
**/ios/Flutter/.last_build_id
|
||||
.dart_tool/
|
||||
.flutter-plugins
|
||||
.flutter-plugins-dependencies
|
||||
.pub-cache/
|
||||
.pub/
|
||||
/build/
|
||||
/debug-info/
|
||||
|
||||
# Windows Store packaging output (windows/build-msix.ps1)
|
||||
/staging-msix/
|
||||
/plezy-windows.msixbundle
|
||||
/AppxManifest.*.xml
|
||||
|
||||
# tvOS: build artifacts and generated assets (regenerated by xcode_appletv.sh)
|
||||
tvos/build/
|
||||
tvos/Pods/
|
||||
tvos/Podfile.lock
|
||||
tvos/Runner.xcworkspace/xcuserdata/
|
||||
tvos/Runner.xcodeproj/xcuserdata/
|
||||
tvos/Runner.xcodeproj/project.xcworkspace/xcuserdata/
|
||||
tvos/Flutter/Generated.xcconfig
|
||||
tvos/Flutter/flutter_export_environment.sh
|
||||
tvos/Flutter/libPods-Runner.a
|
||||
tvos/tvos_flutter_assets/
|
||||
tvos/Runner/GeneratedPluginRegistrant.h
|
||||
tvos/Runner/GeneratedPluginRegistrant.m
|
||||
|
||||
# Symbolication related
|
||||
app.*.symbols
|
||||
|
||||
# Obfuscation related
|
||||
app.*.map.json
|
||||
|
||||
# Android Studio will place build artifacts here
|
||||
/android/app/debug
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
|
||||
# Code duplication reports
|
||||
duplication-report/
|
||||
|
||||
.env.*
|
||||
.env
|
||||
!.env.example
|
||||
|
||||
**/fastlane/**/
|
||||
**/fastlane/report.xml
|
||||
maestro/**/*.png
|
||||
|
||||
server/deploy.sh
|
||||
server/Caddyfile
|
||||
@@ -1,15 +0,0 @@
|
||||
flows:
|
||||
- "flows/*.yaml"
|
||||
testOutputDir: "build/maestro"
|
||||
executionOrder:
|
||||
continueOnFailure: false
|
||||
flowsOrder:
|
||||
- Fresh install authentication choices
|
||||
- Jellyfin onboarding reaches Home
|
||||
- Browse library and open media details
|
||||
- Search media and open a result
|
||||
- Start and exit video playback
|
||||
- Open empty downloads state
|
||||
- Manage profiles and open settings
|
||||
- Logout returns to authentication
|
||||
- Download and play a movie from Downloads
|
||||
@@ -1,43 +0,0 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Fresh install authentication choices
|
||||
tags:
|
||||
- e2e
|
||||
- auth
|
||||
---
|
||||
- retry:
|
||||
maxRetries: 1
|
||||
commands:
|
||||
- launchApp:
|
||||
clearState: true
|
||||
permissions:
|
||||
all: allow
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^(?:Sign in with Plex|Wait).*"
|
||||
timeout: 30000
|
||||
- tapOn:
|
||||
text: "Wait"
|
||||
optional: true
|
||||
- extendedWaitUntil:
|
||||
visible: "Sign in with Plex"
|
||||
timeout: 30000
|
||||
- assertVisible: "Show QR Code"
|
||||
- assertVisible: "Connect to Jellyfin"
|
||||
- tapOn: "Connect to Jellyfin"
|
||||
- extendedWaitUntil:
|
||||
visible: "Add Jellyfin server"
|
||||
timeout: 10000
|
||||
- assertVisible: "(?s)Server URLs.*"
|
||||
- assertVisible: "Find server"
|
||||
- runFlow:
|
||||
when:
|
||||
platform: iOS
|
||||
commands:
|
||||
- tapOn: "Back"
|
||||
- runFlow:
|
||||
when:
|
||||
platform: Android
|
||||
commands:
|
||||
- hideKeyboard:
|
||||
optional: true
|
||||
- back
|
||||
- assertVisible: "Sign in with Plex"
|
||||
@@ -1,13 +0,0 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Jellyfin onboarding reaches Home
|
||||
tags:
|
||||
- e2e
|
||||
- onboarding
|
||||
---
|
||||
- runFlow: ../subflows/onboard_jellyfin.yaml
|
||||
- assertVisible: "(?s)^Home.*"
|
||||
- assertVisible: "(?s)^Recently Added.*"
|
||||
- assertVisible: "(?s).*(?:Alpha Archive|Bravo Beacon|Charlie Circuit|Delta Drive|Echo Engine|Foxtrot Frame|Gamma Garden|Hotel Horizon|India Index|Juliet Junction|Kilo Key|Lima Loop|Mike Matrix|November Node|Oscar Orbit|Papa Pipeline|Quebec Queue|Romeo Relay|Sierra Signal|Tango Track|Uniform Update|Victor View|Whiskey Widget|Xray XML|Yankee Yield|Zulu Zone).*"
|
||||
- assertVisible: "(?s)^Libraries.*"
|
||||
- assertVisible: "(?s)^Search.*"
|
||||
- assertVisible: "(?s)^Downloads.*"
|
||||
@@ -1,33 +0,0 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Browse library and open media details
|
||||
tags:
|
||||
- e2e
|
||||
- library
|
||||
---
|
||||
- runFlow: ../subflows/ensure_onboarded.yaml
|
||||
- tapOn: "(?s)^Libraries.*"
|
||||
- tapOn: "Browse"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Maestro Movies.*"
|
||||
timeout: 15000
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Alpha Archive.*"
|
||||
timeout: 15000
|
||||
- tapOn: "(?s).*Alpha Archive.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Overview"
|
||||
timeout: 15000
|
||||
- assertVisible: "A deterministic title for TV alphabet focus coverage."
|
||||
- assertVisible: "Play"
|
||||
- runFlow:
|
||||
when:
|
||||
platform: iOS
|
||||
commands:
|
||||
- tapOn:
|
||||
point: "6%, 9%"
|
||||
- runFlow:
|
||||
when:
|
||||
platform: Android
|
||||
commands:
|
||||
- back
|
||||
- assertVisible: "(?s).*Maestro Movies.*"
|
||||
@@ -1,33 +0,0 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Search media and open a result
|
||||
tags:
|
||||
- e2e
|
||||
- search
|
||||
---
|
||||
- runFlow: ../subflows/ensure_onboarded.yaml
|
||||
- tapOn: "(?s)^Search.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Search movies, shows, music..."
|
||||
timeout: 10000
|
||||
- tapOn: "Search movies, shows, music..."
|
||||
- inputText: "Maestro"
|
||||
- runFlow:
|
||||
when:
|
||||
platform: iOS
|
||||
commands:
|
||||
- pressKey: ENTER
|
||||
- runFlow:
|
||||
when:
|
||||
platform: Android
|
||||
commands:
|
||||
- hideKeyboard
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Maestro Movie.*"
|
||||
timeout: 15000
|
||||
- tapOn: "(?s).*Maestro Movie.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Overview"
|
||||
timeout: 15000
|
||||
- assertVisible: "A deterministic movie used to verify Plezy's end-to-end flows."
|
||||
- back
|
||||
- assertVisible: "(?s)^Maestro Movie.*"
|
||||
@@ -1,57 +0,0 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Start and exit video playback
|
||||
tags:
|
||||
- e2e
|
||||
- playback
|
||||
---
|
||||
- runFlow: ../subflows/ensure_onboarded.yaml
|
||||
- tapOn: "(?s)^Libraries.*"
|
||||
- tapOn: "Browse"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Alpha Archive.*"
|
||||
timeout: 15000
|
||||
- tapOn: "(?s).*Alpha Archive.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Play"
|
||||
timeout: 15000
|
||||
- tapOn:
|
||||
text: "Play"
|
||||
waitToSettleTimeoutMs: 1000
|
||||
- runFlow:
|
||||
when:
|
||||
platform: iOS
|
||||
commands:
|
||||
- extendedWaitUntil:
|
||||
visible: "Video timeline"
|
||||
timeout: 20000
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Alpha Archive.*\\n0min\\n0:(?:0[1-9]|[1-5][0-9])\\n-.*"
|
||||
timeout: 10000
|
||||
- runFlow:
|
||||
when:
|
||||
platform: Android
|
||||
commands:
|
||||
- extendedWaitUntil:
|
||||
visible: "Pause"
|
||||
timeout: 20000
|
||||
- runFlow:
|
||||
when:
|
||||
platform: iOS
|
||||
commands:
|
||||
- tapOn:
|
||||
point: "90%, 5%"
|
||||
- extendedWaitUntil:
|
||||
visible: "Overview"
|
||||
timeout: 10000
|
||||
- runFlow:
|
||||
when:
|
||||
platform: Android
|
||||
commands:
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
notVisible: "Pause"
|
||||
timeout: 10000
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
visible: "Overview"
|
||||
timeout: 10000
|
||||
@@ -1,11 +0,0 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Open empty downloads state
|
||||
tags:
|
||||
- e2e
|
||||
- downloads
|
||||
---
|
||||
- runFlow: ../subflows/ensure_onboarded.yaml
|
||||
- tapOn: "(?s)^Downloads.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "No downloads"
|
||||
timeout: 15000
|
||||
@@ -1,87 +0,0 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Manage profiles and open settings
|
||||
tags:
|
||||
- e2e
|
||||
- profiles
|
||||
- settings
|
||||
---
|
||||
- runFlow: ../subflows/ensure_onboarded.yaml
|
||||
- tapOn: "(?s)^M(?:.*Profiles.*)?$"
|
||||
- tapOn:
|
||||
text: "Profiles"
|
||||
above:
|
||||
text: "Settings"
|
||||
- extendedWaitUntil:
|
||||
visible: "Switch Profile"
|
||||
timeout: 10000
|
||||
- assertVisible: "(?s).*Maestro.*"
|
||||
- tapOn: "Add Plezy profile"
|
||||
- extendedWaitUntil:
|
||||
visible: "New profile"
|
||||
timeout: 10000
|
||||
- tapOn: "e.g. Guests, Kids, Family Room"
|
||||
- inputText: "E2E Guest"
|
||||
- runFlow:
|
||||
when:
|
||||
platform: iOS
|
||||
commands:
|
||||
- pressKey: ENTER
|
||||
- runFlow:
|
||||
when:
|
||||
platform: Android
|
||||
commands:
|
||||
- hideKeyboard
|
||||
- tapOn: "Continue"
|
||||
- extendedWaitUntil:
|
||||
visible: "Add to E2E Guest"
|
||||
timeout: 10000
|
||||
- assertVisible: "(?s)^Sign in with Plex.*"
|
||||
- assertVisible: "(?s)^Connect to Jellyfin.*"
|
||||
- runFlow:
|
||||
when:
|
||||
platform: iOS
|
||||
commands:
|
||||
- tapOn: "Back"
|
||||
- runFlow:
|
||||
when:
|
||||
platform: Android
|
||||
commands:
|
||||
- back
|
||||
- assertVisible: "Switch Profile"
|
||||
- assertVisible: "(?s).*E2E Guest.*"
|
||||
- runFlow:
|
||||
when:
|
||||
platform: iOS
|
||||
commands:
|
||||
- tapOn: "Back"
|
||||
- runFlow:
|
||||
when:
|
||||
platform: Android
|
||||
commands:
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Home.*"
|
||||
timeout: 10000
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 5000
|
||||
- repeat:
|
||||
times: 2
|
||||
while:
|
||||
notVisible: "Settings"
|
||||
commands:
|
||||
- tapOn: "(?s)^M(?:.*Profiles.*)?$"
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- tapOn:
|
||||
text: "Settings"
|
||||
below:
|
||||
text: "Profiles"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Appearance.*"
|
||||
timeout: 10000
|
||||
- assertVisible: "(?s)^Video Playback.*"
|
||||
- assertVisible: "(?s)^Connections.*"
|
||||
- runFlow:
|
||||
when:
|
||||
platform: Android
|
||||
file: ../subflows/settings_deep_checks.yaml
|
||||
@@ -1,23 +0,0 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Logout returns to authentication
|
||||
tags:
|
||||
- e2e
|
||||
- auth
|
||||
---
|
||||
- runFlow: ../subflows/onboard_jellyfin.yaml
|
||||
- tapOn: "(?s)^M(?:.*Profiles.*)?$"
|
||||
- tapOn:
|
||||
text: "Log out"
|
||||
below:
|
||||
text: "Settings"
|
||||
- extendedWaitUntil:
|
||||
visible: "Are you sure you want to log out?"
|
||||
timeout: 10000
|
||||
- tapOn:
|
||||
text: "Log out"
|
||||
below:
|
||||
text: "Are you sure you want to log out?"
|
||||
- extendedWaitUntil:
|
||||
visible: "Sign in with Plex"
|
||||
timeout: 20000
|
||||
- assertVisible: "Connect to Jellyfin"
|
||||
@@ -1,77 +0,0 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Download and play a movie from Downloads
|
||||
|
||||
tags:
|
||||
- e2e
|
||||
- downloads
|
||||
- playback
|
||||
---
|
||||
- runFlow: ../subflows/onboard_jellyfin.yaml
|
||||
- tapOn: "(?s)^Libraries.*"
|
||||
- tapOn: "Browse"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Alpha Archive.*"
|
||||
timeout: 15000
|
||||
- tapOn: "(?s).*Alpha Archive.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Overview"
|
||||
timeout: 15000
|
||||
- tapOn: "Download"
|
||||
- runFlow:
|
||||
when:
|
||||
platform: iOS
|
||||
commands:
|
||||
- tapOn:
|
||||
point: "6%, 9%"
|
||||
- runFlow:
|
||||
when:
|
||||
platform: Android
|
||||
commands:
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
visible: "Maestro Movies"
|
||||
timeout: 10000
|
||||
- tapOn: "(?s)^Downloads.*"
|
||||
- tapOn:
|
||||
text: "Movies"
|
||||
waitToSettleTimeoutMs: 3000
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Alpha Archive.*"
|
||||
timeout: 60000
|
||||
- runScript:
|
||||
file: ../scripts/set_jellyfin_offline.js
|
||||
env:
|
||||
JELLYFIN_CONTROL_URL: ${JELLYFIN_CONTROL_URL}
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- tapOn: "(?s).*Alpha Archive.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Overview"
|
||||
timeout: 15000
|
||||
- tapOn:
|
||||
text: "Play"
|
||||
waitToSettleTimeoutMs: 1000
|
||||
- extendedWaitUntil:
|
||||
visible: "Pause"
|
||||
timeout: 30000
|
||||
- runFlow:
|
||||
when:
|
||||
platform: iOS
|
||||
commands:
|
||||
- tapOn:
|
||||
point: "90%, 5%"
|
||||
- extendedWaitUntil:
|
||||
visible: "Overview"
|
||||
timeout: 10000
|
||||
- runFlow:
|
||||
when:
|
||||
platform: Android
|
||||
commands:
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
notVisible: "Pause"
|
||||
timeout: 10000
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
visible: "Overview"
|
||||
timeout: 10000
|
||||
@@ -1,56 +0,0 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
ARG JELLYFIN_IMAGE=jellyfin/jellyfin:10.11.11@sha256:aefb67e6a7ff1debdd154a78a7bbb780fd0c873d8639210a7f6a2016ad2b35db
|
||||
|
||||
FROM ${JELLYFIN_IMAGE} AS seed
|
||||
|
||||
USER root
|
||||
RUN apt-get update \
|
||||
&& apt-get install --yes --no-install-recommends ca-certificates python3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /opt/plezy-demo
|
||||
COPY scripts/maestro_fixtures.py scripts/maestro_real_jellyfin.py scripts/prepare_maestro_media.py ./
|
||||
COPY .maestro/jellyfin-demo/seed.sh ./seed.sh
|
||||
|
||||
ARG PLEZY_DEMO_MEDIA_BASE_URL=https://demo-files.plezy.app/media-samples/
|
||||
ARG PLEZY_DEMO_MEDIA_REVISION=2026-07-14
|
||||
ARG PLEZY_DEMO_MEDIA_DURATION=300
|
||||
RUN test -n "${PLEZY_DEMO_MEDIA_REVISION}" \
|
||||
&& python3 maestro_real_jellyfin.py download-codecs \
|
||||
--base-url "${PLEZY_DEMO_MEDIA_BASE_URL}" \
|
||||
--output-dir /tmp/plezy-codecs \
|
||||
&& PATH="/usr/lib/jellyfin-ffmpeg:${PATH}" python3 prepare_maestro_media.py \
|
||||
/tmp/plezy-codecs /tmp/plezy-codecs-prepared \
|
||||
--duration "${PLEZY_DEMO_MEDIA_DURATION}" \
|
||||
--extend av1_opus_ass_srt.mkv \
|
||||
--extend h264_eac3_multisub.mkv \
|
||||
--extend hevc10_flac_ass.mkv \
|
||||
&& python3 maestro_real_jellyfin.py prepare \
|
||||
--output-dir /media \
|
||||
--codec-source-dir /tmp/plezy-codecs-prepared \
|
||||
--include-codecs \
|
||||
&& rm -rf /tmp/plezy-codecs /tmp/plezy-codecs-prepared __pycache__ \
|
||||
&& chmod -R a=rX /media
|
||||
|
||||
RUN ./seed.sh
|
||||
|
||||
FROM ${JELLYFIN_IMAGE}
|
||||
|
||||
LABEL org.opencontainers.image.title="Plezy Jellyfin demo server" \
|
||||
org.opencontainers.image.description="Ready-to-run Jellyfin server with Plezy's deterministic codec demo catalog" \
|
||||
org.opencontainers.image.source="https://github.com/edde746/plezy"
|
||||
|
||||
COPY --from=seed /media /media
|
||||
COPY --from=seed /opt/plezy-demo/seed-config /opt/plezy-demo/seed-config
|
||||
COPY .maestro/jellyfin-demo/entrypoint.sh /usr/local/bin/plezy-demo-entrypoint
|
||||
|
||||
RUN chmod 0755 /usr/local/bin/plezy-demo-entrypoint \
|
||||
&& chmod -R a=rX /media /opt/plezy-demo/seed-config
|
||||
|
||||
ENV TZ=UTC \
|
||||
JELLYFIN_PublishedServerUrl=http://localhost:8096
|
||||
|
||||
EXPOSE 8096
|
||||
STOPSIGNAL SIGTERM
|
||||
ENTRYPOINT ["/usr/local/bin/plezy-demo-entrypoint"]
|
||||
@@ -1,17 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
SEED_CONFIG=/opt/plezy-demo/seed-config
|
||||
SEED_MARKER=.plezy-demo-seed
|
||||
|
||||
if [ ! -f "/config/${SEED_MARKER}" ]; then
|
||||
existing="$(find /config -mindepth 1 -maxdepth 1 -print -quit)"
|
||||
if [ -n "${existing}" ]; then
|
||||
echo "Refusing to overwrite a non-demo Jellyfin configuration in /config." >&2
|
||||
echo "Start this image with a new or empty /config volume." >&2
|
||||
exit 1
|
||||
fi
|
||||
cp -a "${SEED_CONFIG}/." /config/
|
||||
fi
|
||||
|
||||
exec /jellyfin/jellyfin "$@"
|
||||
@@ -1,46 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
SEED_ROOT=/opt/plezy-demo
|
||||
SEED_CONFIG="${SEED_ROOT}/seed-config"
|
||||
SEED_CACHE="${SEED_ROOT}/seed-cache"
|
||||
JELLYFIN_PID=""
|
||||
|
||||
stop_jellyfin() {
|
||||
if [ -n "${JELLYFIN_PID}" ] && kill -0 "${JELLYFIN_PID}" 2>/dev/null; then
|
||||
kill -TERM "${JELLYFIN_PID}"
|
||||
wait "${JELLYFIN_PID}" || true
|
||||
fi
|
||||
}
|
||||
|
||||
on_exit() {
|
||||
exit_status=$?
|
||||
if [ "${exit_status}" -ne 0 ] && [ -f "${SEED_ROOT}/seed-jellyfin.log" ]; then
|
||||
cat "${SEED_ROOT}/seed-jellyfin.log" >&2
|
||||
fi
|
||||
stop_jellyfin
|
||||
}
|
||||
trap on_exit EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
mkdir -p "${SEED_CONFIG}/config" "${SEED_CONFIG}/log" "${SEED_CACHE}"
|
||||
JELLYFIN_DATA_DIR="${SEED_CONFIG}" \
|
||||
JELLYFIN_CONFIG_DIR="${SEED_CONFIG}/config" \
|
||||
JELLYFIN_LOG_DIR="${SEED_CONFIG}/log" \
|
||||
JELLYFIN_CACHE_DIR="${SEED_CACHE}" \
|
||||
XDG_CACHE_HOME="${SEED_CACHE}" \
|
||||
JELLYFIN_PublishedServerUrl="http://127.0.0.1:8096" \
|
||||
/jellyfin/jellyfin >"${SEED_ROOT}/seed-jellyfin.log" 2>&1 &
|
||||
JELLYFIN_PID=$!
|
||||
|
||||
python3 "${SEED_ROOT}/maestro_real_jellyfin.py" bootstrap \
|
||||
--url http://127.0.0.1:8096 \
|
||||
--timeout 180 \
|
||||
--include-codecs
|
||||
|
||||
stop_jellyfin
|
||||
JELLYFIN_PID=""
|
||||
rm -rf "${SEED_CONFIG}/log" "${SEED_CACHE}" "${SEED_ROOT}/seed-jellyfin.log"
|
||||
printf '%s\n' 'Plezy Jellyfin demo seed v1' >"${SEED_CONFIG}/.plezy-demo-seed"
|
||||
trap - EXIT INT TERM
|
||||
@@ -1,12 +0,0 @@
|
||||
flows:
|
||||
- "*.yaml"
|
||||
testOutputDir: "build/maestro-media"
|
||||
executionOrder:
|
||||
continueOnFailure: false
|
||||
flowsOrder:
|
||||
- Codec sample - UHD Dolby Vision TrueHD and PGS
|
||||
- Codec sample - Web Dolby Vision EAC3 Atmos and SRT
|
||||
- Codec sample - AV1 Opus ASS and SRT
|
||||
- Codec sample - H264 High10 DTS-HD and ASS
|
||||
- Codec sample - H264 EAC3 multilingual SRT
|
||||
- Codec sample - HEVC10 FLAC and ASS
|
||||
@@ -1,28 +0,0 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Codec sample - UHD Dolby Vision TrueHD and PGS
|
||||
tags:
|
||||
- e2e
|
||||
- media
|
||||
- dolby-vision
|
||||
---
|
||||
- runFlow:
|
||||
file: ../subflows/open_codec_sample.yaml
|
||||
env:
|
||||
SAMPLE_TITLE: "Codec DV UHD TrueHD PGS"
|
||||
SAMPLE_OVERVIEW: "Dolby Vision profile 8 with an HDR fallback, TrueHD Atmos, AC-3, and PGS subtitles."
|
||||
- runFlow:
|
||||
file: ../subflows/switch_codec_tracks.yaml
|
||||
env:
|
||||
AUDIO_TRACK: "(?is)^English\\s+[^,]*(?:AC3|TrueHD|Surround|5\\.1|7\\.1)[^,]*$"
|
||||
SUBTITLE_TRACK: "(?is)^Spanish\\s+[^,]*(?:PGS|APPLICATION/PGS)[^,]*$"
|
||||
- back
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- runFlow:
|
||||
when:
|
||||
notVisible: "Overview"
|
||||
commands:
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
visible: "Overview"
|
||||
timeout: 10000
|
||||
@@ -1,28 +0,0 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Codec sample - Web Dolby Vision EAC3 Atmos and SRT
|
||||
tags:
|
||||
- e2e
|
||||
- media
|
||||
- dolby-vision
|
||||
---
|
||||
- runFlow:
|
||||
file: ../subflows/open_codec_sample.yaml
|
||||
env:
|
||||
SAMPLE_TITLE: "Codec DV Web EAC3 SRT"
|
||||
SAMPLE_OVERVIEW: "Dolby Vision profile 8 with an HDR fallback, E-AC-3 Atmos, and multilingual SRT subtitles."
|
||||
- runFlow:
|
||||
file: ../subflows/switch_codec_tracks.yaml
|
||||
env:
|
||||
AUDIO_TRACK: "(?is)^German\\s+[^,]*(?:E-AC3|Dolby Digital Plus|Surround|5\\.1)[^,]*$"
|
||||
SUBTITLE_TRACK: "(?is)^German\\s+[^,]*(?:SRT|APPLICATION/X-SUBRIP)[^,]*$"
|
||||
- back
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- runFlow:
|
||||
when:
|
||||
notVisible: "Overview"
|
||||
commands:
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
visible: "Overview"
|
||||
timeout: 10000
|
||||
@@ -1,28 +0,0 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Codec sample - AV1 Opus ASS and SRT
|
||||
tags:
|
||||
- e2e
|
||||
- media
|
||||
- av1
|
||||
---
|
||||
- runFlow:
|
||||
file: ../subflows/open_codec_sample.yaml
|
||||
env:
|
||||
SAMPLE_TITLE: "Codec AV1 Opus ASS"
|
||||
SAMPLE_OVERVIEW: "AV1 video with multilingual Opus audio, styled ASS subtitles, SRT subtitles, and embedded fonts."
|
||||
- runFlow:
|
||||
file: ../subflows/switch_codec_tracks.yaml
|
||||
env:
|
||||
AUDIO_TRACK: "(?is)^English\\s+[^,]*(?:Opus|Stereo)[^,]*$"
|
||||
SUBTITLE_TRACK: "(?is)^English\\s+[^,]*(?:ASS|TEXT/X-SSA)[^,]*$"
|
||||
- back
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- runFlow:
|
||||
when:
|
||||
notVisible: "Overview"
|
||||
commands:
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
visible: "Overview"
|
||||
timeout: 10000
|
||||
@@ -1,28 +0,0 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Codec sample - H264 High10 DTS-HD and ASS
|
||||
tags:
|
||||
- e2e
|
||||
- media
|
||||
- h264-high10
|
||||
---
|
||||
- runFlow:
|
||||
file: ../subflows/open_codec_sample.yaml
|
||||
env:
|
||||
SAMPLE_TITLE: "Codec H264 High10 DTS-HD ASS"
|
||||
SAMPLE_OVERVIEW: "H.264 High 10 video with dual DTS-HD MA audio, styled ASS subtitles, and embedded fonts."
|
||||
- runFlow:
|
||||
file: ../subflows/switch_codec_tracks.yaml
|
||||
env:
|
||||
AUDIO_TRACK: "(?is)^Japanese\\s+[^,]*(?:DTS-HD MA|DTS|Surround|5\\.1)[^,]*$"
|
||||
SUBTITLE_TRACK: "(?is)^English\\s+[^,]*(?:ASS|TEXT/X-SSA)[^,]*$"
|
||||
- back
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- runFlow:
|
||||
when:
|
||||
notVisible: "Overview"
|
||||
commands:
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
visible: "Overview"
|
||||
timeout: 10000
|
||||
@@ -1,29 +0,0 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Codec sample - H264 EAC3 multilingual SRT
|
||||
tags:
|
||||
- e2e
|
||||
- media
|
||||
- subtitles
|
||||
---
|
||||
- runFlow:
|
||||
file: ../subflows/open_codec_sample.yaml
|
||||
env:
|
||||
SAMPLE_TITLE: "Codec H264 EAC3 Multisub"
|
||||
SAMPLE_OVERVIEW: "H.264 video with three E-AC-3 audio tracks and a broad multilingual SRT subtitle set."
|
||||
- runFlow:
|
||||
file: ../subflows/switch_codec_tracks_deep.yaml
|
||||
env:
|
||||
AUDIO_TRACK: "(?is)^English\\s+[^,]*(?:Dolby Digital Plus|E-AC3|Surround|5\\.1)[^,]*$"
|
||||
SUBTITLE_TRACK: "(?is)^English\\s+[^,]*(?:SRT|APPLICATION/X-SUBRIP)[^,]*$"
|
||||
SHEET_MARKER: "(?is)^Japanese\\s+[^,]*(?:Dolby Digital Plus|E-AC3|Surround|5\\.1)[^,]*$"
|
||||
- back
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- runFlow:
|
||||
when:
|
||||
notVisible: "Overview"
|
||||
commands:
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
visible: "Overview"
|
||||
timeout: 10000
|
||||
@@ -1,28 +0,0 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Codec sample - HEVC10 FLAC and ASS
|
||||
tags:
|
||||
- e2e
|
||||
- media
|
||||
- hevc
|
||||
---
|
||||
- runFlow:
|
||||
file: ../subflows/open_codec_sample.yaml
|
||||
env:
|
||||
SAMPLE_TITLE: "Codec HEVC10 FLAC ASS"
|
||||
SAMPLE_OVERVIEW: "HEVC Main 10 video with dual FLAC 5.1 audio, styled ASS subtitles, and embedded fonts."
|
||||
- runFlow:
|
||||
file: ../subflows/switch_codec_tracks.yaml
|
||||
env:
|
||||
AUDIO_TRACK: "(?is)^Japanese\\s+[^,]*(?:FLAC|Surround|5\\.1)[^,]*$"
|
||||
SUBTITLE_TRACK: "(?is)^English\\s+[^,]*(?:ASS|TEXT/X-SSA)[^,]*$"
|
||||
- back
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- runFlow:
|
||||
when:
|
||||
notVisible: "Overview"
|
||||
commands:
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
visible: "Overview"
|
||||
timeout: 10000
|
||||
@@ -1,38 +0,0 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Real Jellyfin imports and opens codec media
|
||||
tags:
|
||||
- e2e
|
||||
- real-jellyfin
|
||||
- media
|
||||
---
|
||||
- runFlow: ../subflows/ensure_onboarded.yaml
|
||||
- tapOn: "(?s)^Search.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Search movies, shows, music..."
|
||||
timeout: 10000
|
||||
- tapOn: "Search movies, shows, music..."
|
||||
- inputText: "Codec DV UHD"
|
||||
- hideKeyboard
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Codec DV UHD TrueHD PGS.*"
|
||||
timeout: 15000
|
||||
- tapOn: "(?s).*Codec DV UHD TrueHD PGS.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Overview"
|
||||
timeout: 15000
|
||||
- assertVisible: "Dolby Vision profile 8 with an HDR fallback, TrueHD Atmos, AC-3, and PGS subtitles."
|
||||
- tapOn:
|
||||
text: "Play"
|
||||
waitToSettleTimeoutMs: 1000
|
||||
- extendedWaitUntil:
|
||||
notVisible: "Overview"
|
||||
timeout: 15000
|
||||
- back
|
||||
- runFlow:
|
||||
when:
|
||||
notVisible: "Overview"
|
||||
commands:
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
visible: "Overview"
|
||||
timeout: 10000
|
||||
@@ -1,54 +0,0 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Jellyfin music browsing survives grouping reloads
|
||||
|
||||
tags:
|
||||
- e2e
|
||||
- real-jellyfin
|
||||
- music
|
||||
- regression
|
||||
---
|
||||
- runFlow: ../subflows/ensure_onboarded.yaml
|
||||
- assertVisible: "(?s)^Latest Albums in Maestro Music.*"
|
||||
- assertVisible: "(?s)^Regression Album.*"
|
||||
- tapOn: "(?s)^Libraries.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Maestro Movies"
|
||||
timeout: 15000
|
||||
- tapOn: "Maestro Movies"
|
||||
- extendedWaitUntil:
|
||||
visible: "Maestro Music"
|
||||
timeout: 10000
|
||||
- tapOn: "Maestro Music"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Regression Album.*"
|
||||
timeout: 15000
|
||||
- assertVisible: "(?s)^Latest Albums in Maestro Music.*"
|
||||
- tapOn: "Browse"
|
||||
- runFlow:
|
||||
when:
|
||||
visible: "Retry"
|
||||
commands:
|
||||
- tapOn: "Retry"
|
||||
- extendedWaitUntil:
|
||||
visible: "Maestro Artist"
|
||||
timeout: 15000
|
||||
- assertNotVisible: "(?i).*connection timeout.*"
|
||||
- tapOn: "Library options"
|
||||
- tapOn: "(?s)^Grouping.*Artists$"
|
||||
- tapOn: "Albums"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Regression Album.*"
|
||||
timeout: 15000
|
||||
- tapOn: "Library options"
|
||||
- tapOn: "(?s)^Grouping.*Albums$"
|
||||
- tapOn: "Tracks"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Resilient Track.*"
|
||||
timeout: 15000
|
||||
- tapOn: "Library options"
|
||||
- tapOn: "(?s)^Grouping.*Tracks$"
|
||||
- tapOn: "Folders"
|
||||
- extendedWaitUntil:
|
||||
visible: "Maestro Artist"
|
||||
timeout: 15000
|
||||
- assertNotVisible: "(?i).*connection timeout.*"
|
||||
@@ -1,66 +0,0 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Profile switching isolates Jellyfin content and libraries
|
||||
|
||||
tags:
|
||||
- e2e
|
||||
- regression
|
||||
- profiles
|
||||
---
|
||||
- runFlow: ../subflows/onboard_jellyfin.yaml
|
||||
- assertVisible: "(?s).*Zulu Zone.*"
|
||||
- tapOn: "(?s)^Libraries.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Maestro Movies"
|
||||
timeout: 15000
|
||||
- assertNotVisible: "Guest Movies"
|
||||
- tapOn: "(?s)^Home.*"
|
||||
- runFlow: ../subflows/create_guest_jellyfin_profile.yaml
|
||||
- tapOn: "(?s)^E\nE2E Guest\n.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Guest Galaxy.*"
|
||||
timeout: 30000
|
||||
- assertNotVisible: "(?s).*Zulu Zone.*"
|
||||
- tapOn: "(?s)^Libraries.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Guest Movies"
|
||||
timeout: 15000
|
||||
- assertNotVisible: "Maestro Movies"
|
||||
- tapOn: "(?s)^Home.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Guest Galaxy.*"
|
||||
timeout: 15000
|
||||
- tapOn: "E"
|
||||
- tapOn:
|
||||
text: "Profiles"
|
||||
above:
|
||||
text: "Settings"
|
||||
- extendedWaitUntil:
|
||||
visible: "Switch Profile"
|
||||
timeout: 10000
|
||||
- tapOn: "(?s)^M\nMaestro\n.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Zulu Zone.*"
|
||||
timeout: 30000
|
||||
- assertNotVisible: "(?s).*Guest Galaxy.*"
|
||||
- tapOn: "(?s)^Libraries.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Maestro Movies"
|
||||
timeout: 15000
|
||||
- assertNotVisible: "Guest Movies"
|
||||
- tapOn: "(?s)^Home.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Zulu Zone.*"
|
||||
timeout: 15000
|
||||
- tapOn: "M"
|
||||
- tapOn:
|
||||
text: "Profiles"
|
||||
above:
|
||||
text: "Settings"
|
||||
- extendedWaitUntil:
|
||||
visible: "Switch Profile"
|
||||
timeout: 10000
|
||||
- tapOn: "(?s)^E\nE2E Guest\n.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Guest Galaxy.*"
|
||||
timeout: 30000
|
||||
- assertNotVisible: "(?s).*Zulu Zone.*"
|
||||
@@ -1,51 +0,0 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Removing a Jellyfin profile connection leaves no orphaned session
|
||||
|
||||
tags:
|
||||
- e2e
|
||||
- regression
|
||||
- profiles
|
||||
- teardown
|
||||
---
|
||||
- runFlow: ../subflows/onboard_jellyfin.yaml
|
||||
- runFlow: ../subflows/create_guest_jellyfin_profile.yaml
|
||||
- tapOn:
|
||||
text: "Manage"
|
||||
index: 1
|
||||
- tapOn: "Manage"
|
||||
- extendedWaitUntil:
|
||||
visible: "Profile name"
|
||||
timeout: 10000
|
||||
- assertVisible: "(?s)^E2E Guest.*"
|
||||
- assertVisible: "(?s)^Maestro Jellyfin.*Default.*$"
|
||||
- tapOn: "Manage"
|
||||
- extendedWaitUntil:
|
||||
visible: "Remove"
|
||||
timeout: 10000
|
||||
- tapOn: "Remove"
|
||||
- extendedWaitUntil:
|
||||
visible: "^Remove connection\\?$"
|
||||
timeout: 10000
|
||||
- assertVisible: "(?s).*E2E Guest's access to Maestro Jellyfin.*"
|
||||
- tapOn: "Remove"
|
||||
- extendedWaitUntil:
|
||||
notVisible: "(?s)^Maestro Jellyfin.*"
|
||||
timeout: 15000
|
||||
- assertVisible: "Delete profile"
|
||||
- retry:
|
||||
maxRetries: 2
|
||||
commands:
|
||||
- tapOn: "Delete profile"
|
||||
- extendedWaitUntil:
|
||||
visible: "^Delete profile\\?$"
|
||||
timeout: 10000
|
||||
- tapOn: "^Delete$"
|
||||
- extendedWaitUntil:
|
||||
visible: "Switch Profile"
|
||||
timeout: 30000
|
||||
- assertNotVisible: "(?s).*E2E Guest.*"
|
||||
- assertVisible: "(?s)^M\nMaestro\nActive.*"
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Recently Added in Maestro Movies.*"
|
||||
timeout: 30000
|
||||
@@ -1,77 +0,0 @@
|
||||
appId: com.edde746.plezy
|
||||
name: TV alphabet rail retains focus until returning to the media grid
|
||||
|
||||
tags:
|
||||
- e2e
|
||||
- regression
|
||||
- tv
|
||||
- focus
|
||||
---
|
||||
- runFlow: ../subflows/onboard_jellyfin_tv.yaml
|
||||
- pressKey: "Remote Dpad Left"
|
||||
- pressKey: "Remote Dpad Down"
|
||||
- pressKey: "Remote Dpad Down"
|
||||
- pressKey: "Remote Dpad Center"
|
||||
- extendedWaitUntil:
|
||||
visible: "Recommended"
|
||||
timeout: 15000
|
||||
- pressKey: "Remote Dpad Right"
|
||||
- pressKey: "Remote Dpad Up"
|
||||
- pressKey: "Remote Dpad Right"
|
||||
- pressKey: "Remote Dpad Center"
|
||||
- pressKey: "Remote Dpad Up"
|
||||
- pressKey: "Remote Dpad Right"
|
||||
- pressKey: "Remote Dpad Right"
|
||||
# The grid row must be matchable before the sort sheet opens, otherwise the
|
||||
# occlusion assertion below would pass for the wrong reason.
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Alpha Archive.*"
|
||||
timeout: 15000
|
||||
- pressKey: "Remote Dpad Center"
|
||||
- extendedWaitUntil:
|
||||
visible: "Title"
|
||||
timeout: 10000
|
||||
# The host renders the grid, the barrier, and the sheet in one Stack and never
|
||||
# unmounts the grid, so before 8e1904dd wrapped the barrier in BlockSemantics
|
||||
# an occluded row still read as visible to accessibility and to Maestro.
|
||||
- assertNotVisible: "(?s).*Alpha Archive.*"
|
||||
- pressKey: "Remote Dpad Center"
|
||||
- pressKey: "Remote Dpad Left"
|
||||
- extendedWaitUntil:
|
||||
notVisible: "Sort By"
|
||||
timeout: 10000
|
||||
- pressKey: "Remote Dpad Down"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Alpha Archive.*"
|
||||
timeout: 15000
|
||||
- repeat:
|
||||
times: 8
|
||||
commands:
|
||||
- pressKey: "Remote Dpad Right"
|
||||
- repeat:
|
||||
times: 13
|
||||
commands:
|
||||
- pressKey: "Remote Dpad Down"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Mike Matrix.*"
|
||||
timeout: 15000
|
||||
- assertNotVisible: "(?s).*Alpha Archive.*"
|
||||
- repeat:
|
||||
times: 13
|
||||
commands:
|
||||
- pressKey: "Remote Dpad Down"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Zulu Zone.*"
|
||||
timeout: 15000
|
||||
- assertNotVisible: "(?s).*Mike Matrix.*"
|
||||
- pressKey: "Remote Dpad Left"
|
||||
- pressKey: "Remote Dpad Center"
|
||||
- extendedWaitUntil:
|
||||
visible: "Play"
|
||||
timeout: 15000
|
||||
- assertVisible: "(?s).*Zulu Zone.*"
|
||||
- pressKey: "back"
|
||||
- extendedWaitUntil:
|
||||
visible: "Browse"
|
||||
timeout: 15000
|
||||
- assertVisible: "(?s).*Zulu Zone.*"
|
||||
@@ -1,73 +0,0 @@
|
||||
appId: com.edde746.plezy
|
||||
name: TV hardware media and back keys follow player chrome state
|
||||
|
||||
tags:
|
||||
- e2e
|
||||
- regression
|
||||
- tv
|
||||
- playback
|
||||
---
|
||||
- runFlow: ../subflows/onboard_jellyfin_tv.yaml
|
||||
- pressKey: "Remote Dpad Center"
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 5000
|
||||
- runFlow:
|
||||
when:
|
||||
visible: "(?s)^(Play|Resume).*$"
|
||||
commands:
|
||||
- pressKey: "Remote Dpad Center"
|
||||
- extendedWaitUntil:
|
||||
visible: "Pause"
|
||||
timeout: 30000
|
||||
- pressKey: "back"
|
||||
- extendedWaitUntil:
|
||||
notVisible: "Pause"
|
||||
timeout: 10000
|
||||
# Hardware transport keys drive playback without raising the chrome (#1676):
|
||||
# the player answers with a centred disc (pause) or a side readout (seek), never the
|
||||
# full chrome, so subtitles stay readable.
|
||||
# Pause first — it also freezes the clip, so the seek assertions below cannot
|
||||
# race the end of a short fixture item.
|
||||
- pressKey: "Remote Media Play Pause"
|
||||
- extendedWaitUntil:
|
||||
visible: "Paused"
|
||||
timeout: 10000
|
||||
- assertNotVisible: "(?s)^Play$"
|
||||
# D-pad seeking reports through the skip badge, never the scrub bar, and
|
||||
# consecutive presses in one direction stack into a running total.
|
||||
- pressKey: "Remote Dpad Right"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Seek forward 10 seconds$"
|
||||
timeout: 10000
|
||||
- assertNotVisible: "(?s)^Play$"
|
||||
- pressKey: "Remote Dpad Right"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Seek forward 20 seconds$"
|
||||
timeout: 10000
|
||||
- pressKey: "Remote Media Play Pause"
|
||||
- extendedWaitUntil:
|
||||
visible: "Playing"
|
||||
timeout: 10000
|
||||
- assertNotVisible: "(?s)^Pause$"
|
||||
# Select stays the deliberate way to bring the chrome back.
|
||||
- pressKey: "Remote Dpad Center"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^(Play|Pause)$"
|
||||
timeout: 10000
|
||||
- pressKey: "back"
|
||||
- extendedWaitUntil:
|
||||
notVisible: "(?s)^Pause$"
|
||||
timeout: 10000
|
||||
- assertNotVisible: "Overview"
|
||||
- pressKey: "back"
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 5000
|
||||
- runFlow:
|
||||
when:
|
||||
visible: "Overview"
|
||||
commands:
|
||||
- pressKey: "back"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "tv_browse_rail_selection"
|
||||
timeout: 15000
|
||||
@@ -1,50 +0,0 @@
|
||||
appId: com.edde746.plezy
|
||||
name: TV Back dismisses Next Episode without leaving playback
|
||||
tags:
|
||||
- e2e
|
||||
- regression
|
||||
- tv
|
||||
- playback
|
||||
---
|
||||
- runFlow: ../subflows/onboard_jellyfin_tv.yaml
|
||||
- tapOn:
|
||||
point: "6%,22%"
|
||||
- tapOn: "Search"
|
||||
- extendedWaitUntil:
|
||||
visible: "Search movies, shows, music..."
|
||||
timeout: 10000
|
||||
- tapOn: "Search movies, shows, music..."
|
||||
- inputText: "Maestro Show"
|
||||
- tapOn:
|
||||
point: "87%,90%"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Maestro Show, TV show, (?:watched|unwatched)$"
|
||||
timeout: 15000
|
||||
- tapOn: "(?s)^Maestro Show, TV show, (?:watched|unwatched)$"
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 5000
|
||||
- tapOn: "(?s)^Play S1E1$"
|
||||
- extendedWaitUntil:
|
||||
visible: "Pause"
|
||||
timeout: 30000
|
||||
- pressKey: "Remote Media Fast Forward"
|
||||
- pressKey: "Remote Media Fast Forward"
|
||||
- extendedWaitUntil:
|
||||
visible: "Next Episode"
|
||||
timeout: 20000
|
||||
- runFlow:
|
||||
when:
|
||||
platform: iOS
|
||||
commands:
|
||||
- tapOn: "Cancel"
|
||||
- runFlow:
|
||||
when:
|
||||
platform: Android
|
||||
commands:
|
||||
- pressKey: "back"
|
||||
- assertNotVisible: "Next Episode"
|
||||
- assertNotVisible: "Overview"
|
||||
- pressKey: "Remote Dpad Up"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Maestro Episode 1.*"
|
||||
timeout: 10000
|
||||
@@ -1,36 +0,0 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Playback recovers after a transient stream failure
|
||||
tags:
|
||||
- e2e
|
||||
- regression
|
||||
- playback
|
||||
- recovery
|
||||
---
|
||||
- runFlow: ../subflows/onboard_jellyfin.yaml
|
||||
- tapOn: "(?s)^Libraries.*"
|
||||
# Every seeded alphabet title carries the same dateadded, so which of them the
|
||||
# "Recently Added" rail returns is not stable across scans. This flow only
|
||||
# needs some playable movie, so accept any of them rather than pinning one and
|
||||
# failing whenever the tie-break lands elsewhere.
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*(?:Alpha Archive|Bravo Beacon|Charlie Circuit|Delta Drive|Echo Engine|Foxtrot Frame|Gamma Garden|Hotel Horizon|India Index|Juliet Junction|Kilo Key|Lima Loop|Mike Matrix|November Node|Oscar Orbit|Papa Pipeline|Quebec Queue|Romeo Relay|Sierra Signal|Tango Track|Uniform Update|Victor View|Whiskey Widget|Xray XML|Yankee Yield|Zulu Zone).*, movie, .*"
|
||||
timeout: 15000
|
||||
- tapOn: "(?s).*(?:Alpha Archive|Bravo Beacon|Charlie Circuit|Delta Drive|Echo Engine|Foxtrot Frame|Gamma Garden|Hotel Horizon|India Index|Juliet Junction|Kilo Key|Lima Loop|Mike Matrix|November Node|Oscar Orbit|Papa Pipeline|Quebec Queue|Romeo Relay|Sierra Signal|Tango Track|Uniform Update|Victor View|Whiskey Widget|Xray XML|Yankee Yield|Zulu Zone).*, movie, .*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Play"
|
||||
timeout: 15000
|
||||
- tapOn:
|
||||
text: "Play"
|
||||
waitToSettleTimeoutMs: 1000
|
||||
- extendedWaitUntil:
|
||||
visible: "Pause"
|
||||
timeout: 45000
|
||||
- assertNotVisible: "Overview"
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
notVisible: "Pause"
|
||||
timeout: 10000
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
visible: "Overview"
|
||||
timeout: 15000
|
||||
@@ -1,94 +0,0 @@
|
||||
appId: com.edde746.plezy
|
||||
name: System back dismisses a hosted sheet without leaving Settings
|
||||
|
||||
tags:
|
||||
- e2e
|
||||
- regression
|
||||
- settings
|
||||
- sheets
|
||||
---
|
||||
# Guards 8e1904dd and c48cbf70 together.
|
||||
#
|
||||
# On a phone, Settings is a pushed route. Before c48cbf70 it carried no
|
||||
# OverlaySheetHost, so Manage Libraries fell back to showModalBottomSheet and a
|
||||
# single Back tore down the sheet *and* Settings. Before 8e1904dd the host
|
||||
# answered the platform pop with a TV-only dedup marker that could already be
|
||||
# spent, stranding the sheet open with no way out. Either defect fails the
|
||||
# "Manage Libraries is still there after one Back" assertion below.
|
||||
#
|
||||
# The same commit blocks semantics behind the barrier, so the occlusion
|
||||
# assertions pin that an open sheet actually hides the rows underneath.
|
||||
# Pristine state on purpose. The TV regressions in the same CI group leave
|
||||
# "Force TV mode" enabled, and this flow asserts the handheld Settings route,
|
||||
# so it must not inherit whichever layout the previous flow left behind.
|
||||
- runFlow: ../subflows/onboard_jellyfin.yaml
|
||||
- runFlow: ../subflows/open_settings.yaml
|
||||
- assertVisible: "(?s)^Services.*"
|
||||
- assertVisible: "(?s)^Video Playback.*"
|
||||
- tapOn: "(?s)^Manage Libraries.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Maestro Movies"
|
||||
timeout: 15000
|
||||
# The barrier swallows every pointer event, so the rows behind it must leave
|
||||
# the semantics tree too. Without BlockSemantics an occluded row still reads as
|
||||
# visible and a tap on it lands on the barrier instead. "Manage Libraries" is
|
||||
# excluded on purpose: it is also the sheet's own title.
|
||||
- assertNotVisible: "(?s)^Services.*"
|
||||
- assertNotVisible: "(?s)^Video Playback.*"
|
||||
- assertNotVisible: "(?s)^Appearance.*"
|
||||
- assertNotVisible: "(?s)^Connections.*"
|
||||
- back
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- assertNotVisible: "Maestro Movies"
|
||||
# One Back closed only the sheet: Settings is still the current route.
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Manage Libraries.*"
|
||||
timeout: 10000
|
||||
- assertVisible: "(?s)^Services.*"
|
||||
- assertVisible: "(?s)^Video Playback.*"
|
||||
- assertNotVisible: "Discover"
|
||||
# Reopening proves the host did not strand the one-shot dedup marker: a second
|
||||
# dismissal has to work exactly like the first.
|
||||
- tapOn: "(?s)^Manage Libraries.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Maestro Movies"
|
||||
timeout: 15000
|
||||
# The per-library menu is a nested page of the same sheet now that the host
|
||||
# exists, so Back returns to the library list instead of tearing the sheet down.
|
||||
- tapOn: "Library options"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Refresh Metadata.*"
|
||||
timeout: 15000
|
||||
- back
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- extendedWaitUntil:
|
||||
visible: "Maestro Movies"
|
||||
timeout: 10000
|
||||
- assertNotVisible: "(?s).*Refresh Metadata.*"
|
||||
# Back at the library list, one more Back leaves the sheet entirely.
|
||||
- back
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- assertNotVisible: "Maestro Movies"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Manage Libraries.*"
|
||||
timeout: 10000
|
||||
- tapOn: "(?s)^Manage Libraries.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Maestro Movies"
|
||||
timeout: 15000
|
||||
- back
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- assertNotVisible: "Maestro Movies"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Manage Libraries.*"
|
||||
timeout: 10000
|
||||
# With no sheet open the host must let the route pop normally.
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
visible: "Discover"
|
||||
timeout: 15000
|
||||
- assertNotVisible: "(?s)^Manage Libraries.*"
|
||||
@@ -1,112 +0,0 @@
|
||||
appId: com.edde746.plezy
|
||||
name: An explicit track choice survives the pending automatic pass
|
||||
|
||||
tags:
|
||||
- e2e
|
||||
- regression
|
||||
- playback
|
||||
- subtitles
|
||||
---
|
||||
# Guards 468d6804. When a source advertises tracks the native list has not
|
||||
# produced yet, TrackManager arms an automatic selection pass with a 5s first
|
||||
# attempt and a 25s deadline. A user pick made inside that window used to only
|
||||
# persist the preference, leaving the pass armed, so the choice snapped back to
|
||||
# the server/profile preferred track up to ~30s later. The pick now retires the
|
||||
# pending pass, and the wait below is long enough to outlive both timers.
|
||||
#
|
||||
# Fixture: Codec H264 EAC3 Multisub advertises three E-AC-3 audio streams
|
||||
# (Hindi and Japanese flagged default, English not) and 36 SRT subtitle
|
||||
# streams, so both picks below are genuine changes away from the automatic one.
|
||||
# Pristine state on purpose, for two reasons: the TV regressions in the same
|
||||
# CI group leave "Force TV mode" enabled, and a remembered track selection
|
||||
# from an earlier run would pre-select the very rows this flow picks, making
|
||||
# every assertion below vacuous.
|
||||
- runFlow: ../subflows/onboard_jellyfin.yaml
|
||||
- runFlow:
|
||||
file: ../subflows/open_codec_sample.yaml
|
||||
env:
|
||||
SAMPLE_TITLE: "Codec H264 EAC3 Multisub"
|
||||
SAMPLE_OVERVIEW: "H.264 video with three E-AC-3 audio tracks and a broad multilingual SRT subtitle set."
|
||||
- assertVisible: "(?is)^English\\s+[^,]*(?:Dolby Digital Plus|E-AC3|Surround|5\\.1)[^,]*$"
|
||||
- tapOn:
|
||||
text: "(?is)^English\\s+[^,]*(?:Dolby Digital Plus|E-AC3|Surround|5\\.1)[^,]*$"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^.*Audio & Subtitles$"
|
||||
timeout: 30000
|
||||
- tapOn: "(?s)^.*Audio & Subtitles$"
|
||||
# German is the only German subtitle in the set and is never the automatic
|
||||
# pick, so a revert to the preferred language or to Off is unambiguous.
|
||||
- repeat:
|
||||
times: 12
|
||||
while:
|
||||
notVisible: "(?is)^German\\s+[^,]*(?:SRT|APPLICATION/X-SUBRIP)[^,]*$"
|
||||
commands:
|
||||
- swipe:
|
||||
start: 70%, 82%
|
||||
end: 70%, 68%
|
||||
duration: 300
|
||||
- tapOn:
|
||||
text: "(?is)^German\\s+[^,]*(?:SRT|APPLICATION/X-SUBRIP)[^,]*$"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^.*Audio & Subtitles$"
|
||||
timeout: 30000
|
||||
# The pending pass is wall-clock driven and a paused frame settles instantly,
|
||||
# so playback has to run for the wait below to consume real time.
|
||||
- runFlow:
|
||||
when:
|
||||
visible: "Play"
|
||||
commands:
|
||||
- tapOn: "Play"
|
||||
- extendedWaitUntil:
|
||||
visible: "Pause"
|
||||
timeout: 20000
|
||||
- repeat:
|
||||
times: 14
|
||||
commands:
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
# Chrome auto-hides after 30s even in Maestro builds, so bring it back.
|
||||
- tapOn:
|
||||
point: 50%, 50%
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^.*Audio & Subtitles$"
|
||||
timeout: 30000
|
||||
# The tracks button announces the live selection, so this catches a revert
|
||||
# without reopening the sheet.
|
||||
- assertVisible: "(?is)^English[^,]*(?:Dolby Digital Plus|E-AC3|Surround|5\\.1)[^,]*,.*Audio & Subtitles$"
|
||||
- tapOn: "(?s)^.*Audio & Subtitles$"
|
||||
- assertVisible:
|
||||
text: "(?is)^English\\s+[^,]*(?:Dolby Digital Plus|E-AC3|Surround|5\\.1)[^,]*$"
|
||||
selected: true
|
||||
- assertNotVisible:
|
||||
text: "(?is)^Japanese\\s+[^,]*(?:Dolby Digital Plus|E-AC3|Surround|5\\.1)[^,]*$"
|
||||
selected: true
|
||||
- repeat:
|
||||
times: 12
|
||||
while:
|
||||
notVisible: "(?is)^German\\s+[^,]*(?:SRT|APPLICATION/X-SUBRIP)[^,]*$"
|
||||
commands:
|
||||
- swipe:
|
||||
start: 70%, 82%
|
||||
end: 70%, 68%
|
||||
duration: 300
|
||||
- assertVisible:
|
||||
text: "(?is)^German\\s+[^,]*(?:SRT|APPLICATION/X-SUBRIP)[^,]*$"
|
||||
selected: true
|
||||
- assertNotVisible:
|
||||
text: "Off"
|
||||
selected: true
|
||||
- back
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- back
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- runFlow:
|
||||
when:
|
||||
notVisible: "Overview"
|
||||
commands:
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
visible: "Overview"
|
||||
timeout: 15000
|
||||
@@ -1,90 +0,0 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Language picker offers the new locales and switching one relocalizes the app
|
||||
|
||||
tags:
|
||||
- e2e
|
||||
- regression
|
||||
- settings
|
||||
- i18n
|
||||
---
|
||||
# Guards 7677d159 and 100d7729. AppLocale is switched over exhaustively in
|
||||
# appearance_settings_screen.dart, so a missing arm is a compile error, but a
|
||||
# locale whose JSON never reached the generator ships a picker entry that falls
|
||||
# back to English. Selecting Turkish and reading root-level navigation labels
|
||||
# proves the generated locale is wired end to end.
|
||||
#
|
||||
# This flow mutates a persisted pref, and the app language decides every
|
||||
# selector after the tap, so it starts from a cleared install rather than
|
||||
# ensure_onboarded: a run that dies mid-flow must not leave the next flow
|
||||
# hunting for English strings in a Turkish tree.
|
||||
- runFlow: ../subflows/onboard_jellyfin.yaml
|
||||
- runFlow: ../subflows/open_settings.yaml
|
||||
- tapOn: "(?s)^Appearance.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Theme.*"
|
||||
timeout: 15000
|
||||
- tapOn: "(?s)^Language.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Azərbaycanca"
|
||||
timeout: 10000
|
||||
- assertVisible: "English"
|
||||
- assertVisible: "Қазақша"
|
||||
# The dialog holds 22 endonyms and only the first 16 fit on a phone, so the two
|
||||
# locales that sort last need scrolling into view.
|
||||
- repeat:
|
||||
times: 8
|
||||
while:
|
||||
notVisible: "Türkçe"
|
||||
commands:
|
||||
- swipe:
|
||||
start: 50%, 80%
|
||||
end: 50%, 45%
|
||||
duration: 300
|
||||
- assertVisible: "Türkçe"
|
||||
- assertVisible: "Oʻzbekcha"
|
||||
- tapOn: "Türkçe"
|
||||
# Selecting a language rebuilds the app from the root route, so everything
|
||||
# below runs against the Turkish tree.
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Kitaplıklar.*"
|
||||
timeout: 30000
|
||||
- assertVisible: "(?s)^Ana Sayfa.*"
|
||||
- assertVisible: "(?s)^Ara.*"
|
||||
- assertVisible: "(?s)^İndirmeler.*"
|
||||
- assertVisible: "Keşfet"
|
||||
- assertNotVisible: "(?s)^Libraries.*"
|
||||
- assertNotVisible: "Discover"
|
||||
# Walk back through the Turkish tree and restore English. The pref outlives a
|
||||
# plain launchApp, so leaving the device in Turkish would break every later
|
||||
# flow in the suite.
|
||||
- repeat:
|
||||
times: 3
|
||||
while:
|
||||
notVisible: "Ayarlar"
|
||||
commands:
|
||||
- tapOn: "(?s)^M(?:.*Profiller.*)?$"
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- tapOn:
|
||||
text: "Ayarlar"
|
||||
below:
|
||||
text: "Profiller"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Görünüm.*"
|
||||
timeout: 15000
|
||||
- assertVisible: "(?s)^Servisler.*"
|
||||
- assertVisible: "(?s)^Video Oynatma.*"
|
||||
- tapOn: "(?s)^Görünüm.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Tema.*"
|
||||
timeout: 15000
|
||||
- tapOn: "(?s)^Dil.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "English"
|
||||
timeout: 10000
|
||||
- tapOn: "English"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Libraries.*"
|
||||
timeout: 30000
|
||||
- assertVisible: "Discover"
|
||||
- assertNotVisible: "(?s)^Kitaplıklar.*"
|
||||
@@ -1,142 +0,0 @@
|
||||
appId: com.edde746.plezy
|
||||
name: TV settings stay navigable and dismiss their overlays
|
||||
|
||||
tags:
|
||||
- e2e
|
||||
- regression
|
||||
- tv
|
||||
- settings
|
||||
---
|
||||
# Guards ef310459 and the Apple-only gates touched by 6c14049e, and pins TV
|
||||
# settings navigation generally.
|
||||
#
|
||||
# It deliberately does NOT guard the 15b54e2e density change. That commit took
|
||||
# every settings row from ~80dp to ~61dp, but the rows below the fold stay in
|
||||
# the semantics tree either way, so this flow passes identically on both sides
|
||||
# of it — measured, not assumed. A real guard would need a pixel `height`
|
||||
# assertion, and Maestro has no DPR-relative form, so pinning one here would
|
||||
# only hold for the box it was written on. Row geometry belongs to
|
||||
# test/widgets/setting_tile_test.dart.
|
||||
#
|
||||
# Every step uses D-pad keys, never taps. InputModeTracker flips to pointer
|
||||
# mode on the first touch event and the TV number spinner only builds in
|
||||
# keyboard mode, so a single tapOn anywhere above would silently swap the
|
||||
# spinner for a plain text field.
|
||||
- runFlow: ../subflows/onboard_jellyfin_tv_device.yaml
|
||||
- pressKey: "Remote Dpad Left"
|
||||
- extendedWaitUntil:
|
||||
visible: "Settings"
|
||||
timeout: 15000
|
||||
- repeat:
|
||||
times: 12
|
||||
while:
|
||||
notVisible:
|
||||
text: "Settings"
|
||||
focused: true
|
||||
commands:
|
||||
- pressKey: "Remote Dpad Down"
|
||||
- pressKey: "Remote Dpad Center"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Appearance.*"
|
||||
timeout: 15000
|
||||
- assertVisible: "(?s)^Video Playback.*"
|
||||
- assertVisible: "(?s)^Manage Libraries.*"
|
||||
- assertVisible: "(?s)^Services.*"
|
||||
# The Connections card sits below the first card, so reaching it also proves
|
||||
# the list scrolled or fits — not that any particular row height applies.
|
||||
- assertVisible: "(?s)^Add connection.*"
|
||||
- assertVisible: "(?s)^Profiles.*"
|
||||
# Manage Libraries takes the TV dialog path, not the overlay-sheet path, and
|
||||
# one Back has to return to Settings rather than leave it.
|
||||
- repeat:
|
||||
times: 6
|
||||
while:
|
||||
notVisible:
|
||||
text: "(?s)^Manage Libraries.*"
|
||||
focused: true
|
||||
commands:
|
||||
- pressKey: "Remote Dpad Down"
|
||||
- pressKey: "Remote Dpad Center"
|
||||
- extendedWaitUntil:
|
||||
visible: "Maestro Movies"
|
||||
timeout: 15000
|
||||
- assertVisible: "Maestro Shows"
|
||||
- assertNotVisible: "(?s)^Services.*"
|
||||
- assertNotVisible: "(?s)^Video Playback.*"
|
||||
- pressKey: "back"
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- assertNotVisible: "Maestro Movies"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Services.*"
|
||||
timeout: 15000
|
||||
- assertVisible: "(?s)^Manage Libraries.*"
|
||||
# Services keeps every hub row after losing its own density overrides.
|
||||
- repeat:
|
||||
times: 6
|
||||
while:
|
||||
notVisible:
|
||||
text: "(?s)^Services.*"
|
||||
focused: true
|
||||
commands:
|
||||
- pressKey: "Remote Dpad Down"
|
||||
- pressKey: "Remote Dpad Center"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Trakt.*"
|
||||
timeout: 15000
|
||||
- assertVisible: "(?s)^MyAnimeList.*"
|
||||
- assertVisible: "(?s)^AniList.*"
|
||||
- assertVisible: "(?s)^Simkl.*"
|
||||
- assertVisible: "(?s)^Seerr.*"
|
||||
- pressKey: "back"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Manage Libraries.*"
|
||||
timeout: 15000
|
||||
# Video Playback: the Atmos test screen is gated to Apple TV, and the D-pad
|
||||
# numeric dialog must expose the spinner's restored accessibility labels.
|
||||
- repeat:
|
||||
times: 8
|
||||
while:
|
||||
notVisible:
|
||||
text: "(?s)^Video Playback.*"
|
||||
focused: true
|
||||
commands:
|
||||
- pressKey: "Remote Dpad Up"
|
||||
- pressKey: "Remote Dpad Center"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Player.*"
|
||||
timeout: 15000
|
||||
- assertNotVisible: "(?s)^Atmos Output Test.*"
|
||||
- repeat:
|
||||
times: 30
|
||||
while:
|
||||
notVisible:
|
||||
text: "(?s)^Small Skip Duration.*"
|
||||
focused: true
|
||||
commands:
|
||||
- pressKey: "Remote Dpad Down"
|
||||
- pressKey: "Remote Dpad Center"
|
||||
- extendedWaitUntil:
|
||||
visible: "Decrease"
|
||||
timeout: 15000
|
||||
- assertVisible: "Increase"
|
||||
- assertVisible: "(?s)^Enter duration.*"
|
||||
- pressKey: "back"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Small Skip Duration.*"
|
||||
timeout: 15000
|
||||
- pressKey: "back"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Manage Libraries.*"
|
||||
timeout: 15000
|
||||
# Settings is a rail tab on TV rather than a pushed route, so Back hands focus
|
||||
# back to the rail and expands it instead of leaving the screen.
|
||||
- pressKey: "back"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "Settings"
|
||||
focused: true
|
||||
timeout: 20000
|
||||
- assertVisible: "Home"
|
||||
- assertVisible: "Libraries"
|
||||
- assertVisible: "Downloads"
|
||||
@@ -1,11 +0,0 @@
|
||||
const controlUrl = String(JELLYFIN_CONTROL_URL).trim();
|
||||
|
||||
if (controlUrl !== "" && controlUrl !== "undefined" && controlUrl !== "null") {
|
||||
const response = http.post(`${controlUrl}/__maestro/offline`, {
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify({enabled: true}),
|
||||
});
|
||||
if (response.status !== 204) {
|
||||
throw new Error(`Could not put Jellyfin fixture offline: HTTP ${response.status}`);
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
appId: com.edde746.plezy
|
||||
---
|
||||
- tapOn: "M"
|
||||
- tapOn:
|
||||
text: "Profiles"
|
||||
above:
|
||||
text: "Settings"
|
||||
- extendedWaitUntil:
|
||||
visible: "Switch Profile"
|
||||
timeout: 10000
|
||||
- tapOn: "Add Plezy profile"
|
||||
- extendedWaitUntil:
|
||||
visible: "New profile"
|
||||
timeout: 10000
|
||||
- tapOn: "e.g. Guests, Kids, Family Room"
|
||||
- inputText: "E2E Guest"
|
||||
- hideKeyboard
|
||||
- tapOn: "Continue"
|
||||
- extendedWaitUntil:
|
||||
visible: "Add to E2E Guest"
|
||||
timeout: 10000
|
||||
- tapOn: "(?s)^Connect to Jellyfin.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Add Jellyfin server"
|
||||
timeout: 10000
|
||||
- tapOn: "(?s)Server URLs.*"
|
||||
- inputText: ${JELLYFIN_URL}
|
||||
- hideKeyboard
|
||||
- tapOn: "Find server"
|
||||
- extendedWaitUntil:
|
||||
visible: "Maestro Jellyfin"
|
||||
timeout: 15000
|
||||
- tapOn: "Username"
|
||||
- inputText: "guest"
|
||||
- tapOn: "Password"
|
||||
- inputText: "guest"
|
||||
- hideKeyboard
|
||||
- tapOn: "Sign in"
|
||||
- extendedWaitUntil:
|
||||
visible: "Switch Profile"
|
||||
timeout: 30000
|
||||
- assertVisible: "(?s)^M\nMaestro\n.*"
|
||||
- assertVisible: "(?s)^E\nE2E Guest\n.*"
|
||||
@@ -1,27 +0,0 @@
|
||||
appId: com.edde746.plezy
|
||||
---
|
||||
# Reach a signed-in Home without paying for onboarding that already happened.
|
||||
#
|
||||
# `onboard_jellyfin.yaml` clears app state and retypes the server URL and
|
||||
# credentials, which measures ~59s per flow on a Pixel 7 and dominates every
|
||||
# flow that only needs "signed in, on Home". A plain `launchApp` keeps stored
|
||||
# data and cold-starts straight back to Home, which measures ~16s.
|
||||
#
|
||||
# The guard keeps each flow runnable on its own: when no session is stored --
|
||||
# a fresh install, or a run after the logout flow -- "Discover" is absent and
|
||||
# this falls through to the real onboarding. Flows that must prove onboarding
|
||||
# itself, or that need pristine state, keep calling onboard_jellyfin.yaml.
|
||||
- launchApp
|
||||
# Settle on a known screen first. Branching straight off `launchApp` races the
|
||||
# splash: "Discover" has not rendered yet, the guard reads it as absent, and
|
||||
# every flow pays for a full re-onboarding it did not need.
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^(?:Discover|Sign in with Plex|Connect to Jellyfin|Wait).*"
|
||||
timeout: 30000
|
||||
- runFlow:
|
||||
when:
|
||||
notVisible: "Discover"
|
||||
file: onboard_jellyfin.yaml
|
||||
- extendedWaitUntil:
|
||||
visible: "Discover"
|
||||
timeout: 30000
|
||||
@@ -1,50 +0,0 @@
|
||||
appId: com.edde746.plezy
|
||||
---
|
||||
- retry:
|
||||
maxRetries: 1
|
||||
commands:
|
||||
- launchApp:
|
||||
clearState: true
|
||||
permissions:
|
||||
all: allow
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^(?:Connect to Jellyfin|Wait).*"
|
||||
timeout: 30000
|
||||
# A miss on `tapOn ... optional: true` still runs Maestro's full element
|
||||
# search before giving up, which measured 3s here and 7.8s for "Sign in"
|
||||
# on every onboarding. These taps normally find nothing, so gate them on
|
||||
# a single visibility check instead.
|
||||
- runFlow:
|
||||
when:
|
||||
visible: "Wait"
|
||||
commands:
|
||||
- tapOn: "Wait"
|
||||
- extendedWaitUntil:
|
||||
visible: "Connect to Jellyfin"
|
||||
timeout: 30000
|
||||
- tapOn: "Connect to Jellyfin"
|
||||
- extendedWaitUntil:
|
||||
visible: "Add Jellyfin server"
|
||||
timeout: 10000
|
||||
- tapOn: "(?s)Server URLs.*"
|
||||
- inputText: ${JELLYFIN_URL}
|
||||
- tapOn: "Find server"
|
||||
- extendedWaitUntil:
|
||||
visible: "Maestro Jellyfin"
|
||||
timeout: 15000
|
||||
- tapOn: "Username"
|
||||
- inputText: "maestro"
|
||||
- tapOn: "Password"
|
||||
- inputText: "maestro"
|
||||
- pressKey: ENTER
|
||||
- retry:
|
||||
maxRetries: 1
|
||||
commands:
|
||||
- runFlow:
|
||||
when:
|
||||
visible: "Sign in"
|
||||
commands:
|
||||
- tapOn: "Sign in"
|
||||
- extendedWaitUntil:
|
||||
visible: "Discover"
|
||||
timeout: 30000
|
||||
@@ -1,55 +0,0 @@
|
||||
appId: com.edde746.plezy
|
||||
---
|
||||
- retry:
|
||||
maxRetries: 1
|
||||
commands:
|
||||
- launchApp:
|
||||
appId: com.edde746.plezy
|
||||
clearState: true
|
||||
permissions:
|
||||
all: allow
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^(?:Connect to Jellyfin|Wait).*"
|
||||
timeout: 30000
|
||||
- tapOn:
|
||||
text: "Wait"
|
||||
optional: true
|
||||
- extendedWaitUntil:
|
||||
visible: "Connect to Jellyfin"
|
||||
timeout: 30000
|
||||
- tapOn: "Connect to Jellyfin"
|
||||
- extendedWaitUntil:
|
||||
visible: "Add Jellyfin server"
|
||||
timeout: 10000
|
||||
- tapOn: "(?s)Server URLs.*"
|
||||
- inputText: ${JELLYFIN_URL}
|
||||
- tapOn: "Find server"
|
||||
- extendedWaitUntil:
|
||||
visible: "Username"
|
||||
timeout: 15000
|
||||
- tapOn: "Username"
|
||||
- inputText: "maestro"
|
||||
- tapOn: "Password"
|
||||
- inputText: "maestro"
|
||||
- tapOn: "Sign in"
|
||||
- extendedWaitUntil:
|
||||
visible: "Discover"
|
||||
timeout: 30000
|
||||
- tapOn: "M"
|
||||
- tapOn:
|
||||
text: "Settings"
|
||||
below:
|
||||
text: "Profiles"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Appearance.*"
|
||||
timeout: 10000
|
||||
- tapOn: "(?s)^Appearance.*"
|
||||
- swipe:
|
||||
start: 50%, 85%
|
||||
end: 50%, 25%
|
||||
duration: 500
|
||||
- tapOn: "(?s)^Force TV mode.*"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "tv_browse_rail_selection"
|
||||
timeout: 30000
|
||||
@@ -1,57 +0,0 @@
|
||||
appId: com.edde746.plezy
|
||||
---
|
||||
# Onboarding for a device that already reports itself as a TV.
|
||||
#
|
||||
# `onboard_jellyfin_tv.yaml` exists for the phone emulator CI runs on: it
|
||||
# onboards through the handheld layout and then walks Settings to flip
|
||||
# "Force TV mode". A real Android TV box takes the TV layout from the first
|
||||
# frame, so that walk has no handheld chrome to tap and the toggle would only
|
||||
# re-assert what PlatformDetector already reports.
|
||||
- retry:
|
||||
maxRetries: 1
|
||||
commands:
|
||||
- launchApp:
|
||||
clearState: true
|
||||
permissions:
|
||||
all: allow
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^(?:Connect to Jellyfin|Wait).*"
|
||||
timeout: 30000
|
||||
- runFlow:
|
||||
when:
|
||||
visible: "Wait"
|
||||
commands:
|
||||
- tapOn: "Wait"
|
||||
- extendedWaitUntil:
|
||||
visible: "Connect to Jellyfin"
|
||||
timeout: 30000
|
||||
- tapOn: "Connect to Jellyfin"
|
||||
- extendedWaitUntil:
|
||||
visible: "Add Jellyfin server"
|
||||
timeout: 15000
|
||||
- tapOn: "(?s)Server URLs.*"
|
||||
- inputText: ${JELLYFIN_URL}
|
||||
- tapOn: "Find server"
|
||||
# A TV-sized layout offers Quick Connect first because typing on a remote is
|
||||
# slow. The code path needs a second device to approve, so fall back to the
|
||||
# username form the phone flow already uses.
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^(?:Username|Cancel)$"
|
||||
timeout: 30000
|
||||
- runFlow:
|
||||
when:
|
||||
notVisible: "Username"
|
||||
commands:
|
||||
- tapOn: "Cancel"
|
||||
- extendedWaitUntil:
|
||||
visible: "Username"
|
||||
timeout: 20000
|
||||
- tapOn: "Username"
|
||||
- inputText: "maestro"
|
||||
- tapOn: "Password"
|
||||
- inputText: "maestro"
|
||||
- tapOn: "Sign in"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "tv_browse_rail_selection"
|
||||
timeout: 45000
|
||||
@@ -1,48 +0,0 @@
|
||||
appId: com.edde746.plezy
|
||||
---
|
||||
- runFlow: ensure_onboarded.yaml
|
||||
- tapOn: "(?s)^Search.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Search movies, shows, music..."
|
||||
timeout: 10000
|
||||
- tapOn: "Search movies, shows, music..."
|
||||
- inputText: "${SAMPLE_TITLE}"
|
||||
- runFlow:
|
||||
when:
|
||||
platform: iOS
|
||||
commands:
|
||||
- pressKey: ENTER
|
||||
- runFlow:
|
||||
when:
|
||||
platform: Android
|
||||
commands:
|
||||
- hideKeyboard
|
||||
# A card announces its watch state three ways, and the codec samples keep a
|
||||
# resume position on the server once any earlier flow has played them, so the
|
||||
# partial form has to be accepted for this subflow to be re-runnable against a
|
||||
# fixture container that is not thrown away between suites.
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^${SAMPLE_TITLE}, movie, (?:watched|unwatched|[0-9]+ percent watched)$"
|
||||
timeout: 15000
|
||||
- tapOn: "(?s)^${SAMPLE_TITLE}, movie, (?:watched|unwatched|[0-9]+ percent watched)$"
|
||||
- extendedWaitUntil:
|
||||
visible: "Overview"
|
||||
timeout: 15000
|
||||
- assertVisible: "${SAMPLE_OVERVIEW}"
|
||||
- tapOn:
|
||||
text: "Play"
|
||||
waitToSettleTimeoutMs: 1000
|
||||
- extendedWaitUntil:
|
||||
notVisible: "Overview"
|
||||
timeout: 15000
|
||||
- extendedWaitUntil:
|
||||
visible: "Pause"
|
||||
timeout: 30000
|
||||
- tapOn: "Pause"
|
||||
- extendedWaitUntil:
|
||||
visible: "Play"
|
||||
timeout: 5000
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^.*Audio & Subtitles$"
|
||||
timeout: 30000
|
||||
- tapOn: "(?s)^.*Audio & Subtitles$"
|
||||
@@ -1,27 +0,0 @@
|
||||
appId: com.edde746.plezy
|
||||
---
|
||||
# Reach Settings from a signed-in Home on a phone layout.
|
||||
#
|
||||
# The profile menu is itself an overlay sheet on the MainScreen host, so a tap
|
||||
# issued while the previous route is still settling opens nothing. The guarded
|
||||
# repeat re-opens the menu instead of failing the flow, matching the pattern
|
||||
# already used by flows/07_profiles_settings.yaml.
|
||||
- extendedWaitUntil:
|
||||
visible: "Discover"
|
||||
timeout: 30000
|
||||
- repeat:
|
||||
times: 3
|
||||
while:
|
||||
notVisible: "Settings"
|
||||
commands:
|
||||
- tapOn: "(?s)^M(?:.*Profiles.*)?$"
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- tapOn:
|
||||
text: "Settings"
|
||||
below:
|
||||
text: "Profiles"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Appearance.*"
|
||||
timeout: 15000
|
||||
- assertVisible: "(?s)^Manage Libraries.*"
|
||||
@@ -1,60 +0,0 @@
|
||||
appId: com.edde746.plezy
|
||||
---
|
||||
- tapOn: "(?s)^Appearance.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Theme.*"
|
||||
timeout: 10000
|
||||
- assertVisible: "(?s).*Library Density.*"
|
||||
- back
|
||||
- assertVisible: "(?s)^Appearance.*"
|
||||
- tapOn: "(?s)^Video Playback.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Player.*"
|
||||
timeout: 10000
|
||||
- repeat:
|
||||
times: 4
|
||||
while:
|
||||
notVisible: "(?s)^Subtitles & Configuration.*Subtitle Styling.*"
|
||||
commands:
|
||||
- swipe:
|
||||
start: 50%, 80%
|
||||
end: 50%, 65%
|
||||
duration: 800
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 1500
|
||||
- assertVisible: "(?s)^Subtitles & Configuration.*Subtitle Styling.*"
|
||||
- repeat:
|
||||
times: 4
|
||||
while:
|
||||
notVisible: "(?s)^Seek & Timing.*"
|
||||
commands:
|
||||
- swipe:
|
||||
start: 50%, 80%
|
||||
end: 50%, 65%
|
||||
duration: 800
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 1500
|
||||
- assertVisible: "(?s)^Seek & Timing.*"
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Manage Libraries.*"
|
||||
timeout: 10000
|
||||
- tapOn: "(?s)^Manage Libraries.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Maestro Movies"
|
||||
timeout: 10000
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Services.*"
|
||||
timeout: 10000
|
||||
- tapOn: "(?s)^Services.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Services"
|
||||
timeout: 10000
|
||||
- assertVisible: "(?s)^Trakt.*"
|
||||
- assertVisible: "(?s)^MyAnimeList.*"
|
||||
- assertVisible: "(?s)^AniList.*"
|
||||
- assertVisible: "(?s)^Simkl.*"
|
||||
- assertVisible: "(?s)^Seerr.*"
|
||||
- back
|
||||
- assertVisible: "(?s)^Services.*"
|
||||
@@ -1,23 +0,0 @@
|
||||
appId: com.edde746.plezy
|
||||
---
|
||||
- assertVisible: "${AUDIO_TRACK}"
|
||||
- tapOn:
|
||||
text: "${AUDIO_TRACK}"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^.*Audio & Subtitles$"
|
||||
timeout: 30000
|
||||
- tapOn: "(?s)^.*Audio & Subtitles$"
|
||||
- assertVisible: "${SUBTITLE_TRACK}"
|
||||
- tapOn:
|
||||
text: "${SUBTITLE_TRACK}"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^.*Audio & Subtitles$"
|
||||
timeout: 30000
|
||||
- tapOn: "(?s)^.*Audio & Subtitles$"
|
||||
- assertVisible:
|
||||
text: "${AUDIO_TRACK}"
|
||||
selected: true
|
||||
- assertVisible:
|
||||
text: "${SUBTITLE_TRACK}"
|
||||
selected: true
|
||||
- back
|
||||
@@ -1,41 +0,0 @@
|
||||
appId: com.edde746.plezy
|
||||
---
|
||||
- assertVisible: "${AUDIO_TRACK}"
|
||||
- tapOn:
|
||||
text: "${AUDIO_TRACK}"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^.*Audio & Subtitles$"
|
||||
timeout: 30000
|
||||
- tapOn: "(?s)^.*Audio & Subtitles$"
|
||||
- repeat:
|
||||
times: 12
|
||||
while:
|
||||
notVisible: "${SUBTITLE_TRACK}"
|
||||
commands:
|
||||
- swipe:
|
||||
start: 70%, 82%
|
||||
end: 70%, 68%
|
||||
duration: 300
|
||||
- assertVisible: "${SUBTITLE_TRACK}"
|
||||
- tapOn:
|
||||
text: "${SUBTITLE_TRACK}"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^.*Audio & Subtitles$"
|
||||
timeout: 30000
|
||||
- tapOn: "(?s)^.*Audio & Subtitles$"
|
||||
- assertVisible:
|
||||
text: "${AUDIO_TRACK}"
|
||||
selected: true
|
||||
- repeat:
|
||||
times: 12
|
||||
while:
|
||||
notVisible: "${SUBTITLE_TRACK}"
|
||||
commands:
|
||||
- swipe:
|
||||
start: 70%, 82%
|
||||
end: 70%, 68%
|
||||
duration: 300
|
||||
- assertVisible:
|
||||
text: "${SUBTITLE_TRACK}"
|
||||
selected: true
|
||||
- back
|
||||
@@ -1,45 +0,0 @@
|
||||
# This file tracks properties of this Flutter project.
|
||||
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||
#
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: "077b4a4ce10a07b82caa6897f0c626f9c0a3ac90"
|
||||
channel: "stable"
|
||||
|
||||
project_type: app
|
||||
|
||||
# Tracks metadata for the flutter migrate command
|
||||
migration:
|
||||
platforms:
|
||||
- platform: root
|
||||
create_revision: 077b4a4ce10a07b82caa6897f0c626f9c0a3ac90
|
||||
base_revision: 077b4a4ce10a07b82caa6897f0c626f9c0a3ac90
|
||||
- platform: android
|
||||
create_revision: 077b4a4ce10a07b82caa6897f0c626f9c0a3ac90
|
||||
base_revision: 077b4a4ce10a07b82caa6897f0c626f9c0a3ac90
|
||||
- platform: ios
|
||||
create_revision: 077b4a4ce10a07b82caa6897f0c626f9c0a3ac90
|
||||
base_revision: 077b4a4ce10a07b82caa6897f0c626f9c0a3ac90
|
||||
- platform: linux
|
||||
create_revision: 077b4a4ce10a07b82caa6897f0c626f9c0a3ac90
|
||||
base_revision: 077b4a4ce10a07b82caa6897f0c626f9c0a3ac90
|
||||
- platform: macos
|
||||
create_revision: 077b4a4ce10a07b82caa6897f0c626f9c0a3ac90
|
||||
base_revision: 077b4a4ce10a07b82caa6897f0c626f9c0a3ac90
|
||||
- platform: web
|
||||
create_revision: 077b4a4ce10a07b82caa6897f0c626f9c0a3ac90
|
||||
base_revision: 077b4a4ce10a07b82caa6897f0c626f9c0a3ac90
|
||||
- platform: windows
|
||||
create_revision: 077b4a4ce10a07b82caa6897f0c626f9c0a3ac90
|
||||
base_revision: 077b4a4ce10a07b82caa6897f0c626f9c0a3ac90
|
||||
|
||||
# User provided section
|
||||
|
||||
# List of Local paths (relative to this file) that should be
|
||||
# ignored by the migrate tool.
|
||||
#
|
||||
# Files that are not part of the templates will be ignored by default.
|
||||
unmanaged_files:
|
||||
- 'lib/main.dart'
|
||||
- 'ios/Runner.xcodeproj/project.pbxproj'
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"indentation" : {
|
||||
"spaces" : 2
|
||||
},
|
||||
"lineLength" : 120,
|
||||
"maximumBlankLines" : 1,
|
||||
"respectsExistingLineBreaks" : true,
|
||||
"rules" : {},
|
||||
"version" : 1
|
||||
}
|
||||
-144
@@ -1,144 +0,0 @@
|
||||
# Contributing
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. Fork and clone the repository
|
||||
2. Run `flutter pub get` to install dependencies
|
||||
3. Run `scripts/codegen.sh` to generate translations and Dart model code
|
||||
4. Start developing!
|
||||
|
||||
## Development
|
||||
|
||||
- Follow Dart/Flutter conventions
|
||||
- Run `dart format .` to format Dart code (note: generated files like `*.g.dart` are excluded from CI checks)
|
||||
- Run `scripts/format_native.sh --fix` to format Kotlin, Swift, C++, C, Objective-C, and native headers
|
||||
- Run `flutter analyze` before submitting to check for issues
|
||||
- Run `scripts/run_tests.sh` to run the test suite (same as `flutter test`, but scaled to your core count)
|
||||
- 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 and native formatting standards
|
||||
- Run locally: `dart format .` to format Dart files
|
||||
- Run locally: `scripts/format_native.sh --fix` to format native 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. **Generated Code**: Ensures generated translations and model files are current
|
||||
- Run locally: `scripts/codegen.sh --check`
|
||||
|
||||
4. **Tests**: Runs unit and widget tests (when available)
|
||||
- Run locally: `scripts/run_tests.sh`
|
||||
- This is `flutter test` with `-j` set to the core count. The default is half your cores, which
|
||||
leaves most of the machine idle because the suite is dominated by per-file compilation.
|
||||
Arguments are forwarded, so `scripts/run_tests.sh test/widgets/some_test.dart` works.
|
||||
|
||||
All these checks must pass before your changes can be merged.
|
||||
|
||||
### Maestro end-to-end tests
|
||||
|
||||
Android E2E tests use [Maestro](https://maestro.mobile.dev/) against a disposable, pre-seeded Jellyfin container.
|
||||
|
||||
Prerequisites: Java 17, Flutter and Android SDK/platform tools, a running Android emulator, Docker, and the
|
||||
[Maestro CLI](https://docs.maestro.dev/getting-started/installing-maestro).
|
||||
|
||||
Run the suites from the repository root (`py -3` can replace `python3` on Windows):
|
||||
|
||||
```bash
|
||||
python3 scripts/run_maestro.py basic # Basic user flows
|
||||
python3 scripts/run_maestro.py catalog # Catalog and music flows
|
||||
python3 scripts/run_maestro.py media # Codec playback and track selection
|
||||
```
|
||||
|
||||
Run one flow with `--flow`:
|
||||
|
||||
```bash
|
||||
python3 scripts/run_maestro.py basic --flow .maestro/flows/04_search.yaml
|
||||
```
|
||||
|
||||
Use `--skip-build` to reuse the debug APK and `--skip-jellyfin-build` to reuse the Jellyfin image. Set
|
||||
`--device <adb-serial>` when multiple devices are connected; physical devices also require `--adb-reverse`.
|
||||
|
||||
Top-level flows live in `.maestro/flows/`, shared setup in `.maestro/subflows/`, and focused regressions in
|
||||
`.maestro/regression_flows/`. Automatic PR groups are declared in `scripts/run_maestro_ci.py::GROUPS`. Every top-level
|
||||
regression flow must be registered either there or in `DESTRUCTIVE_MANUAL_TARGETS`; reusable subflows are not
|
||||
independent tests. A manual-only classification must state why the flow cannot run automatically and must not be
|
||||
described as CI coverage. `.github/workflows/e2e.yml` runs only automatic targets and uploads diagnostics on failure.
|
||||
|
||||
The profile-isolation and profile-teardown regressions create and remove profile connections, so they are a destructive
|
||||
manual target rather than automatic PR coverage. Run them only against the pre-seeded Jellyfin fixture and a disposable
|
||||
emulator, using the required opt-in:
|
||||
|
||||
```bash
|
||||
python3 scripts/run_maestro_ci.py profile-regressions --disposable-emulator
|
||||
```
|
||||
|
||||
The target refuses to start without `--disposable-emulator`. Each profile flow writes to its own Jellyfin log and
|
||||
diagnostics directory under `build/maestro-profile-regressions/`.
|
||||
|
||||
### Production container image updates
|
||||
|
||||
Production images in `server/Dockerfile` and `server/docker-compose.yml` use a readable version or source-revision tag
|
||||
plus an authoritative multi-platform index digest. The adjacent `Platforms` declaration records the supported
|
||||
`linux/amd64` and `linux/arm64` variants. Never replace these references with a mutable tag or a single-platform child
|
||||
manifest.
|
||||
|
||||
Update a production image only through a reviewed change:
|
||||
|
||||
1. For the Bugs service, first record the running container's image ID, repository digest, platform, and OCI source
|
||||
revision without printing its environment. Prefer that reviewed running identity; selecting anything else is a
|
||||
service upgrade, not a routine pin refresh.
|
||||
2. Review the upstream source revision and changelog, provenance, vulnerability results, and manifest contents. Resolve
|
||||
the readable tag and digest-qualified reference independently and confirm they identify the same OCI index in two
|
||||
clean caches. The index must contain both declared platforms; provenance/attestation descriptors do not count as
|
||||
runnable platforms.
|
||||
3. Change the readable tag, full `sha256` index digest, and adjacent platform declaration together. Include the old and
|
||||
new identities, manifest/platform evidence, review findings, smoke results, and rollback notes in the change.
|
||||
4. Before changing the Bugs digest, exercise it with non-production configuration and a disposable volume. Review
|
||||
migrations, take a restorable `bugs_data` backup, then validate a cloned volume. A forward-only migration rolls back
|
||||
with the prior digest and pre-change backup, not by changing the image reference alone.
|
||||
5. Run `python3 scripts/check_container_image_pins.py`, `python3 scripts/test_check_container_image_pins.py`, and
|
||||
`(cd server && go test ./...)`. Inspect the rendered Compose configuration and rebuilt images locally without
|
||||
exposing configuration values. Do not publish or deploy from a review checkout, and never fall back to `latest` when
|
||||
a digest is unavailable.
|
||||
|
||||
## 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 "2.11.0"
|
||||
sha256 "72a6a74d2f8f4ac76d83d005ff28957b3abcc9d80d7d575710df031d12c1c8cf"
|
||||
|
||||
url "https://github.com/edde746/plezy/releases/download/#{version}/plezy-macos.dmg"
|
||||
name "Plezy"
|
||||
desc "Modern Plex and Jellyfin 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
|
||||
@@ -1,674 +0,0 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
@@ -1,207 +0,0 @@
|
||||
<h1>
|
||||
<img src="assets/plezy.png" alt="Plezy Logo" height="24" style="vertical-align: middle;" />
|
||||
Plezy
|
||||
</h1>
|
||||
|
||||
A modern client for Plex and Jellyfin on desktop, mobile, and TV. Built with Flutter for native performance and a clean interface.
|
||||
|
||||
<p>
|
||||
<a href="https://plezy.app">Website</a> ·
|
||||
<a href="https://plezy.app/#screenshots">Screenshots</a> ·
|
||||
<a href="#download">Download</a> ·
|
||||
<a href="CONTRIBUTING.md">Contributing</a> ·
|
||||
<a href="LICENSE">License</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/readme-showcase.webp" alt="Plezy mobile screenshots" width="900" />
|
||||
</p>
|
||||
|
||||
## Download
|
||||
|
||||
<a href='https://apps.apple.com/app/apple-store/id6754315964?pt=128238902&ct=GitHub&mt=8'><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&referrer=utm_source%3Dgithub%26utm_campaign%3Dreadme_badge'><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>
|
||||
<a href='https://get.microsoft.com/installer/download/9n5r1s1t68h7?referrer=appbadge&cid=github'><img height='60' alt='Get it from Microsoft' src='./assets/microsoft-badge.png'/></a>
|
||||
|
||||
| Platform | Download |
|
||||
| --- | --- |
|
||||
| macOS | [DMG (x64, arm64)](https://github.com/edde746/plezy/releases/latest/download/plezy-macos.dmg) |
|
||||
| Linux x64 | [.deb](https://github.com/edde746/plezy/releases/latest/download/plezy-linux-x64.deb) · [.rpm](https://github.com/edde746/plezy/releases/latest/download/plezy-linux-x64.rpm) · [.pkg.tar.zst](https://github.com/edde746/plezy/releases/latest/download/plezy-linux-x64.pkg.tar.zst) · [portable tar.gz](https://github.com/edde746/plezy/releases/latest/download/plezy-linux-x64.tar.gz) |
|
||||
| Linux arm64 | [.deb](https://github.com/edde746/plezy/releases/latest/download/plezy-linux-arm64.deb) · [.rpm](https://github.com/edde746/plezy/releases/latest/download/plezy-linux-arm64.rpm) · [.pkg.tar.zst](https://github.com/edde746/plezy/releases/latest/download/plezy-linux-arm64.pkg.tar.zst) · [portable tar.gz](https://github.com/edde746/plezy/releases/latest/download/plezy-linux-arm64.tar.gz) |
|
||||
|
||||
Package managers:
|
||||
|
||||
- [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
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
### <img src="assets/readme_icons/browse.svg" height="20" alt="" align="center" /> Browse & Discover
|
||||
- Libraries, collections, and playlists — video and audio
|
||||
- Discover hub — Continue Watching, Next Up, trending, and recommendations
|
||||
- Cross-server search across every connected Plex and Jellyfin server
|
||||
- Filtering, sorting, and alphabetical jump navigation
|
||||
- Folder browsing and folder playback — home-video libraries open in folder view
|
||||
- Resolution, HDR/Dolby Vision, and audio-format badges on cards and detail pages
|
||||
- Favorites and unwatched library filters[^jf]
|
||||
- Extras — trailers, deleted scenes, behind-the-scenes
|
||||
|
||||
### <img src="assets/readme_icons/explore.svg" height="20" alt="" align="center" /> Explore & Requests
|
||||
- Explore tab — watchlist, trending, popular, and recommendation rows from Plex Discover[^plex], Trakt, MyAnimeList, AniList, Simkl, and Seerr[^connect]
|
||||
- Search any connected catalog source
|
||||
- Catalog titles matched back to your own libraries by external ID
|
||||
- Seerr — request movies and shows with per-season, 4K, and advanced destination options, and see request status inline
|
||||
- Watchlist sync — add and remove titles on Plex, Trakt, MyAnimeList, AniList, and Simkl from anywhere in the app
|
||||
|
||||
### <img src="assets/readme_icons/playback.svg" height="20" alt="" align="center" /> Playback
|
||||
- Wide codec support (HEVC, AV1, VP9, and more)
|
||||
- HDR and Dolby Vision[^hdr]
|
||||
- Direct play, or transcode presets from 240p/320 kbps to 1080p/20 Mbps
|
||||
- Multi-version switching with per-version file details
|
||||
- Full ASS/SSA subtitles with customizable styling
|
||||
- Online subtitle search & download[^plex]
|
||||
- Audio & subtitle choices remembered per title, or follow the server's per-episode selections
|
||||
- Progress sync and resume
|
||||
- Auto-play next episode with skip intro / skip credits
|
||||
- Chapter navigation with thumbnail scrub previews
|
||||
- Playback speed from 0.25x to 8x, audio sync offset, sleep timer (fixed durations or end of video)
|
||||
- Video zoom 50-200% with pinch, presets, and hotkeys
|
||||
- Audio passthrough[^pass], stereo downmix with center-channel boost, and loudness normalization
|
||||
- File Info sheet — every version, file, and stream the server reports
|
||||
- Ambient lighting and GLSL shader presets[^mpv]
|
||||
- Picture-in-Picture[^pip]
|
||||
- Refresh-rate matching[^rrm]
|
||||
- External player launch (VLC, MX Player, etc.) with progress sync back[^android]
|
||||
|
||||
### <img src="assets/readme_icons/music.svg" height="20" alt="" align="center" /> Music
|
||||
- Music libraries — artist, album, and track browsing with square artwork
|
||||
- Album and artist screens with play, shuffle, and Instant Mix
|
||||
- Gapless playback with a full play queue — reorder, remove, play next, add to queue
|
||||
- Now Playing with synced lyrics[^lyrics], persistent mini-player, and sleep timer
|
||||
- Background playback with lock-screen, media-key, and notification controls[^bgaudio]
|
||||
- Offline playback of downloaded albums and tracks
|
||||
- Streaming quality presets — Original, 320, 192, or 128 kbps
|
||||
|
||||
### <img src="assets/readme_icons/live-tv.svg" height="20" alt="" align="center" /> Live TV & DVR
|
||||
- Live TV channel browsing, tuning, and favorites
|
||||
- EPG guide with What's On and per-show schedules
|
||||
- DVR recording rules, scheduled recordings, and a rememberable recording target library[^plex]
|
||||
- Multi-server Live TV support where available
|
||||
|
||||
### <img src="assets/readme_icons/downloads.svg" height="20" alt="" align="center" /> Downloads & Offline
|
||||
- Download movies, shows, and music for offline playback[^dl]
|
||||
- Background queue with pause / resume
|
||||
- Sync rules for automatic downloads, with per-show "Include Specials"
|
||||
- Offline browsing with watch state sync-back on reconnect
|
||||
|
||||
### <img src="assets/readme_icons/watch-together.svg" height="20" alt="" align="center" /> Watch Together
|
||||
- Synchronized playback with friends
|
||||
- Real-time play / pause / seek sync
|
||||
|
||||
### <img src="assets/readme_icons/integrations.svg" height="20" alt="" align="center" /> Integrations
|
||||
- Discord Rich Presence[^desktop]
|
||||
- Trakt, MyAnimeList, AniList, and Simkl — ratings, watched sync, and real-time scrobbling[^rt]
|
||||
- Plezy Remote — control desktop and TV from mobile
|
||||
- Watch Next row and tvOS Top Shelf[^shelf]
|
||||
|
||||
### <img src="assets/readme_icons/customization.svg" height="20" alt="" align="center" /> Platform & Customization
|
||||
- Desktop, mobile, and TV — full D-pad, keyboard, and gamepad support
|
||||
- Multiple servers at once — Plex and Jellyfin side by side
|
||||
- Profiles with per-profile downloads, watch state, and settings; Plex Home switching with PIN
|
||||
- Jellyfin local-server discovery, multiple URLs per server, and Quick Connect sign-in
|
||||
- TV layout options — corner spotlight backdrop, full-card artwork, and Force TV mode on desktop
|
||||
- Customizable keyboard shortcuts[^desktop]
|
||||
- Metadata and artwork editing
|
||||
- Settings import/export
|
||||
- Localized in English plus 21 translations
|
||||
|
||||
[^jf]: Jellyfin only.
|
||||
[^plex]: Plex only.
|
||||
[^connect]: Requires connecting the service under Settings > Services.
|
||||
[^hdr]: In-app HDR toggle on Windows, macOS, iOS, and tvOS. Dolby Vision on Android and Apple TV.
|
||||
[^pass]: Desktop, Android TV, and Apple TV.
|
||||
[^mpv]: Requires the mpv player backend — unavailable on iOS and tvOS, and Android defaults to ExoPlayer.
|
||||
[^pip]: Android, iOS, and macOS — not on Android TV or Apple TV.
|
||||
[^rrm]: Windows, Android, and tvOS.
|
||||
[^android]: Progress sync on Android.
|
||||
[^lyrics]: Where your server provides lyrics.
|
||||
[^bgaudio]: tvOS pauses music when the app is backgrounded.
|
||||
[^dl]: Not available on tvOS.
|
||||
[^desktop]: Desktop only.
|
||||
[^rt]: Real-time scrobbling on Trakt and Simkl; MyAnimeList and AniList update on completion.
|
||||
[^shelf]: Android TV / Fire TV and tvOS.
|
||||
|
||||
## Building from Source
|
||||
|
||||
### Prerequisites
|
||||
- Flutter SDK 3.44.0+
|
||||
- A Plex account or Jellyfin server with user credentials
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
git clone https://github.com/edde746/plezy.git
|
||||
cd plezy
|
||||
flutter pub get
|
||||
scripts/codegen.sh
|
||||
flutter run
|
||||
```
|
||||
|
||||
### Code Generation
|
||||
|
||||
After modifying model classes or other generated sources:
|
||||
|
||||
```bash
|
||||
scripts/codegen.sh
|
||||
```
|
||||
|
||||
After modifying translations:
|
||||
|
||||
```bash
|
||||
dart run slang
|
||||
```
|
||||
|
||||
### Local Checks
|
||||
|
||||
```bash
|
||||
scripts/ci_checks.sh
|
||||
```
|
||||
|
||||
To install the same pre-commit checks locally:
|
||||
|
||||
```bash
|
||||
scripts/setup_hooks.sh
|
||||
```
|
||||
|
||||
End-to-end tests (Android emulator plus a Dockerized Jellyfin fixture):
|
||||
|
||||
```bash
|
||||
python3 scripts/run_maestro.py basic
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for development workflow, formatting, tests, and translation guidelines.
|
||||
|
||||
## License
|
||||
|
||||
Plezy is licensed under [GPL-3.0](LICENSE).
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
- Built with [Flutter](https://flutter.dev)
|
||||
- Supports [Plex Media Server](https://www.plex.tv) and [Jellyfin](https://jellyfin.org)
|
||||
- Playback powered by [mpv](https://mpv.io), [MPVKit](https://github.com/mpvkit/MPVKit), Android [ExoPlayer](https://developer.android.com/media/media3/exoplayer), [libass-android](https://github.com/peerless2012/libass-android), and [libmpv-android](https://github.com/jarnedemeulemeester/libmpv-android)
|
||||
@@ -1,84 +0,0 @@
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
analyzer:
|
||||
exclude:
|
||||
- "**/*.g.dart"
|
||||
- "**/*.freezed.dart"
|
||||
|
||||
linter:
|
||||
rules:
|
||||
unawaited_futures: true
|
||||
cancel_subscriptions: true
|
||||
close_sinks: true
|
||||
use_super_parameters: true
|
||||
prefer_final_locals: true
|
||||
prefer_final_in_for_each: true
|
||||
avoid_print: true
|
||||
|
||||
# Do not register DCL as an analyzer plugin: its analyzer 10 integration crashes
|
||||
# on Linux. CI invokes the supported check-unused commands explicitly.
|
||||
dart_code_linter:
|
||||
rules-exclude:
|
||||
- "test/**"
|
||||
metrics-exclude:
|
||||
- "test/**"
|
||||
|
||||
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,11 +0,0 @@
|
||||
/.gradle
|
||||
/captures/
|
||||
/local.properties
|
||||
GeneratedPluginRegistrant.java
|
||||
.cxx/
|
||||
|
||||
# Remember to never publicly share your keystore.
|
||||
# See https://flutter.dev/to/reference-keystore
|
||||
key.properties
|
||||
**/*.keystore
|
||||
**/*.jks
|
||||
@@ -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
|
||||
@@ -1,537 +0,0 @@
|
||||
import java.io.FileInputStream
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.StandardCopyOption
|
||||
import java.security.MessageDigest
|
||||
import java.util.Properties
|
||||
import java.util.UUID
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
fun verifySha256(file: File, expected: String, identity: String) {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
file.inputStream().buffered().use { input ->
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
while (true) {
|
||||
val count = input.read(buffer)
|
||||
if (count < 0) break
|
||||
digest.update(buffer, 0, count)
|
||||
}
|
||||
}
|
||||
val actual = digest.digest().joinToString("") {
|
||||
(it.toInt() and 0xff).toString(16).padStart(2, '0')
|
||||
}
|
||||
if (actual != expected) {
|
||||
throw GradleException("SHA-256 mismatch for $identity: expected $expected, got $actual")
|
||||
}
|
||||
}
|
||||
|
||||
fun promoteDirectory(staging: File, destination: File) {
|
||||
val backup = File(destination.parentFile, "${destination.name}.backup-${UUID.randomUUID()}")
|
||||
val hadDestination = destination.exists()
|
||||
try {
|
||||
if (hadDestination) {
|
||||
Files.move(destination.toPath(), backup.toPath(), StandardCopyOption.ATOMIC_MOVE)
|
||||
}
|
||||
try {
|
||||
Files.move(staging.toPath(), destination.toPath(), StandardCopyOption.ATOMIC_MOVE)
|
||||
} catch (promotionFailure: Exception) {
|
||||
if (hadDestination && backup.exists()) {
|
||||
try {
|
||||
Files.move(backup.toPath(), destination.toPath(), StandardCopyOption.ATOMIC_MOVE)
|
||||
} catch (restoreFailure: Exception) {
|
||||
promotionFailure.addSuppressed(restoreFailure)
|
||||
}
|
||||
}
|
||||
throw promotionFailure
|
||||
}
|
||||
if (hadDestination && backup.exists() && !backup.deleteRecursively()) {
|
||||
throw GradleException("Failed to remove obsolete native artifact backup at ${backup.absolutePath}")
|
||||
}
|
||||
} finally {
|
||||
staging.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("kotlin-android")
|
||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
val mpvVersion = "v1.0.7"
|
||||
val mpvSha256 = "d55d440e587b2a9ffb91874d93069460a987be05fe72af8394849983f0df2d7a"
|
||||
val mpvDir = layout.buildDirectory.dir("libmpv").get().asFile
|
||||
val mpvAar = "libmpv-release.aar"
|
||||
val mpvUrl = "https://github.com/edde746/libmpv-android/releases/download/$mpvVersion/$mpvAar"
|
||||
|
||||
val media3Version = "1.10.1"
|
||||
val mpvFfmpegVersion = "8.0.1"
|
||||
val mpvFfmpegSourceSha256 = "05ee0b03119b45c0bdb4df654b96802e909e0a752f72e4fe3794f487229e5a41"
|
||||
val mpvFfmpegSourceUrl = "https://ffmpeg.org/releases/ffmpeg-$mpvFfmpegVersion.tar.xz"
|
||||
val mpvFfmpegDevelopmentDir = File(mpvDir, "ffmpeg-development")
|
||||
|
||||
val downloadLibmpv = tasks.register("downloadLibmpv") {
|
||||
val aar = File(mpvDir, mpvAar)
|
||||
val manifest = File(mpvDir, ".manifest")
|
||||
inputs.property("version", mpvVersion)
|
||||
inputs.property("sourceUrl", mpvUrl)
|
||||
inputs.property("sha256", mpvSha256)
|
||||
outputs.files(aar, manifest)
|
||||
doLast {
|
||||
mpvDir.parentFile.mkdirs()
|
||||
val staging = File(mpvDir.parentFile, "${mpvDir.name}.staging-${UUID.randomUUID()}")
|
||||
try {
|
||||
staging.mkdirs()
|
||||
val stagedAar = File(staging, mpvAar)
|
||||
try {
|
||||
providers.exec {
|
||||
commandLine("curl", "-sfL", mpvUrl, "-o", stagedAar.absolutePath)
|
||||
}.result.get().assertNormalExitValue()
|
||||
} catch (error: Exception) {
|
||||
throw GradleException("Failed to download $mpvAar $mpvVersion", error)
|
||||
}
|
||||
verifySha256(stagedAar, mpvSha256, "$mpvAar $mpvVersion")
|
||||
File(staging, ".manifest").writeText("version=$mpvVersion\nsha256=$mpvSha256\n")
|
||||
promoteDirectory(staging, mpvDir)
|
||||
} finally {
|
||||
staging.deleteRecursively()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract libc++_shared.so from the libmpv AAR so the app source set can package
|
||||
// it with top merge priority (see packaging { jniLibs } and sourceSets below).
|
||||
val extractMpvLibcxx = tasks.register("extractMpvLibcxx") {
|
||||
dependsOn(downloadLibmpv)
|
||||
val aar = File(mpvDir, mpvAar)
|
||||
val outDir = File(mpvDir, "libcxx")
|
||||
inputs.file(aar)
|
||||
outputs.dir(outDir)
|
||||
doLast {
|
||||
outDir.deleteRecursively() // drop stale ABIs from a previous AAR version
|
||||
outDir.mkdirs()
|
||||
providers.exec {
|
||||
commandLine(
|
||||
"unzip",
|
||||
"-q",
|
||||
"-o",
|
||||
aar.absolutePath,
|
||||
"jni/*/libc++_shared.so",
|
||||
"-d",
|
||||
outDir.absolutePath
|
||||
)
|
||||
}.result.get().assertNormalExitValue()
|
||||
}
|
||||
}
|
||||
|
||||
// Build the Media3 JNI adapter against the same shared FFmpeg libraries that
|
||||
// libmpv packages. Headers are pinned to libmpv's FFmpeg version and remain
|
||||
// build-only; the APK contains one FFmpeg implementation for both players.
|
||||
val prepareMpvFfmpegDevelopment = tasks.register("prepareMpvFfmpegDevelopment") {
|
||||
dependsOn(downloadLibmpv)
|
||||
val aar = File(mpvDir, mpvAar)
|
||||
val manifest = File(mpvFfmpegDevelopmentDir, ".manifest")
|
||||
val abis = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64")
|
||||
val libraries = listOf("avcodec", "avutil", "swresample")
|
||||
inputs.file(aar)
|
||||
inputs.property("ffmpegVersion", mpvFfmpegVersion)
|
||||
inputs.property("sourceUrl", mpvFfmpegSourceUrl)
|
||||
inputs.property("sourceSha256", mpvFfmpegSourceSha256)
|
||||
outputs.files(
|
||||
abis.flatMap { abi ->
|
||||
libraries.map { library -> File(mpvFfmpegDevelopmentDir, "native/$abi/lib$library.so") }
|
||||
}
|
||||
)
|
||||
outputs.files(
|
||||
File(mpvFfmpegDevelopmentDir, "include/libavcodec/avcodec.h"),
|
||||
File(mpvFfmpegDevelopmentDir, "include/libavutil/avconfig.h"),
|
||||
File(mpvFfmpegDevelopmentDir, "include/libswresample/swresample.h"),
|
||||
manifest
|
||||
)
|
||||
doLast {
|
||||
val staging = File(
|
||||
mpvFfmpegDevelopmentDir.parentFile,
|
||||
"${mpvFfmpegDevelopmentDir.name}.staging-${UUID.randomUUID()}"
|
||||
)
|
||||
try {
|
||||
val sourceArchive = File(staging, "ffmpeg-$mpvFfmpegVersion.tar.xz")
|
||||
val includeDir = File(staging, "include")
|
||||
val nativeDir = File(staging, "native")
|
||||
staging.mkdirs()
|
||||
try {
|
||||
providers.exec {
|
||||
commandLine("curl", "-sfL", mpvFfmpegSourceUrl, "-o", sourceArchive.absolutePath)
|
||||
}.result.get().assertNormalExitValue()
|
||||
} catch (error: Exception) {
|
||||
throw GradleException("Failed to download FFmpeg $mpvFfmpegVersion headers", error)
|
||||
}
|
||||
verifySha256(sourceArchive, mpvFfmpegSourceSha256, "FFmpeg $mpvFfmpegVersion source")
|
||||
|
||||
val extractedSource = File(staging, "source").apply { mkdirs() }
|
||||
try {
|
||||
providers.exec {
|
||||
commandLine(
|
||||
"tar",
|
||||
"-xJf",
|
||||
sourceArchive.absolutePath,
|
||||
"--strip-components=1",
|
||||
"-C",
|
||||
extractedSource.absolutePath
|
||||
)
|
||||
}.result.get().assertNormalExitValue()
|
||||
} catch (error: Exception) {
|
||||
throw GradleException("Failed to extract FFmpeg $mpvFfmpegVersion headers", error)
|
||||
}
|
||||
listOf("libavcodec", "libavutil", "libswresample").forEach { library ->
|
||||
project.copy {
|
||||
from(File(extractedSource, library)) {
|
||||
include("*.h")
|
||||
}
|
||||
into(File(includeDir, library))
|
||||
}
|
||||
}
|
||||
File(includeDir, "libavutil/avconfig.h").writeText(
|
||||
"""
|
||||
|/* Generated for Plezy's little-endian Android ABIs. */
|
||||
|#ifndef AVUTIL_AVCONFIG_H
|
||||
|#define AVUTIL_AVCONFIG_H
|
||||
|#define AV_HAVE_BIGENDIAN 0
|
||||
|#define AV_HAVE_FAST_UNALIGNED 0
|
||||
|#endif /* AVUTIL_AVCONFIG_H */
|
||||
|
|
||||
""".trimMargin()
|
||||
)
|
||||
|
||||
project.copy {
|
||||
from(zipTree(aar)) {
|
||||
include(
|
||||
"jni/*/libavcodec.so",
|
||||
"jni/*/libavutil.so",
|
||||
"jni/*/libswresample.so"
|
||||
)
|
||||
eachFile {
|
||||
path = path.removePrefix("jni/")
|
||||
}
|
||||
}
|
||||
includeEmptyDirs = false
|
||||
into(nativeDir)
|
||||
}
|
||||
|
||||
val missing = abis.flatMap { abi ->
|
||||
libraries.map { library -> File(nativeDir, "$abi/lib$library.so") }
|
||||
}.filterNot(File::isFile)
|
||||
if (missing.isNotEmpty()) {
|
||||
throw GradleException(
|
||||
"libmpv $mpvVersion is missing FFmpeg libraries: ${missing.joinToString { it.relativeTo(staging).path }}"
|
||||
)
|
||||
}
|
||||
File(staging, ".manifest").writeText(
|
||||
"mpv=$mpvVersion\nffmpeg=$mpvFfmpegVersion\nsourceSha256=$mpvFfmpegSourceSha256\n"
|
||||
)
|
||||
sourceArchive.delete()
|
||||
extractedSource.deleteRecursively()
|
||||
promoteDirectory(staging, mpvFfmpegDevelopmentDir)
|
||||
} finally {
|
||||
staging.deleteRecursively()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val doviVersion = "2.3.1"
|
||||
val doviDir = layout.buildDirectory.dir("libdovi").get().asFile
|
||||
val doviArtifacts = mapOf(
|
||||
"arm64-v8a" to Pair(
|
||||
"aarch64-linux-android",
|
||||
"9d2983fc86f2f9e6da54c3c84ba8ea3a528690619f312ff4620198071b84e9ae"
|
||||
),
|
||||
"armeabi-v7a" to Pair(
|
||||
"armv7-linux-androideabi",
|
||||
"ed6fec8bf744e41c661b97f5fc4bf1197ebe9b09a140cbde369728e790ee3a68"
|
||||
),
|
||||
"x86" to Pair(
|
||||
"i686-linux-android",
|
||||
"50f0a5606e617dff8976b9e7930a23272f4804882a35a6f0f2b2f2d3f8ed7135"
|
||||
),
|
||||
"x86_64" to Pair(
|
||||
"x86_64-linux-android",
|
||||
"eba59678f89b792f5c6f802962e237542fe8328f6aa03a0a90ee77353dac3194"
|
||||
)
|
||||
)
|
||||
val doviBaseUrl = "https://github.com/edde746/libdovi-builds/releases/download/v$doviVersion"
|
||||
|
||||
val downloadLibdovi = tasks.register("downloadLibdovi") {
|
||||
val manifest = File(doviDir, ".manifest")
|
||||
inputs.property("version", doviVersion)
|
||||
inputs.property("baseUrl", doviBaseUrl)
|
||||
doviArtifacts.forEach { (abi, artifact) ->
|
||||
inputs.property("$abi.triple", artifact.first)
|
||||
inputs.property("$abi.sha256", artifact.second)
|
||||
inputs.property("$abi.sourceUrl", "$doviBaseUrl/libdovi-${artifact.first}.tar.gz")
|
||||
}
|
||||
outputs.files(doviArtifacts.keys.map { abi -> File(doviDir, "$abi/lib/libdovi.a") } + manifest)
|
||||
doLast {
|
||||
doviDir.parentFile.mkdirs()
|
||||
val staging = File(doviDir.parentFile, "${doviDir.name}.staging-${UUID.randomUUID()}")
|
||||
try {
|
||||
staging.mkdirs()
|
||||
val downloads = File(staging, ".downloads").apply { mkdirs() }
|
||||
doviArtifacts.forEach { (abi, artifact) ->
|
||||
val (triple, expectedSha256) = artifact
|
||||
val archiveName = "libdovi-$triple.tar.gz"
|
||||
val archive = File(downloads, archiveName)
|
||||
val sourceUrl = "$doviBaseUrl/$archiveName"
|
||||
try {
|
||||
providers.exec {
|
||||
commandLine("curl", "-sfL", sourceUrl, "-o", archive.absolutePath)
|
||||
}.result.get().assertNormalExitValue()
|
||||
} catch (error: Exception) {
|
||||
throw GradleException("Failed to download $archiveName v$doviVersion", error)
|
||||
}
|
||||
verifySha256(archive, expectedSha256, "$archiveName v$doviVersion")
|
||||
|
||||
val outDir = File(staging, "$abi/lib").apply { mkdirs() }
|
||||
try {
|
||||
providers.exec {
|
||||
commandLine("tar", "-xzf", archive.absolutePath, "-C", outDir.absolutePath)
|
||||
}.result.get().assertNormalExitValue()
|
||||
} catch (error: Exception) {
|
||||
throw GradleException("Failed to extract $archiveName", error)
|
||||
}
|
||||
if (!File(outDir, "libdovi.a").isFile) {
|
||||
throw GradleException("$archiveName did not contain the expected libdovi.a")
|
||||
}
|
||||
}
|
||||
if (!downloads.deleteRecursively()) {
|
||||
throw GradleException("Failed to clean staged libdovi archives")
|
||||
}
|
||||
val manifestText = buildString {
|
||||
append("version=$doviVersion\n")
|
||||
doviArtifacts.forEach { (abi, artifact) ->
|
||||
append("$abi=${artifact.first},${artifact.second}\n")
|
||||
}
|
||||
}
|
||||
File(staging, ".manifest").writeText(manifestText)
|
||||
promoteDirectory(staging, doviDir)
|
||||
} finally {
|
||||
staging.deleteRecursively()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.edde746.plezy"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
buildToolsVersion = "36.1.0"
|
||||
ndkVersion = "29.0.14206865"
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
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
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
arguments += listOf(
|
||||
"-DDOVI_ENABLE_LIBDOVI=ON",
|
||||
"-DDOVI_LIBDOVI_PREBUILT_ROOT=${doviDir.absolutePath}",
|
||||
"-DMPV_FFMPEG_ROOT=${mpvFfmpegDevelopmentDir.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")
|
||||
version = "4.1.2"
|
||||
}
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
val keystoreProperties = Properties()
|
||||
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
|
||||
|
||||
keyAlias = keystoreProperties["keyAlias"] as String
|
||||
keyPassword = keystoreProperties["keyPassword"] as String
|
||||
storeFile = file(keystoreProperties["storeFile"] as String)
|
||||
storePassword = keystoreProperties["storePassword"] as String
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// Only use release signing if key.properties exists (not in CI/CD)
|
||||
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
// If key.properties doesn't exist, it will use debug signing for CI builds
|
||||
ndk {
|
||||
debugSymbolLevel = "FULL"
|
||||
}
|
||||
}
|
||||
|
||||
// Instrumentation target that runs R8 (see testBuildType below).
|
||||
//
|
||||
// R8 only ever ran on `release`, so every gate in this repository exercised code
|
||||
// the shipped APK does not contain: reflective lookups, JNI callbacks and native
|
||||
// library loading can all break under shrinking while every debug check passes.
|
||||
// #1703 shipped that way — DefaultRenderersFactory's Class.forName for the bundled
|
||||
// FFmpeg audio renderer failed in release builds only.
|
||||
//
|
||||
// Inherits release's minification and keep rules (the Flutter plugin has already
|
||||
// installed them by the time this block runs) but stays debuggable and debug-signed,
|
||||
// so it is an ordinary test artifact and never a publishable one. Debuggable also
|
||||
// makes the Flutter plugin treat it as debug mode, so it uses debug Dart artifacts.
|
||||
create("minified") {
|
||||
initWith(getByName("release"))
|
||||
isDebuggable = true
|
||||
// Resource shrinking is orthogonal to the reachability this variant guards and
|
||||
// would only slow the instrumentation build down.
|
||||
isShrinkResources = false
|
||||
testProguardFiles("proguard-test-rules.pro")
|
||||
proguardFile("proguard-instrumentation-rules.pro")
|
||||
// Release has no signing config unless key.properties exists, which would leave
|
||||
// this variant unsigned and uninstallable in CI.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
// Plugin subprojects only publish debug and release variants.
|
||||
matchingFallbacks += listOf("debug", "release")
|
||||
ndk {
|
||||
debugSymbolLevel = "NONE"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Instrumentation normally runs against `debug`; the R8 reachability gate opts into the
|
||||
// minified variant with -Pplezy.testBuildType=minified. Only one build type can host
|
||||
// androidTest, and the existing playback suites need media3 builder APIs the app itself
|
||||
// never calls — which R8 legitimately shrinks — so they stay on debug.
|
||||
testBuildType = (findProperty("plezy.testBuildType") as String?) ?: "debug"
|
||||
|
||||
packaging {
|
||||
jniLibs {
|
||||
// pickFirst only suppresses the duplicate libc++ merge error; the
|
||||
// sourceSets rule below makes libmpv's newer runtime win for
|
||||
// std::from_chars<float>, while older native consumers remain ABI-compatible.
|
||||
pickFirsts.add("lib/*/libc++_shared.so")
|
||||
}
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
getByName("main") {
|
||||
// PROJECT-scope jniLibs merge ahead of subprojects/AARs, so dependency
|
||||
// order cannot accidentally select the older libc++ copy.
|
||||
jniLibs.srcDir(File(mpvDir, "libcxx/jni"))
|
||||
}
|
||||
}
|
||||
|
||||
lint {
|
||||
// Enforce the app-owned minSdk boundary without auditing upstream AndroidX.
|
||||
checkDependencies = false
|
||||
checkOnly += setOf("NewApi")
|
||||
}
|
||||
}
|
||||
|
||||
// BackgroundWorkDiagnostics routes users to background_downloader's private
|
||||
// notification channel. Fail the build if an upstream ref changes that ID.
|
||||
val verifyBackgroundDownloaderNotificationChannel = tasks.register("verifyBackgroundDownloaderNotificationChannel") {
|
||||
val expectedChannelId = "background_downloader"
|
||||
val downloaderProject = rootProject.findProject(":background_downloader")
|
||||
val notificationsSource = downloaderProject?.projectDir?.resolve(
|
||||
"src/main/kotlin/com/bbflight/background_downloader/Notifications.kt"
|
||||
)
|
||||
notificationsSource?.let(inputs::file)
|
||||
doLast {
|
||||
if (notificationsSource == null) {
|
||||
logger.lifecycle("Skipping downloader channel verification: Flutter plugin project is not configured")
|
||||
return@doLast
|
||||
}
|
||||
val actualChannelId = Regex(
|
||||
"""private const val notificationChannelId\s*=\s*"([^"]+)""""
|
||||
).find(notificationsSource.readText())?.groupValues?.get(1)
|
||||
?: throw GradleException("Could not locate background_downloader's notification channel ID")
|
||||
if (actualChannelId != expectedChannelId) {
|
||||
throw GradleException(
|
||||
"background_downloader channel ID changed from $expectedChannelId to $actualChannelId; " +
|
||||
"update BackgroundWorkDiagnostics and its tests"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
tasks.named("preBuild").configure {
|
||||
dependsOn(verifyBackgroundDownloaderNotificationChannel)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.JVM_17)
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
|
||||
tasks.matching { it.name.contains("CMake") || it.name.contains("externalNative") }.configureEach {
|
||||
dependsOn(downloadLibdovi, prepareMpvFfmpegDevelopment)
|
||||
}
|
||||
|
||||
tasks.matching { it.name.startsWith("pre") && it.name.endsWith("Build") }.configureEach {
|
||||
dependsOn(downloadLibmpv, extractMpvLibcxx, prepareMpvFfmpegDevelopment)
|
||||
}
|
||||
// Gradle snapshots jniLibs source dirs before task execution; this keeps the
|
||||
// extracted libmpv libc++ directory present during input discovery.
|
||||
tasks.matching { it.name.startsWith("merge") && it.name.endsWith("JniLibFolders") }.configureEach {
|
||||
dependsOn(extractMpvLibcxx)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(files(File(mpvDir, mpvAar)))
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0")
|
||||
|
||||
// Android TV Watch Next integration
|
||||
implementation("androidx.tvprovider:tvprovider:1.1.0")
|
||||
|
||||
// Media3 ExoPlayer for Android
|
||||
implementation("androidx.media3:media3-decoder:$media3Version")
|
||||
implementation("androidx.media3:media3-exoplayer:$media3Version")
|
||||
implementation("androidx.media3:media3-exoplayer-hls:$media3Version")
|
||||
implementation("androidx.media3:media3-ui:$media3Version")
|
||||
implementation("androidx.media3:media3-common:$media3Version")
|
||||
|
||||
// Cronet for HTTP/2 multiplexing + better connection management
|
||||
implementation("androidx.media3:media3-datasource-cronet:$media3Version")
|
||||
implementation("org.chromium.net:cronet-embedded:143.7445.0")
|
||||
|
||||
// Keeping libass in-project lets its static core share the app's native
|
||||
// packaging rules.
|
||||
implementation(project(":libass"))
|
||||
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
// Real android.util.* implementations for tests exercising media3 classes
|
||||
// (MatroskaExtractor uses SparseArray, which is a no-op stub on plain JVM)
|
||||
testImplementation("org.robolectric:robolectric:4.16.1")
|
||||
androidTestImplementation("androidx.test:runner:1.7.0")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.3.0")
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
# Applied only to the `minified` variant, which exists to run R8 over the app under test.
|
||||
#
|
||||
# The instrumentation runner is loaded through the tested app's class loader, and its
|
||||
# supertypes resolve from the app APK. Shrinking androidx.test there leaves the harness
|
||||
# with an AndroidJUnitRunner it cannot link, and the run dies with ClassNotFoundException
|
||||
# before a single test starts — reported as `tests="0"`, which is easy to mistake for a
|
||||
# passing gate. No shipped build includes this file.
|
||||
-keep class androidx.test.** { *; }
|
||||
-dontwarn androidx.test.**
|
||||
Vendored
-20
@@ -1,20 +0,0 @@
|
||||
# Flutter turns minification on for every release build (FlutterPlugin sets
|
||||
# releaseBuildType.isMinifyEnabled), and appends this file when it exists. Anything the
|
||||
# app reaches only by name — reflection or JNI — therefore needs an explicit keep here.
|
||||
|
||||
# The bundled Media3 FFmpeg audio decoder (ALAC, DTS, DTS-HD, TrueHD, ...).
|
||||
#
|
||||
# DefaultRenderersFactory instantiates FfmpegAudioRenderer through Class.forName and no
|
||||
# app code references it, so R8 shrinks the class away; media3's own consumer rules only
|
||||
# -keepclassmembers its constructor, which neither keeps the class nor pins its name.
|
||||
# ffmpeg_jni.cc separately resolves FfmpegAudioDecoder and its growOutputBuffer callback
|
||||
# by name in JNI_OnLoad, and returns JNI_ERR when either is missing, which fails the whole
|
||||
# System.loadLibrary("ffmpegJNI") call.
|
||||
#
|
||||
# Without these keeps a release build silently loses every codec this decoder adds:
|
||||
# TrueHD/DTS-HD land on MediaCodecAudioRenderer, which has no decoder for them, and
|
||||
# playback bails to the mpv fallback and loses ExoPlayer's Dolby Vision handling (#1703).
|
||||
-keep class androidx.media3.decoder.ffmpeg.** { *; }
|
||||
|
||||
# growOutputBuffer's JNI descriptor names this type, so it may not be renamed either.
|
||||
-keep class androidx.media3.decoder.SimpleDecoderOutputBuffer { *; }
|
||||
@@ -1,11 +0,0 @@
|
||||
# Shrinker rules for the instrumentation APK of the `minified` variant.
|
||||
#
|
||||
# That variant exists to run R8 over the app under test, not over the harness. AGP
|
||||
# applies the app's rules to the test APK too, which deletes the instrumentation runner
|
||||
# and every test class the runner resolves by name — the run then dies with
|
||||
# ClassNotFoundException before a single test starts. The harness is never shipped, so
|
||||
# it has nothing to gain from shrinking.
|
||||
-dontshrink
|
||||
-dontoptimize
|
||||
# The runner resolves the instrumentation class and every -e class filter by name.
|
||||
-dontobfuscate
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
-160
@@ -1,160 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Plezy contributors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package androidx.media3.decoder.ffmpeg;
|
||||
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import android.content.Context;
|
||||
import android.net.Uri;
|
||||
import android.os.Handler;
|
||||
import android.os.HandlerThread;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.media3.common.MediaItem;
|
||||
import androidx.media3.common.PlaybackException;
|
||||
import androidx.media3.common.Player;
|
||||
import androidx.media3.datasource.DefaultDataSource;
|
||||
import androidx.media3.exoplayer.ExoPlayer;
|
||||
import androidx.media3.exoplayer.Renderer;
|
||||
import androidx.media3.exoplayer.RenderersFactory;
|
||||
import androidx.media3.exoplayer.audio.DefaultAudioSink;
|
||||
import androidx.media3.exoplayer.source.MediaSource;
|
||||
import androidx.media3.exoplayer.source.ProgressiveMediaSource;
|
||||
import androidx.media3.extractor.DefaultExtractorsFactory;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
import androidx.test.platform.app.InstrumentationRegistry;
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public final class PlezyFfmpegPlaybackTest {
|
||||
private static final String[] FIXTURES = {
|
||||
"ffmpeg/stereo.flac",
|
||||
"ffmpeg/surround_5_1.flac",
|
||||
"ffmpeg/surround_7_1.flac",
|
||||
"ffmpeg/planar_5_1.m4a",
|
||||
"ffmpeg/surround_5_1_eac3.mka",
|
||||
"ffmpeg/surround_5_1_dts.mka",
|
||||
"ffmpeg/surround_5_1_truehd.mka"
|
||||
};
|
||||
|
||||
@Test
|
||||
public void sharedDecoderUsesLibmpvFfmpegAndPlaysAllFixtures() throws Exception {
|
||||
assertTrue("FFmpeg JNI library is unavailable", FfmpegLibrary.isAvailable());
|
||||
String version = FfmpegLibrary.getVersion();
|
||||
assertTrue("Expected libmpv's FFmpeg 8, got " + version, version != null && version.startsWith("Lavc62."));
|
||||
for (String fixture : FIXTURES) {
|
||||
playToEnd(fixture);
|
||||
}
|
||||
}
|
||||
|
||||
private static void playToEnd(String fixture) throws Exception {
|
||||
Context instrumentationContext = InstrumentationRegistry.getInstrumentation().getContext();
|
||||
Context context = InstrumentationRegistry.getInstrumentation().getTargetContext();
|
||||
File fixtureFile = copyFixture(instrumentationContext, context, fixture);
|
||||
HandlerThread playbackThread = new HandlerThread("ffmpeg-test-player");
|
||||
playbackThread.start();
|
||||
Handler handler = new Handler(playbackThread.getLooper());
|
||||
CountDownLatch completed = new CountDownLatch(1);
|
||||
AtomicReference<ExoPlayer> playerReference = new AtomicReference<>();
|
||||
AtomicReference<Throwable> errorReference = new AtomicReference<>();
|
||||
|
||||
handler.post(
|
||||
() -> {
|
||||
try {
|
||||
RenderersFactory renderersFactory =
|
||||
(eventHandler,
|
||||
videoRendererEventListener,
|
||||
audioRendererEventListener,
|
||||
textRendererOutput,
|
||||
metadataRendererOutput) ->
|
||||
new Renderer[] {
|
||||
new FfmpegAudioRenderer(
|
||||
eventHandler, audioRendererEventListener, new DefaultAudioSink.Builder().build())
|
||||
};
|
||||
ExoPlayer player =
|
||||
new ExoPlayer.Builder(context, renderersFactory)
|
||||
.setLooper(playbackThread.getLooper())
|
||||
.build();
|
||||
playerReference.set(player);
|
||||
player.addListener(
|
||||
new Player.Listener() {
|
||||
@Override
|
||||
public void onPlayerError(PlaybackException error) {
|
||||
errorReference.set(error);
|
||||
completed.countDown();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPlaybackStateChanged(@Player.State int playbackState) {
|
||||
if (playbackState == Player.STATE_ENDED) {
|
||||
completed.countDown();
|
||||
}
|
||||
}
|
||||
});
|
||||
MediaSource source =
|
||||
new ProgressiveMediaSource.Factory(
|
||||
new DefaultDataSource.Factory(context), new DefaultExtractorsFactory())
|
||||
.createMediaSource(MediaItem.fromUri(Uri.fromFile(fixtureFile)));
|
||||
player.setMediaSource(source);
|
||||
player.prepare();
|
||||
player.play();
|
||||
} catch (Throwable error) {
|
||||
errorReference.set(error);
|
||||
completed.countDown();
|
||||
}
|
||||
});
|
||||
|
||||
boolean finished = completed.await(20, TimeUnit.SECONDS);
|
||||
CountDownLatch released = new CountDownLatch(1);
|
||||
handler.post(
|
||||
() -> {
|
||||
@Nullable ExoPlayer player = playerReference.get();
|
||||
if (player != null) player.release();
|
||||
playbackThread.quitSafely();
|
||||
released.countDown();
|
||||
});
|
||||
boolean teardownFinished = released.await(5, TimeUnit.SECONDS);
|
||||
playbackThread.join(5000);
|
||||
boolean fixtureDeleted = fixtureFile.delete();
|
||||
|
||||
assertTrue("Player teardown timed out for " + fixture, teardownFinished);
|
||||
assertTrue("Playback timed out for " + fixture, finished);
|
||||
assertNull("Playback failed for " + fixture, errorReference.get());
|
||||
assertTrue("Fixture cleanup failed for " + fixture, fixtureDeleted);
|
||||
}
|
||||
|
||||
private static File copyFixture(
|
||||
Context instrumentationContext, Context targetContext, String fixture) throws Exception {
|
||||
File output = File.createTempFile("ffmpeg-fixture-", null, targetContext.getCacheDir());
|
||||
try (InputStream input = instrumentationContext.getAssets().open(fixture);
|
||||
OutputStream sink = new FileOutputStream(output)) {
|
||||
byte[] buffer = new byte[8192];
|
||||
int count;
|
||||
while ((count = input.read(buffer)) != -1) {
|
||||
sink.write(buffer, 0, count);
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
}
|
||||
-72
@@ -1,72 +0,0 @@
|
||||
package androidx.media3.decoder.ffmpeg
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.media3.common.MimeTypes
|
||||
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||
import androidx.media3.exoplayer.audio.AudioRendererEventListener
|
||||
import androidx.media3.exoplayer.video.VideoRendererEventListener
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.edde746.plezy.exoplayer.PlezyRenderersFactory
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* Asserts the bundled FFmpeg audio decoder is reachable the way production reaches it.
|
||||
*
|
||||
* Every path here is name-based, so R8 can sever it while the code still compiles and
|
||||
* every debug check passes. #1703 shipped exactly that: the shrinker dropped
|
||||
* FfmpegAudioRenderer and FfmpegAudioDecoder.growOutputBuffer, TrueHD and DTS-HD lost
|
||||
* their only decoder, and 4K Dolby Vision files bailed to the mpv fallback.
|
||||
*
|
||||
* Run this against the `minified` build type (`-Pplezy.testBuildType=minified`); on an
|
||||
* unminified variant it can only ever pass. Deliberately touches no ExoPlayer builder
|
||||
* API, so no keep rule beyond the ones under test has to exist for it to run.
|
||||
*
|
||||
* Emptying `proguard-rules.pro` was verified to fail
|
||||
* [nativeLibraryLoadsAndReportsTheCodecsOnlyItCanDecode]; the renderer-list assertion
|
||||
* kept passing, because something else in this variant still retains that class. Treat
|
||||
* the JNI assertion as the load-bearing one, and `scripts/check_shrinker_rules.py` as the
|
||||
* guard for the renderer's own keep.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class FfmpegDecoderReachabilityTest {
|
||||
|
||||
@Test
|
||||
fun productionRendererListIncludesTheFfmpegAudioRenderer() {
|
||||
// Goes through the app's own factory rather than repeating media3's Class.forName:
|
||||
// the instrumentation APK shares a class loader with the app, so a direct reflective
|
||||
// lookup can resolve a copy the harness carries even when the app APK lost its own.
|
||||
// DefaultRenderersFactory swallows ClassNotFoundException as "built without the
|
||||
// extension", so a shrunk renderer leaves no trace but missing codecs.
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
val factory = PlezyRenderersFactory(context)
|
||||
.setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON)
|
||||
val handler = Handler(Looper.getMainLooper())
|
||||
|
||||
val names = factory.createRenderers(
|
||||
handler,
|
||||
object : VideoRendererEventListener {},
|
||||
object : AudioRendererEventListener {},
|
||||
{ },
|
||||
{ }
|
||||
).map { it.name }
|
||||
|
||||
assertTrue("FfmpegAudioRenderer missing from $names", names.contains("FfmpegAudioRenderer"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nativeLibraryLoadsAndReportsTheCodecsOnlyItCanDecode() {
|
||||
// isAvailable() covers the whole JNI handshake: the shared library loads, JNI_OnLoad
|
||||
// resolves FfmpegAudioDecoder by name, and GetMethodID finds growOutputBuffer with a
|
||||
// descriptor naming SimpleDecoderOutputBuffer. Any of those renamed or shrunk away
|
||||
// makes this false.
|
||||
assertTrue("FFmpeg JNI library is unavailable", FfmpegLibrary.isAvailable())
|
||||
|
||||
// The formats MediaCodec has no decoder for on the affected devices.
|
||||
assertTrue("no truehd decoder", FfmpegLibrary.supportsFormat(MimeTypes.AUDIO_TRUEHD))
|
||||
assertTrue("no dts-hd decoder", FfmpegLibrary.supportsFormat(MimeTypes.AUDIO_DTS_HD))
|
||||
}
|
||||
}
|
||||
-149
@@ -1,149 +0,0 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MimeTypes
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.audio.ChannelMixingMatrix
|
||||
import androidx.media3.datasource.DefaultDataSource
|
||||
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.source.ProgressiveMediaSource
|
||||
import androidx.media3.extractor.DefaultExtractorsFactory
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import java.io.File
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class PlezyAudioModePlaybackTest {
|
||||
private enum class AudioMode {
|
||||
PASSTHROUGH_ALLOWED,
|
||||
FORCE_DECODED,
|
||||
DOWNMIX_NORMALIZED,
|
||||
DOWNMIX_UNNORMALIZED,
|
||||
NORMALIZATION
|
||||
}
|
||||
|
||||
@Test
|
||||
fun appAudioPipelinePlaysAcrossOutputModes() {
|
||||
playToEnd("ffmpeg/surround_5_1_dts.mka", AudioMode.PASSTHROUGH_ALLOWED)
|
||||
playToEnd("ffmpeg/surround_5_1_truehd.mka", AudioMode.FORCE_DECODED)
|
||||
playToEnd("ffmpeg/surround_5_1.flac", AudioMode.DOWNMIX_NORMALIZED)
|
||||
playToEnd("ffmpeg/surround_7_1.flac", AudioMode.DOWNMIX_UNNORMALIZED)
|
||||
playToEnd("ffmpeg/surround_5_1_eac3.mka", AudioMode.NORMALIZATION)
|
||||
}
|
||||
|
||||
private fun playToEnd(fixture: String, mode: AudioMode) {
|
||||
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
val context = instrumentation.targetContext
|
||||
val fixtureFile = copyFixture(instrumentation.context, context, fixture)
|
||||
val playbackThread = HandlerThread("plezy-audio-mode-test").apply { start() }
|
||||
val handler = Handler(playbackThread.looper)
|
||||
val completed = CountDownLatch(1)
|
||||
val playerReference = AtomicReference<ExoPlayer>()
|
||||
val errorReference = AtomicReference<Throwable>()
|
||||
val outputPolicyConsulted = AtomicBoolean(false)
|
||||
val normalizationAttachAttempted = AtomicBoolean(false)
|
||||
val normalization = AudioNormalizationEffect { _, _, _ -> }
|
||||
val downmixActive = AtomicBoolean(false)
|
||||
|
||||
handler.post {
|
||||
try {
|
||||
val factory = PlezyRenderersFactory(context).apply {
|
||||
setEnableDecoderFallback(true)
|
||||
setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON)
|
||||
shouldBlockDirectAudioOutput = { format ->
|
||||
val encoded = format.sampleMimeType != null && format.sampleMimeType != MimeTypes.AUDIO_RAW
|
||||
if (encoded) outputPolicyConsulted.set(true)
|
||||
encoded && mode != AudioMode.PASSTHROUGH_ALLOWED
|
||||
}
|
||||
if (mode == AudioMode.DOWNMIX_NORMALIZED || mode == AudioMode.DOWNMIX_UNNORMALIZED) {
|
||||
val normalize = mode == AudioMode.DOWNMIX_NORMALIZED
|
||||
for (channelCount in DownmixMatrices.MIN_DOWNMIX_INPUT_CHANNELS..DownmixMatrices.MAX_DOWNMIX_INPUT_CHANNELS) {
|
||||
val coefficients = DownmixMatrices.stereoCoefficients(channelCount, centerBoostDb = 6, normalize = normalize)!!
|
||||
channelMixProcessor.putChannelMixingMatrix(ChannelMixingMatrix(channelCount, 2, coefficients))
|
||||
}
|
||||
}
|
||||
}
|
||||
val player = ExoPlayer.Builder(context, factory)
|
||||
.setLooper(playbackThread.looper)
|
||||
.build()
|
||||
playerReference.set(player)
|
||||
player.addListener(object : Player.Listener {
|
||||
override fun onAudioSessionIdChanged(audioSessionId: Int) {
|
||||
if (mode == AudioMode.NORMALIZATION) {
|
||||
normalizationAttachAttempted.set(true)
|
||||
normalization.attach(audioSessionId, channelCount = 6)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlayerError(error: PlaybackException) {
|
||||
errorReference.set(error)
|
||||
completed.countDown()
|
||||
}
|
||||
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
if (playbackState == Player.STATE_ENDED) {
|
||||
downmixActive.set(factory.channelMixProcessor.isActive)
|
||||
completed.countDown()
|
||||
}
|
||||
}
|
||||
})
|
||||
val source = ProgressiveMediaSource.Factory(
|
||||
DefaultDataSource.Factory(context),
|
||||
DefaultExtractorsFactory()
|
||||
).createMediaSource(MediaItem.fromUri(Uri.fromFile(fixtureFile)))
|
||||
player.setMediaSource(source)
|
||||
player.prepare()
|
||||
player.play()
|
||||
} catch (error: Throwable) {
|
||||
errorReference.set(error)
|
||||
completed.countDown()
|
||||
}
|
||||
}
|
||||
|
||||
val finished = completed.await(20, TimeUnit.SECONDS)
|
||||
val released = CountDownLatch(1)
|
||||
handler.post {
|
||||
playerReference.get()?.release()
|
||||
normalization.release()
|
||||
playbackThread.quitSafely()
|
||||
released.countDown()
|
||||
}
|
||||
val teardownFinished = released.await(5, TimeUnit.SECONDS)
|
||||
playbackThread.join(5_000)
|
||||
val fixtureDeleted = fixtureFile.delete()
|
||||
|
||||
assertTrue("Player teardown timed out for $mode / $fixture", teardownFinished)
|
||||
assertTrue("Playback timed out for $mode / $fixture", finished)
|
||||
assertNull("Playback failed for $mode / $fixture", errorReference.get())
|
||||
assertTrue("Audio output policy was not consulted for $mode / $fixture", outputPolicyConsulted.get())
|
||||
if (mode == AudioMode.DOWNMIX_NORMALIZED || mode == AudioMode.DOWNMIX_UNNORMALIZED) {
|
||||
assertTrue("Downmix processor was inactive for $mode / $fixture", downmixActive.get())
|
||||
}
|
||||
if (mode == AudioMode.NORMALIZATION) {
|
||||
assertTrue("Normalization did not receive an audio session", normalizationAttachAttempted.get())
|
||||
}
|
||||
assertTrue("Fixture cleanup failed for $mode / $fixture", fixtureDeleted)
|
||||
}
|
||||
|
||||
private fun copyFixture(instrumentationContext: Context, targetContext: Context, fixture: String): File {
|
||||
val output = File.createTempFile("audio-mode-fixture-", null, targetContext.cacheDir)
|
||||
instrumentationContext.assets.open(fixture).use { input ->
|
||||
output.outputStream().use(input::copyTo)
|
||||
}
|
||||
return output
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
@@ -1,143 +0,0 @@
|
||||
<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"/>
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
|
||||
|
||||
|
||||
<!-- PIP and media session foreground service permissions -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
|
||||
<uses-permission android:name="android.permission.REORDER_TASKS"/>
|
||||
|
||||
<!-- 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" />
|
||||
|
||||
|
||||
<!-- 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"/>
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||
<!-- 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:networkSecurityConfig="@xml/network_security_config"
|
||||
android:usesCleartextTraffic="true">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:taskAffinity=""
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:supportsPictureInPicture="true"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|navigation|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||
the Android process has started. This theme is visible to the user
|
||||
while the Flutter UI initializes. After that, this theme continues
|
||||
to determine the Window background behind the Flutter UI. -->
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme"
|
||||
/>
|
||||
<meta-data
|
||||
android:name="flutter_deeplinking_enabled"
|
||||
android:value="false" />
|
||||
<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>
|
||||
<!-- Launcher shelf artwork. Not exported: readers reach it only through the
|
||||
per-URI read grants issued to CATEGORY_HOME packages by WatchNextProvider. -->
|
||||
<provider
|
||||
android:name=".watchnext.SystemShelfArtworkProvider"
|
||||
android:authorities="com.edde746.plezy.systemshelf.artwork"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true" />
|
||||
<receiver
|
||||
android:name=".watchnext.SystemShelfUpdateReceiver"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
</application>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
|
||||
|
||||
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
</intent>
|
||||
<!-- Required for url_launcher to open HTTPS URLs -->
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:scheme="https" />
|
||||
</intent>
|
||||
<intent>
|
||||
<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>
|
||||
<!-- Required to identify HOME launchers as shelf artwork consumers. -->
|
||||
<intent>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.HOME" />
|
||||
</intent>
|
||||
</queries>
|
||||
</manifest>
|
||||
@@ -1,39 +0,0 @@
|
||||
cmake_minimum_required(VERSION 3.22.1)
|
||||
project(plezy_native)
|
||||
|
||||
option(DOVI_ENABLE_LIBDOVI "Link real libdovi" ON)
|
||||
set(DOVI_LIBDOVI_PREBUILT_ROOT "" CACHE PATH "Path to prebuilt libdovi")
|
||||
set(MPV_FFMPEG_ROOT "" CACHE PATH "Path to libmpv's FFmpeg headers and shared libraries")
|
||||
|
||||
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()
|
||||
|
||||
foreach(ffmpeg_library avcodec avutil swresample)
|
||||
add_library(${ffmpeg_library} SHARED IMPORTED)
|
||||
set_target_properties(${ffmpeg_library} PROPERTIES
|
||||
IMPORTED_LOCATION "${MPV_FFMPEG_ROOT}/native/${ANDROID_ABI}/lib${ffmpeg_library}.so")
|
||||
endforeach()
|
||||
|
||||
add_library(ffmpegJNI SHARED
|
||||
media3_ffmpeg_decoder/ffmpeg_jni.cc)
|
||||
target_compile_features(ffmpegJNI PRIVATE cxx_std_17)
|
||||
target_include_directories(ffmpegJNI PRIVATE
|
||||
"${MPV_FFMPEG_ROOT}/include"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/media3_ffmpeg_decoder")
|
||||
target_link_libraries(ffmpegJNI
|
||||
avcodec
|
||||
avutil
|
||||
swresample
|
||||
android
|
||||
log)
|
||||
@@ -1,132 +0,0 @@
|
||||
#include <android/log.h>
|
||||
#include <jni.h>
|
||||
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
#include <new>
|
||||
#include <vector>
|
||||
|
||||
#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";
|
||||
static constexpr jint CONVERT_FAILED = -1;
|
||||
static constexpr jint DESTINATION_TOO_SMALL = -2;
|
||||
static constexpr jint MAX_RPU_INPUT_SIZE = 8192;
|
||||
static constexpr size_t MAX_RPU_OUTPUT_SIZE = 16384;
|
||||
static constexpr int MAX_ERROR_LOG_LENGTH = 256;
|
||||
|
||||
extern "C" JNIEXPORT jint JNICALL Java_com_edde746_plezy_exoplayer_DoviBridge_nativeConvertDv7RpuToDv81(
|
||||
JNIEnv* env, jclass, jbyteArray payload, jint payload_offset, jint payload_length, jbyteArray output,
|
||||
jint output_offset, jint output_capacity, jint mode) {
|
||||
#if !DOVI_REAL_LINKED
|
||||
return CONVERT_FAILED;
|
||||
#else
|
||||
if (payload == nullptr || output == nullptr) return CONVERT_FAILED;
|
||||
if (payload_offset < 0 || payload_length <= 0 || output_offset < 0 || output_capacity < 0) {
|
||||
return CONVERT_FAILED;
|
||||
}
|
||||
|
||||
const jsize payload_array_len = env->GetArrayLength(payload);
|
||||
const jsize output_array_len = env->GetArrayLength(output);
|
||||
if (payload_offset > payload_array_len || payload_length > payload_array_len - payload_offset) {
|
||||
return CONVERT_FAILED;
|
||||
}
|
||||
if (output_offset > output_array_len) return DESTINATION_TOO_SMALL;
|
||||
|
||||
const jsize logical_output_len = output_capacity < output_array_len ? output_capacity : output_array_len;
|
||||
if (output_offset > logical_output_len) return DESTINATION_TOO_SMALL;
|
||||
|
||||
// Valid RPU NALs are typically <2 KiB; reject unreasonable sizes
|
||||
if (payload_length > MAX_RPU_INPUT_SIZE) {
|
||||
LOGW("RPU payload too large (%d bytes), skipping", payload_length);
|
||||
return CONVERT_FAILED;
|
||||
}
|
||||
|
||||
// Copy to native heap so libdovi never touches JVM heap memory.
|
||||
// Do not use GetPrimitiveArrayCritical here: it can block concurrent GC
|
||||
// compaction during sustained playback. A thread-local scratch buffer avoids
|
||||
// per-frame heap churn while keeping libdovi away from JVM heap memory.
|
||||
thread_local std::vector<uint8_t> scratch;
|
||||
try {
|
||||
scratch.resize(static_cast<size_t>(payload_length));
|
||||
} catch (...) {
|
||||
return CONVERT_FAILED;
|
||||
}
|
||||
|
||||
env->GetByteArrayRegion(payload, payload_offset, payload_length, reinterpret_cast<jbyte*>(scratch.data()));
|
||||
if (env->ExceptionCheck()) {
|
||||
return CONVERT_FAILED;
|
||||
}
|
||||
|
||||
// The input is a complete (possibly escaped) HEVC UNSPEC62 NAL. Parsing it as
|
||||
// a raw RPU after a framed-parser error can reinterpret malformed/truncated
|
||||
// NAL bytes as valid metadata.
|
||||
const auto rpu_len = static_cast<size_t>(payload_length);
|
||||
using RpuPtr = std::unique_ptr<DoviRpuOpaque, decltype(&dovi_rpu_free)>;
|
||||
RpuPtr rpu(dovi_parse_unspec62_nalu(scratch.data(), rpu_len), dovi_rpu_free);
|
||||
|
||||
if (rpu == nullptr) {
|
||||
return CONVERT_FAILED;
|
||||
}
|
||||
|
||||
const char* err = dovi_rpu_get_error(rpu.get());
|
||||
if (err != nullptr) {
|
||||
LOGW("RPU NAL parse failed: %.*s", MAX_ERROR_LOG_LENGTH, err);
|
||||
return CONVERT_FAILED;
|
||||
}
|
||||
|
||||
// Mode 2 matches Kodi's P8.1 compatibility path and sets luma/chroma curves to no-op.
|
||||
int32_t ret = dovi_convert_rpu_with_mode(rpu.get(), static_cast<uint8_t>(mode));
|
||||
if (ret != 0) {
|
||||
err = dovi_rpu_get_error(rpu.get());
|
||||
LOGW("RPU conversion failed (mode %d): %.*s", mode, MAX_ERROR_LOG_LENGTH, err ? err : "unknown");
|
||||
return CONVERT_FAILED;
|
||||
}
|
||||
|
||||
// Write back as UNSPEC62 NAL
|
||||
using DoviDataPtr = std::unique_ptr<const DoviData, decltype(&dovi_data_free)>;
|
||||
DoviDataPtr out(dovi_write_unspec62_nalu(rpu.get()), dovi_data_free);
|
||||
if (out == nullptr || out->data == nullptr || out->len == 0) {
|
||||
err = dovi_rpu_get_error(rpu.get());
|
||||
LOGW("RPU write failed: %.*s", MAX_ERROR_LOG_LENGTH, err ? err : "unknown");
|
||||
return CONVERT_FAILED;
|
||||
}
|
||||
|
||||
if (out->len > MAX_RPU_OUTPUT_SIZE) {
|
||||
LOGW("RPU output unexpectedly large (%zu bytes), discarding", out->len);
|
||||
return CONVERT_FAILED;
|
||||
}
|
||||
|
||||
const auto writable = static_cast<size_t>(logical_output_len - output_offset);
|
||||
if (out->len > writable) {
|
||||
return DESTINATION_TOO_SMALL;
|
||||
}
|
||||
|
||||
env->SetByteArrayRegion(
|
||||
output, output_offset, static_cast<jsize>(out->len), reinterpret_cast<const jbyte*>(out->data));
|
||||
const bool write_failed = env->ExceptionCheck();
|
||||
const auto written = static_cast<jint>(out->len);
|
||||
|
||||
return write_failed ? CONVERT_FAILED : written;
|
||||
#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,48 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Plezy contributors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
#ifndef PLEZY_FFMPEG_AUDIO_BUFFER_H_
|
||||
#define PLEZY_FFMPEG_AUDIO_BUFFER_H_
|
||||
|
||||
#include <limits.h>
|
||||
#include <stdint.h>
|
||||
|
||||
namespace plezy {
|
||||
namespace ffmpeg {
|
||||
|
||||
inline bool CheckedAudioByteCount(int sample_count, int channel_count, int bytes_per_sample, int* byte_count) {
|
||||
if (sample_count < 0 || channel_count <= 0 || bytes_per_sample <= 0 || byte_count == nullptr) {
|
||||
return false;
|
||||
}
|
||||
const int64_t size = static_cast<int64_t>(sample_count) * channel_count * bytes_per_sample;
|
||||
if (size > INT_MAX) {
|
||||
return false;
|
||||
}
|
||||
*byte_count = static_cast<int>(size);
|
||||
return true;
|
||||
}
|
||||
|
||||
inline bool CheckedAddByteCount(int current_size, int additional_size, int* total_size) {
|
||||
if (current_size < 0 || additional_size < 0 || total_size == nullptr || current_size > INT_MAX - additional_size) {
|
||||
return false;
|
||||
}
|
||||
*total_size = current_size + additional_size;
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace ffmpeg
|
||||
} // namespace plezy
|
||||
|
||||
#endif // PLEZY_FFMPEG_AUDIO_BUFFER_H_
|
||||
@@ -1,508 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2016 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
#include <android/log.h>
|
||||
#include <jni.h>
|
||||
|
||||
#include "ffmpeg_audio_buffer.h"
|
||||
|
||||
extern "C" {
|
||||
#ifdef __cplusplus
|
||||
#define __STDC_CONSTANT_MACROS
|
||||
#ifdef _STDINT_H
|
||||
#undef _STDINT_H
|
||||
#endif
|
||||
#include <stdint.h>
|
||||
#endif
|
||||
#include <libavcodec/avcodec.h>
|
||||
#include <libavutil/channel_layout.h>
|
||||
#include <libavutil/error.h>
|
||||
#include <libavutil/opt.h>
|
||||
#include <libswresample/swresample.h>
|
||||
}
|
||||
|
||||
#define LOG_TAG "ffmpeg_jni"
|
||||
#define LOGE(...) ((void)__android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__))
|
||||
#define LOGD(...) ((void)__android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__))
|
||||
|
||||
// clang-format 18 and 21 disagree on escaped-newline alignment in these JNI macros.
|
||||
// clang-format off
|
||||
#define LIBRARY_FUNC(RETURN_TYPE, NAME, ...) \
|
||||
extern "C" { \
|
||||
JNIEXPORT RETURN_TYPE \
|
||||
Java_androidx_media3_decoder_ffmpeg_FfmpegLibrary_##NAME(JNIEnv* env, jobject thiz, ##__VA_ARGS__); \
|
||||
} \
|
||||
JNIEXPORT RETURN_TYPE Java_androidx_media3_decoder_ffmpeg_FfmpegLibrary_##NAME( \
|
||||
JNIEnv* env, jobject thiz, ##__VA_ARGS__)
|
||||
|
||||
#define AUDIO_DECODER_FUNC(RETURN_TYPE, NAME, ...) \
|
||||
extern "C" { \
|
||||
JNIEXPORT RETURN_TYPE \
|
||||
Java_androidx_media3_decoder_ffmpeg_FfmpegAudioDecoder_##NAME(JNIEnv* env, jobject thiz, ##__VA_ARGS__); \
|
||||
} \
|
||||
JNIEXPORT RETURN_TYPE Java_androidx_media3_decoder_ffmpeg_FfmpegAudioDecoder_##NAME( \
|
||||
JNIEnv* env, jobject thiz, ##__VA_ARGS__)
|
||||
// clang-format on
|
||||
|
||||
#define ERROR_STRING_BUFFER_LENGTH 256
|
||||
|
||||
// Output format corresponding to AudioFormat.ENCODING_PCM_16BIT.
|
||||
static const AVSampleFormat OUTPUT_FORMAT_PCM_16BIT = AV_SAMPLE_FMT_S16;
|
||||
// Output format corresponding to AudioFormat.ENCODING_PCM_FLOAT.
|
||||
static const AVSampleFormat OUTPUT_FORMAT_PCM_FLOAT = AV_SAMPLE_FMT_FLT;
|
||||
|
||||
// LINT.IfChange
|
||||
static const int AUDIO_DECODER_ERROR_INVALID_DATA = -1;
|
||||
static const int AUDIO_DECODER_ERROR_OTHER = -2;
|
||||
// LINT.ThenChange(../java/androidx/media3/decoder/ffmpeg/FfmpegAudioDecoder.java)
|
||||
|
||||
namespace {
|
||||
struct ResampleState {
|
||||
SwrContext* context;
|
||||
AVChannelLayout input_channel_layout;
|
||||
AVSampleFormat input_sample_format;
|
||||
AVSampleFormat output_sample_format;
|
||||
int sample_rate;
|
||||
};
|
||||
} // namespace
|
||||
|
||||
static bool resampleConfigurationMatches(
|
||||
const ResampleState* state, const AVCodecContext* context, const AVFrame* frame);
|
||||
static int configureResampler(ResampleState* state, const AVCodecContext* context, const AVFrame* frame);
|
||||
static void releaseResampleState(ResampleState* state);
|
||||
|
||||
static jmethodID growOutputBufferMethod;
|
||||
|
||||
/**
|
||||
* Returns the AVCodec with the specified name, or NULL if it is not available.
|
||||
*/
|
||||
static const AVCodec* getCodecByName(JNIEnv* env, jstring codecName);
|
||||
|
||||
/**
|
||||
* Allocates and opens a new AVCodecContext for the specified codec, passing the
|
||||
* provided extraData as initialization data for the decoder if it is non-NULL.
|
||||
* Returns the created context.
|
||||
*/
|
||||
static AVCodecContext* createContext(
|
||||
JNIEnv* env, const AVCodec* codec, jbyteArray extraData, jboolean outputFloat, jint rawSampleRate,
|
||||
jint rawChannelCount);
|
||||
|
||||
namespace {
|
||||
struct GrowOutputBufferCallback {
|
||||
uint8_t* operator()(int requiredSize) const;
|
||||
|
||||
JNIEnv* env;
|
||||
jobject thiz;
|
||||
jobject decoderOutputBuffer;
|
||||
};
|
||||
} // namespace
|
||||
|
||||
/**
|
||||
* Decodes the packet into the output buffer, returning the number of bytes
|
||||
* written, or a negative AUDIO_DECODER_ERROR constant value in the case of an
|
||||
* error.
|
||||
*/
|
||||
static int decodePacket(
|
||||
AVCodecContext* context, AVPacket* packet, uint8_t* outputBuffer, int outputSize,
|
||||
GrowOutputBufferCallback growBuffer);
|
||||
|
||||
/**
|
||||
* Transforms ffmpeg AVERROR into a negative AUDIO_DECODER_ERROR constant value.
|
||||
*/
|
||||
static int transformError(int errorNumber);
|
||||
|
||||
/**
|
||||
* Outputs a log message describing the avcodec error number.
|
||||
*/
|
||||
static void logError(const char* functionName, int errorNumber);
|
||||
|
||||
/**
|
||||
* Releases the specified context.
|
||||
*/
|
||||
static void releaseContext(AVCodecContext* context);
|
||||
|
||||
JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) {
|
||||
JNIEnv* env;
|
||||
if (vm->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION_1_6) != JNI_OK) {
|
||||
LOGE("JNI_OnLoad: GetEnv failed");
|
||||
return -1;
|
||||
}
|
||||
jclass clazz = env->FindClass("androidx/media3/decoder/ffmpeg/FfmpegAudioDecoder");
|
||||
if (!clazz) {
|
||||
LOGE("JNI_OnLoad: FindClass failed");
|
||||
return -1;
|
||||
}
|
||||
growOutputBufferMethod = env->GetMethodID(
|
||||
clazz, "growOutputBuffer",
|
||||
"(Landroidx/media3/decoder/"
|
||||
"SimpleDecoderOutputBuffer;I)Ljava/nio/ByteBuffer;");
|
||||
if (!growOutputBufferMethod) {
|
||||
LOGE("JNI_OnLoad: GetMethodID failed");
|
||||
return -1;
|
||||
}
|
||||
return JNI_VERSION_1_6;
|
||||
}
|
||||
|
||||
LIBRARY_FUNC(jstring, ffmpegGetVersion) { return env->NewStringUTF(LIBAVCODEC_IDENT); }
|
||||
|
||||
LIBRARY_FUNC(jint, ffmpegGetInputBufferPaddingSize) { return (jint)AV_INPUT_BUFFER_PADDING_SIZE; }
|
||||
|
||||
LIBRARY_FUNC(jboolean, ffmpegHasDecoder, jstring codecName) { return getCodecByName(env, codecName) != nullptr; }
|
||||
|
||||
AUDIO_DECODER_FUNC(
|
||||
jlong, ffmpegInitialize, jstring codecName, jbyteArray extraData, jboolean outputFloat, jint rawSampleRate,
|
||||
jint rawChannelCount) {
|
||||
const AVCodec* codec = getCodecByName(env, codecName);
|
||||
if (!codec) {
|
||||
LOGE("Codec not found.");
|
||||
return 0L;
|
||||
}
|
||||
return (jlong)createContext(env, codec, extraData, outputFloat, rawSampleRate, rawChannelCount);
|
||||
}
|
||||
|
||||
AUDIO_DECODER_FUNC(
|
||||
jint, ffmpegDecode, jlong context, jobject inputData, jint inputSize, jobject decoderOutputBuffer,
|
||||
jobject outputData, jint outputSize) {
|
||||
if (!context) {
|
||||
LOGE("Context must be non-NULL.");
|
||||
return -1;
|
||||
}
|
||||
if (!inputData || !decoderOutputBuffer || !outputData) {
|
||||
LOGE("Input and output buffers must be non-NULL.");
|
||||
return -1;
|
||||
}
|
||||
if (inputSize < 0) {
|
||||
LOGE("Invalid input buffer size: %d.", inputSize);
|
||||
return -1;
|
||||
}
|
||||
if (outputSize < 0) {
|
||||
LOGE("Invalid output buffer length: %d", outputSize);
|
||||
return -1;
|
||||
}
|
||||
const jlong inputCapacity = env->GetDirectBufferCapacity(inputData);
|
||||
const jlong outputCapacity = env->GetDirectBufferCapacity(outputData);
|
||||
if (inputCapacity < inputSize || outputCapacity < outputSize) {
|
||||
LOGE("Buffer size exceeds direct buffer capacity.");
|
||||
return -1;
|
||||
}
|
||||
uint8_t* inputBuffer = (uint8_t*)env->GetDirectBufferAddress(inputData);
|
||||
uint8_t* outputBuffer = (uint8_t*)env->GetDirectBufferAddress(outputData);
|
||||
AVPacket* packet = av_packet_alloc();
|
||||
if (!packet) {
|
||||
LOGE("Failed to allocate packet.");
|
||||
return -1;
|
||||
}
|
||||
packet->data = inputBuffer;
|
||||
packet->size = inputSize;
|
||||
const int ret = decodePacket(
|
||||
(AVCodecContext*)context, packet, outputBuffer, outputSize,
|
||||
GrowOutputBufferCallback{env, thiz, decoderOutputBuffer});
|
||||
av_packet_free(&packet);
|
||||
return ret;
|
||||
}
|
||||
|
||||
uint8_t* GrowOutputBufferCallback::operator()(int requiredSize) const {
|
||||
jobject newOutputData = env->CallObjectMethod(thiz, growOutputBufferMethod, decoderOutputBuffer, requiredSize);
|
||||
if (env->ExceptionCheck()) {
|
||||
LOGE("growOutputBuffer() failed");
|
||||
env->ExceptionDescribe();
|
||||
return nullptr;
|
||||
}
|
||||
if (env->GetDirectBufferCapacity(newOutputData) < requiredSize) {
|
||||
LOGE("growOutputBuffer() returned an undersized or non-direct buffer.");
|
||||
return nullptr;
|
||||
}
|
||||
return static_cast<uint8_t*>(env->GetDirectBufferAddress(newOutputData));
|
||||
}
|
||||
|
||||
AUDIO_DECODER_FUNC(jint, ffmpegGetChannelCount, jlong context) {
|
||||
if (!context) {
|
||||
LOGE("Context must be non-NULL.");
|
||||
return -1;
|
||||
}
|
||||
return ((AVCodecContext*)context)->ch_layout.nb_channels;
|
||||
}
|
||||
|
||||
AUDIO_DECODER_FUNC(jint, ffmpegGetSampleRate, jlong context) {
|
||||
if (!context) {
|
||||
LOGE("Context must be non-NULL.");
|
||||
return -1;
|
||||
}
|
||||
return ((AVCodecContext*)context)->sample_rate;
|
||||
}
|
||||
|
||||
AUDIO_DECODER_FUNC(jlong, ffmpegReset, jlong jContext, jbyteArray extraData) {
|
||||
AVCodecContext* context = (AVCodecContext*)jContext;
|
||||
if (!context) {
|
||||
LOGE("Tried to reset without a context.");
|
||||
return 0L;
|
||||
}
|
||||
|
||||
AVCodecID codecId = context->codec_id;
|
||||
if (codecId == AV_CODEC_ID_TRUEHD) {
|
||||
jboolean outputFloat = (jboolean)(context->request_sample_fmt == OUTPUT_FORMAT_PCM_FLOAT);
|
||||
// Release and recreate the context if the codec is TrueHD.
|
||||
// TODO: Figure out why flushing doesn't work for this codec.
|
||||
releaseContext(context);
|
||||
const AVCodec* codec = avcodec_find_decoder(codecId);
|
||||
if (!codec) {
|
||||
LOGE("Unexpected error finding codec %d.", codecId);
|
||||
return 0L;
|
||||
}
|
||||
return (jlong)createContext(
|
||||
env, codec, extraData, outputFloat,
|
||||
/* rawSampleRate= */ -1,
|
||||
/* rawChannelCount= */ -1);
|
||||
}
|
||||
|
||||
avcodec_flush_buffers(context);
|
||||
return (jlong)context;
|
||||
}
|
||||
|
||||
AUDIO_DECODER_FUNC(void, ffmpegRelease, jlong context) {
|
||||
if (context) {
|
||||
releaseContext((AVCodecContext*)context);
|
||||
}
|
||||
}
|
||||
|
||||
static const AVCodec* getCodecByName(JNIEnv* env, jstring codecName) {
|
||||
if (!codecName) {
|
||||
return nullptr;
|
||||
}
|
||||
const char* codecNameChars = env->GetStringUTFChars(codecName, nullptr);
|
||||
const AVCodec* codec = avcodec_find_decoder_by_name(codecNameChars);
|
||||
env->ReleaseStringUTFChars(codecName, codecNameChars);
|
||||
return codec;
|
||||
}
|
||||
|
||||
static AVCodecContext* createContext(
|
||||
JNIEnv* env, const AVCodec* codec, jbyteArray extraData, jboolean outputFloat, jint rawSampleRate,
|
||||
jint rawChannelCount) {
|
||||
AVCodecContext* context = avcodec_alloc_context3(codec);
|
||||
if (!context) {
|
||||
LOGE("Failed to allocate context.");
|
||||
return nullptr;
|
||||
}
|
||||
context->request_sample_fmt = outputFloat ? OUTPUT_FORMAT_PCM_FLOAT : OUTPUT_FORMAT_PCM_16BIT;
|
||||
if (extraData) {
|
||||
jsize size = env->GetArrayLength(extraData);
|
||||
if (size > INT_MAX - AV_INPUT_BUFFER_PADDING_SIZE) {
|
||||
LOGE("Extradata is too large.");
|
||||
releaseContext(context);
|
||||
return nullptr;
|
||||
}
|
||||
context->extradata_size = size;
|
||||
context->extradata = (uint8_t*)av_mallocz(static_cast<size_t>(size) + AV_INPUT_BUFFER_PADDING_SIZE);
|
||||
if (!context->extradata) {
|
||||
LOGE("Failed to allocate extradata.");
|
||||
releaseContext(context);
|
||||
return nullptr;
|
||||
}
|
||||
env->GetByteArrayRegion(extraData, 0, size, (jbyte*)context->extradata);
|
||||
}
|
||||
if (context->codec_id == AV_CODEC_ID_PCM_MULAW || context->codec_id == AV_CODEC_ID_PCM_ALAW) {
|
||||
context->sample_rate = rawSampleRate;
|
||||
av_channel_layout_default(&context->ch_layout, rawChannelCount);
|
||||
}
|
||||
context->err_recognition = AV_EF_IGNORE_ERR;
|
||||
int result = avcodec_open2(context, codec, nullptr);
|
||||
if (result < 0) {
|
||||
logError("avcodec_open2", result);
|
||||
releaseContext(context);
|
||||
return nullptr;
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
static bool resampleConfigurationMatches(
|
||||
const ResampleState* state, const AVCodecContext* context, const AVFrame* frame) {
|
||||
return state && state->context && state->input_sample_format == static_cast<AVSampleFormat>(frame->format) &&
|
||||
state->output_sample_format == context->request_sample_fmt && state->sample_rate == frame->sample_rate &&
|
||||
av_channel_layout_compare(&state->input_channel_layout, &frame->ch_layout) == 0;
|
||||
}
|
||||
|
||||
static int configureResampler(ResampleState* state, const AVCodecContext* context, const AVFrame* frame) {
|
||||
SwrContext* nextContext = nullptr;
|
||||
const AVSampleFormat inputSampleFormat = static_cast<AVSampleFormat>(frame->format);
|
||||
int result = swr_alloc_set_opts2(
|
||||
&nextContext, // ps
|
||||
&frame->ch_layout, // out_ch_layout
|
||||
context->request_sample_fmt, // out_sample_fmt
|
||||
frame->sample_rate, // out_sample_rate
|
||||
&frame->ch_layout, // in_ch_layout
|
||||
inputSampleFormat, // in_sample_fmt
|
||||
frame->sample_rate, // in_sample_rate
|
||||
0, // log_offset
|
||||
nullptr // log_ctx
|
||||
);
|
||||
if (result < 0) {
|
||||
logError("swr_alloc_set_opts2", result);
|
||||
return result;
|
||||
}
|
||||
result = swr_init(nextContext);
|
||||
if (result < 0) {
|
||||
logError("swr_init", result);
|
||||
swr_free(&nextContext);
|
||||
return result;
|
||||
}
|
||||
|
||||
AVChannelLayout nextInputChannelLayout = {};
|
||||
result = av_channel_layout_copy(&nextInputChannelLayout, &frame->ch_layout);
|
||||
if (result < 0) {
|
||||
logError("av_channel_layout_copy", result);
|
||||
swr_free(&nextContext);
|
||||
return result;
|
||||
}
|
||||
|
||||
swr_free(&state->context);
|
||||
av_channel_layout_uninit(&state->input_channel_layout);
|
||||
state->context = nextContext;
|
||||
state->input_channel_layout = nextInputChannelLayout;
|
||||
state->input_sample_format = inputSampleFormat;
|
||||
state->output_sample_format = context->request_sample_fmt;
|
||||
state->sample_rate = frame->sample_rate;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void releaseResampleState(ResampleState* state) {
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
swr_free(&state->context);
|
||||
av_channel_layout_uninit(&state->input_channel_layout);
|
||||
av_free(state);
|
||||
}
|
||||
|
||||
static int decodePacket(
|
||||
AVCodecContext* context, AVPacket* packet, uint8_t* outputBuffer, int outputSize,
|
||||
GrowOutputBufferCallback growBuffer) {
|
||||
int result = avcodec_send_packet(context, packet);
|
||||
if (result) {
|
||||
logError("avcodec_send_packet", result);
|
||||
return transformError(result);
|
||||
}
|
||||
|
||||
int outSize = 0;
|
||||
while (true) {
|
||||
AVFrame* frame = av_frame_alloc();
|
||||
if (!frame) {
|
||||
LOGE("Failed to allocate output frame.");
|
||||
return AUDIO_DECODER_ERROR_INVALID_DATA;
|
||||
}
|
||||
result = avcodec_receive_frame(context, frame);
|
||||
if (result) {
|
||||
av_frame_free(&frame);
|
||||
if (result == AVERROR(EAGAIN)) {
|
||||
break;
|
||||
}
|
||||
logError("avcodec_receive_frame", result);
|
||||
return transformError(result);
|
||||
}
|
||||
|
||||
const AVSampleFormat sampleFormat = static_cast<AVSampleFormat>(frame->format);
|
||||
const int channelCount = frame->ch_layout.nb_channels;
|
||||
const int sampleRate = frame->sample_rate;
|
||||
const int sampleCount = frame->nb_samples;
|
||||
if (sampleFormat == AV_SAMPLE_FMT_NONE || channelCount <= 0 || sampleRate <= 0 || sampleCount < 0 ||
|
||||
!frame->extended_data || !av_channel_layout_check(&frame->ch_layout)) {
|
||||
LOGE("Decoder returned an invalid audio frame.");
|
||||
av_frame_free(&frame);
|
||||
return AUDIO_DECODER_ERROR_INVALID_DATA;
|
||||
}
|
||||
|
||||
ResampleState* resampleState = static_cast<ResampleState*>(context->opaque);
|
||||
if (!resampleState) {
|
||||
resampleState = static_cast<ResampleState*>(av_mallocz(sizeof(ResampleState)));
|
||||
if (!resampleState) {
|
||||
LOGE("Failed to allocate resampler state.");
|
||||
av_frame_free(&frame);
|
||||
return AUDIO_DECODER_ERROR_OTHER;
|
||||
}
|
||||
context->opaque = resampleState;
|
||||
}
|
||||
if (!resampleConfigurationMatches(resampleState, context, frame)) {
|
||||
result = configureResampler(resampleState, context, frame);
|
||||
if (result < 0) {
|
||||
av_frame_free(&frame);
|
||||
return transformError(result);
|
||||
}
|
||||
}
|
||||
|
||||
const int bytesPerSample = av_get_bytes_per_sample(context->request_sample_fmt);
|
||||
const int outputSampleCapacity = swr_get_out_samples(resampleState->context, sampleCount);
|
||||
int outputByteCapacity;
|
||||
int requiredOutputSize;
|
||||
if (!plezy::ffmpeg::CheckedAudioByteCount(
|
||||
outputSampleCapacity, channelCount, bytesPerSample, &outputByteCapacity) ||
|
||||
!plezy::ffmpeg::CheckedAddByteCount(outSize, outputByteCapacity, &requiredOutputSize)) {
|
||||
LOGE("Decoded audio output size is invalid or too large.");
|
||||
av_frame_free(&frame);
|
||||
return AUDIO_DECODER_ERROR_INVALID_DATA;
|
||||
}
|
||||
if (requiredOutputSize > outputSize) {
|
||||
LOGD(
|
||||
"Output buffer size (%d) too small for output data (%d), "
|
||||
"reallocating buffer.",
|
||||
outputSize, requiredOutputSize);
|
||||
outputSize = requiredOutputSize;
|
||||
outputBuffer = growBuffer(outputSize);
|
||||
if (!outputBuffer) {
|
||||
LOGE("Failed to reallocate output buffer.");
|
||||
av_frame_free(&frame);
|
||||
return AUDIO_DECODER_ERROR_OTHER;
|
||||
}
|
||||
}
|
||||
|
||||
uint8_t* frameOutput = outputBuffer + outSize;
|
||||
uint8_t* outputPlanes[] = {frameOutput};
|
||||
result = swr_convert(
|
||||
resampleState->context, outputPlanes, outputSampleCapacity, (const uint8_t**)frame->extended_data, sampleCount);
|
||||
av_frame_free(&frame);
|
||||
if (result < 0) {
|
||||
logError("swr_convert", result);
|
||||
return AUDIO_DECODER_ERROR_INVALID_DATA;
|
||||
}
|
||||
|
||||
int writtenByteCount;
|
||||
int nextOutSize;
|
||||
if (!plezy::ffmpeg::CheckedAudioByteCount(result, channelCount, bytesPerSample, &writtenByteCount) ||
|
||||
writtenByteCount > outputByteCapacity ||
|
||||
!plezy::ffmpeg::CheckedAddByteCount(outSize, writtenByteCount, &nextOutSize)) {
|
||||
LOGE("Resampler returned an invalid output sample count.");
|
||||
return AUDIO_DECODER_ERROR_INVALID_DATA;
|
||||
}
|
||||
outSize = nextOutSize;
|
||||
}
|
||||
return outSize;
|
||||
}
|
||||
|
||||
static int transformError(int errorNumber) {
|
||||
return errorNumber == AVERROR_INVALIDDATA ? AUDIO_DECODER_ERROR_INVALID_DATA : AUDIO_DECODER_ERROR_OTHER;
|
||||
}
|
||||
|
||||
static void logError(const char* functionName, int errorNumber) {
|
||||
char buffer[ERROR_STRING_BUFFER_LENGTH];
|
||||
av_strerror(errorNumber, buffer, sizeof(buffer));
|
||||
LOGE("Error in %s: %s", functionName, buffer);
|
||||
}
|
||||
|
||||
static void releaseContext(AVCodecContext* context) {
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
releaseResampleState(static_cast<ResampleState*>(context->opaque));
|
||||
context->opaque = nullptr;
|
||||
avcodec_free_context(&context);
|
||||
}
|
||||
@@ -1,294 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2016 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package androidx.media3.decoder.ffmpeg;
|
||||
|
||||
import static com.google.common.base.Preconditions.checkNotNull;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.media3.common.C;
|
||||
import androidx.media3.common.Format;
|
||||
import androidx.media3.common.MimeTypes;
|
||||
import androidx.media3.common.util.ParsableByteArray;
|
||||
import androidx.media3.common.util.Util;
|
||||
import androidx.media3.decoder.DecoderInputBuffer;
|
||||
import androidx.media3.decoder.SimpleDecoder;
|
||||
import androidx.media3.decoder.SimpleDecoderOutputBuffer;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.List;
|
||||
|
||||
/** Media3 audio decoder backed by the FFmpeg libraries packaged by libmpv. */
|
||||
final class FfmpegAudioDecoder
|
||||
extends SimpleDecoder<DecoderInputBuffer, SimpleDecoderOutputBuffer, FfmpegDecoderException> {
|
||||
|
||||
private static final int INITIAL_OUTPUT_BUFFER_SIZE_16BIT = 65535;
|
||||
private static final int INITIAL_OUTPUT_BUFFER_SIZE_32BIT = INITIAL_OUTPUT_BUFFER_SIZE_16BIT * 2;
|
||||
private static final int AUDIO_DECODER_ERROR_INVALID_DATA = -1;
|
||||
private static final int AUDIO_DECODER_ERROR_OTHER = -2;
|
||||
|
||||
private static final byte[] FLAC_STREAM_MARKER = {'f', 'L', 'a', 'C'};
|
||||
private static final int FLAC_METADATA_TYPE_STREAM_INFO = 0;
|
||||
private static final int FLAC_METADATA_BLOCK_HEADER_SIZE = 4;
|
||||
private static final int FLAC_STREAM_INFO_DATA_SIZE = 34;
|
||||
|
||||
private final String codecName;
|
||||
@Nullable private final byte[] extraData;
|
||||
private final @C.PcmEncoding int encoding;
|
||||
private int outputBufferSize;
|
||||
|
||||
private long nativeContext;
|
||||
private boolean hasOutputFormat;
|
||||
private volatile int channelCount;
|
||||
private volatile int sampleRate;
|
||||
|
||||
FfmpegAudioDecoder(
|
||||
Format format,
|
||||
int numInputBuffers,
|
||||
int numOutputBuffers,
|
||||
int initialInputBufferSize,
|
||||
boolean outputFloat)
|
||||
throws FfmpegDecoderException {
|
||||
super(new DecoderInputBuffer[numInputBuffers], new SimpleDecoderOutputBuffer[numOutputBuffers]);
|
||||
if (!FfmpegLibrary.isAvailable()) {
|
||||
throw new FfmpegDecoderException("Failed to load decoder native libraries.");
|
||||
}
|
||||
String mimeType = checkNotNull(format.sampleMimeType);
|
||||
codecName = checkNotNull(FfmpegLibrary.getCodecName(mimeType));
|
||||
extraData = getExtraData(mimeType, format.initializationData);
|
||||
encoding = outputFloat ? C.ENCODING_PCM_FLOAT : C.ENCODING_PCM_16BIT;
|
||||
outputBufferSize =
|
||||
outputFloat ? INITIAL_OUTPUT_BUFFER_SIZE_32BIT : INITIAL_OUTPUT_BUFFER_SIZE_16BIT;
|
||||
nativeContext =
|
||||
ffmpegInitialize(codecName, extraData, outputFloat, format.sampleRate, format.channelCount);
|
||||
if (nativeContext == 0) {
|
||||
throw new FfmpegDecoderException("Initialization failed.");
|
||||
}
|
||||
setInitialInputBufferSize(initialInputBufferSize);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "ffmpeg" + FfmpegLibrary.getVersion() + "-" + codecName;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected DecoderInputBuffer createInputBuffer() {
|
||||
return new DecoderInputBuffer(
|
||||
DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_DIRECT,
|
||||
FfmpegLibrary.getInputBufferPaddingSize());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected SimpleDecoderOutputBuffer createOutputBuffer() {
|
||||
return new SimpleDecoderOutputBuffer(this::releaseOutputBuffer);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected FfmpegDecoderException createUnexpectedDecodeException(Throwable error) {
|
||||
return new FfmpegDecoderException("Unexpected decode error", error);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Nullable
|
||||
protected FfmpegDecoderException decode(
|
||||
DecoderInputBuffer inputBuffer, SimpleDecoderOutputBuffer outputBuffer, boolean reset) {
|
||||
if (reset) {
|
||||
nativeContext = ffmpegReset(nativeContext, extraData);
|
||||
if (nativeContext == 0) {
|
||||
return new FfmpegDecoderException("Error resetting (see logcat).");
|
||||
}
|
||||
}
|
||||
ByteBuffer inputData = Util.castNonNull(inputBuffer.data);
|
||||
int inputSize = inputData.limit();
|
||||
ByteBuffer outputData = outputBuffer.init(inputBuffer.timeUs, outputBufferSize);
|
||||
int result =
|
||||
ffmpegDecode(
|
||||
nativeContext, inputData, inputSize, outputBuffer, outputData, outputBufferSize);
|
||||
if (result == AUDIO_DECODER_ERROR_OTHER) {
|
||||
return new FfmpegDecoderException("Error decoding (see logcat).");
|
||||
} else if (result == AUDIO_DECODER_ERROR_INVALID_DATA) {
|
||||
outputBuffer.shouldBeSkipped = true;
|
||||
return null;
|
||||
} else if (result == 0) {
|
||||
outputBuffer.shouldBeSkipped = true;
|
||||
return null;
|
||||
}
|
||||
if (!hasOutputFormat) {
|
||||
channelCount = ffmpegGetChannelCount(nativeContext);
|
||||
sampleRate = ffmpegGetSampleRate(nativeContext);
|
||||
if (sampleRate == 0 && "alac".equals(codecName)) {
|
||||
checkNotNull(extraData);
|
||||
ParsableByteArray parsableExtraData = new ParsableByteArray(extraData);
|
||||
parsableExtraData.setPosition(extraData.length - 4);
|
||||
sampleRate = parsableExtraData.readUnsignedIntToInt();
|
||||
}
|
||||
hasOutputFormat = true;
|
||||
}
|
||||
outputData = checkNotNull(outputBuffer.data);
|
||||
outputData.position(0);
|
||||
outputData.limit(result);
|
||||
return null;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
private ByteBuffer growOutputBuffer(SimpleDecoderOutputBuffer outputBuffer, int requiredSize) {
|
||||
outputBufferSize = requiredSize;
|
||||
return outputBuffer.grow(requiredSize);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void release() {
|
||||
super.release();
|
||||
ffmpegRelease(nativeContext);
|
||||
nativeContext = 0;
|
||||
}
|
||||
|
||||
int getChannelCount() {
|
||||
return channelCount;
|
||||
}
|
||||
|
||||
int getSampleRate() {
|
||||
return sampleRate;
|
||||
}
|
||||
|
||||
@C.PcmEncoding
|
||||
int getEncoding() {
|
||||
return encoding;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static byte[] getExtraData(String mimeType, List<byte[]> initializationData) {
|
||||
switch (mimeType) {
|
||||
case MimeTypes.AUDIO_AAC:
|
||||
case MimeTypes.AUDIO_OPUS:
|
||||
return initializationData.get(0);
|
||||
case MimeTypes.AUDIO_ALAC:
|
||||
return getAlacExtraData(initializationData);
|
||||
case MimeTypes.AUDIO_VORBIS:
|
||||
return getVorbisExtraData(initializationData);
|
||||
case MimeTypes.AUDIO_FLAC:
|
||||
return getFlacExtraData(initializationData);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] getAlacExtraData(List<byte[]> initializationData) {
|
||||
byte[] magicCookie = initializationData.get(0);
|
||||
int alacAtomLength = 12 + magicCookie.length;
|
||||
ByteBuffer alacAtom = ByteBuffer.allocate(alacAtomLength);
|
||||
alacAtom.putInt(alacAtomLength);
|
||||
alacAtom.putInt(0x616c6163);
|
||||
alacAtom.putInt(0);
|
||||
alacAtom.put(magicCookie, 0, magicCookie.length);
|
||||
return alacAtom.array();
|
||||
}
|
||||
|
||||
private static byte[] getVorbisExtraData(List<byte[]> initializationData) {
|
||||
byte[] header0 = initializationData.get(0);
|
||||
byte[] header1 = initializationData.get(1);
|
||||
byte[] extraData = new byte[header0.length + header1.length + 6];
|
||||
extraData[0] = (byte) (header0.length >> 8);
|
||||
extraData[1] = (byte) (header0.length & 0xFF);
|
||||
System.arraycopy(header0, 0, extraData, 2, header0.length);
|
||||
extraData[header0.length + 2] = 0;
|
||||
extraData[header0.length + 3] = 0;
|
||||
extraData[header0.length + 4] = (byte) (header1.length >> 8);
|
||||
extraData[header0.length + 5] = (byte) (header1.length & 0xFF);
|
||||
System.arraycopy(header1, 0, extraData, header0.length + 6, header1.length);
|
||||
return extraData;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static byte[] getFlacExtraData(List<byte[]> initializationData) {
|
||||
for (int i = 0; i < initializationData.size(); i++) {
|
||||
@Nullable byte[] streamInfo = extractFlacStreamInfo(initializationData.get(i));
|
||||
if (streamInfo != null) {
|
||||
return streamInfo;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static byte[] extractFlacStreamInfo(byte[] data) {
|
||||
int offset = 0;
|
||||
if (arrayStartsWith(data, FLAC_STREAM_MARKER)) {
|
||||
offset = FLAC_STREAM_MARKER.length;
|
||||
}
|
||||
|
||||
if (data.length - offset == FLAC_STREAM_INFO_DATA_SIZE) {
|
||||
byte[] streamInfo = new byte[FLAC_STREAM_INFO_DATA_SIZE];
|
||||
System.arraycopy(data, offset, streamInfo, 0, FLAC_STREAM_INFO_DATA_SIZE);
|
||||
return streamInfo;
|
||||
}
|
||||
|
||||
if (data.length >= offset + FLAC_METADATA_BLOCK_HEADER_SIZE) {
|
||||
int type = data[offset] & 0x7F;
|
||||
int length =
|
||||
((data[offset + 1] & 0xFF) << 16)
|
||||
| ((data[offset + 2] & 0xFF) << 8)
|
||||
| (data[offset + 3] & 0xFF);
|
||||
if (type == FLAC_METADATA_TYPE_STREAM_INFO
|
||||
&& length == FLAC_STREAM_INFO_DATA_SIZE
|
||||
&& data.length >= offset + FLAC_METADATA_BLOCK_HEADER_SIZE + FLAC_STREAM_INFO_DATA_SIZE) {
|
||||
byte[] streamInfo = new byte[FLAC_STREAM_INFO_DATA_SIZE];
|
||||
System.arraycopy(
|
||||
data,
|
||||
offset + FLAC_METADATA_BLOCK_HEADER_SIZE,
|
||||
streamInfo,
|
||||
0,
|
||||
FLAC_STREAM_INFO_DATA_SIZE);
|
||||
return streamInfo;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean arrayStartsWith(byte[] data, byte[] prefix) {
|
||||
if (data.length < prefix.length) {
|
||||
return false;
|
||||
}
|
||||
for (int i = 0; i < prefix.length; i++) {
|
||||
if (data[i] != prefix[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private native long ffmpegInitialize(
|
||||
String codecName,
|
||||
@Nullable byte[] extraData,
|
||||
boolean outputFloat,
|
||||
int rawSampleRate,
|
||||
int rawChannelCount);
|
||||
|
||||
private native int ffmpegDecode(
|
||||
long context,
|
||||
ByteBuffer inputData,
|
||||
int inputSize,
|
||||
SimpleDecoderOutputBuffer decoderOutputBuffer,
|
||||
ByteBuffer outputData,
|
||||
int outputSize);
|
||||
|
||||
private native int ffmpegGetChannelCount(long context);
|
||||
|
||||
private native int ffmpegGetSampleRate(long context);
|
||||
|
||||
private native long ffmpegReset(long context, @Nullable byte[] extraData);
|
||||
|
||||
private native void ffmpegRelease(long context);
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2016 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package androidx.media3.decoder.ffmpeg;
|
||||
|
||||
import static androidx.media3.exoplayer.audio.AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY;
|
||||
import static androidx.media3.exoplayer.audio.AudioSink.SINK_FORMAT_SUPPORTED_WITH_TRANSCODING;
|
||||
import static androidx.media3.exoplayer.audio.AudioSink.SINK_FORMAT_UNSUPPORTED;
|
||||
import static com.google.common.base.Preconditions.checkNotNull;
|
||||
|
||||
import android.os.Handler;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.media3.common.C;
|
||||
import androidx.media3.common.Format;
|
||||
import androidx.media3.common.MimeTypes;
|
||||
import androidx.media3.common.util.TraceUtil;
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
import androidx.media3.common.util.Util;
|
||||
import androidx.media3.decoder.CryptoConfig;
|
||||
import androidx.media3.exoplayer.audio.AudioRendererEventListener;
|
||||
import androidx.media3.exoplayer.audio.AudioSink;
|
||||
import androidx.media3.exoplayer.audio.AudioSink.SinkFormatSupport;
|
||||
import androidx.media3.exoplayer.audio.DecoderAudioRenderer;
|
||||
|
||||
/** Decodes and renders audio using the FFmpeg libraries shared with libmpv. */
|
||||
@UnstableApi
|
||||
public final class FfmpegAudioRenderer extends DecoderAudioRenderer<FfmpegAudioDecoder> {
|
||||
|
||||
private static final String TAG = "FfmpegAudioRenderer";
|
||||
private static final int NUM_BUFFERS = 16;
|
||||
private static final int DEFAULT_INPUT_BUFFER_SIZE = 960 * 6;
|
||||
|
||||
public FfmpegAudioRenderer(
|
||||
@Nullable Handler eventHandler,
|
||||
@Nullable AudioRendererEventListener eventListener,
|
||||
AudioSink audioSink) {
|
||||
super(eventHandler, eventListener, audioSink);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return TAG;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected @C.FormatSupport int supportsFormatInternal(Format format) {
|
||||
String mimeType = checkNotNull(format.sampleMimeType);
|
||||
if (!FfmpegLibrary.isAvailable() || !MimeTypes.isAudio(mimeType)) {
|
||||
return C.FORMAT_UNSUPPORTED_TYPE;
|
||||
} else if (!FfmpegLibrary.supportsFormat(mimeType)
|
||||
|| (!sinkSupportsFormat(format, C.ENCODING_PCM_16BIT)
|
||||
&& !sinkSupportsFormat(format, C.ENCODING_PCM_FLOAT))) {
|
||||
return C.FORMAT_UNSUPPORTED_SUBTYPE;
|
||||
} else if (format.cryptoType != C.CRYPTO_TYPE_NONE) {
|
||||
return C.FORMAT_UNSUPPORTED_DRM;
|
||||
} else {
|
||||
return C.FORMAT_HANDLED;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public @AdaptiveSupport int supportsMixedMimeTypeAdaptation() {
|
||||
return ADAPTIVE_NOT_SEAMLESS;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected FfmpegAudioDecoder createDecoder(Format format, @Nullable CryptoConfig cryptoConfig)
|
||||
throws FfmpegDecoderException {
|
||||
TraceUtil.beginSection("createFfmpegAudioDecoder");
|
||||
int initialInputBufferSize =
|
||||
format.maxInputSize != Format.NO_VALUE ? format.maxInputSize : DEFAULT_INPUT_BUFFER_SIZE;
|
||||
FfmpegAudioDecoder decoder =
|
||||
new FfmpegAudioDecoder(
|
||||
format, NUM_BUFFERS, NUM_BUFFERS, initialInputBufferSize, shouldOutputFloat(format));
|
||||
TraceUtil.endSection();
|
||||
return decoder;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Format getOutputFormat(FfmpegAudioDecoder decoder) {
|
||||
checkNotNull(decoder);
|
||||
return new Format.Builder()
|
||||
.setSampleMimeType(MimeTypes.AUDIO_RAW)
|
||||
.setChannelCount(decoder.getChannelCount())
|
||||
.setSampleRate(decoder.getSampleRate())
|
||||
.setPcmEncoding(decoder.getEncoding())
|
||||
.build();
|
||||
}
|
||||
|
||||
private boolean sinkSupportsFormat(Format inputFormat, @C.PcmEncoding int pcmEncoding) {
|
||||
return sinkSupportsFormat(
|
||||
Util.getPcmFormat(pcmEncoding, inputFormat.channelCount, inputFormat.sampleRate));
|
||||
}
|
||||
|
||||
private boolean shouldOutputFloat(Format inputFormat) {
|
||||
if (!sinkSupportsFormat(inputFormat, C.ENCODING_PCM_16BIT)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@SinkFormatSupport
|
||||
int formatSupport =
|
||||
getSinkFormatSupport(
|
||||
Util.getPcmFormat(
|
||||
C.ENCODING_PCM_FLOAT, inputFormat.channelCount, inputFormat.sampleRate));
|
||||
switch (formatSupport) {
|
||||
case SINK_FORMAT_SUPPORTED_DIRECTLY:
|
||||
return !MimeTypes.AUDIO_AC3.equals(inputFormat.sampleMimeType);
|
||||
case SINK_FORMAT_UNSUPPORTED:
|
||||
case SINK_FORMAT_SUPPORTED_WITH_TRANSCODING:
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2016 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package androidx.media3.decoder.ffmpeg;
|
||||
|
||||
import androidx.media3.decoder.DecoderException;
|
||||
|
||||
/** Thrown when an FFmpeg decoder error occurs. */
|
||||
final class FfmpegDecoderException extends DecoderException {
|
||||
|
||||
FfmpegDecoderException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
FfmpegDecoderException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2016 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package androidx.media3.decoder.ffmpeg;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.media3.common.C;
|
||||
import androidx.media3.common.MimeTypes;
|
||||
import androidx.media3.common.util.LibraryLoader;
|
||||
import androidx.media3.common.util.Log;
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
|
||||
/** Configures and queries the FFmpeg libraries shared with libmpv. */
|
||||
@UnstableApi
|
||||
final class FfmpegLibrary {
|
||||
|
||||
private static final String TAG = "FfmpegLibrary";
|
||||
private static final LibraryLoader LOADER =
|
||||
new LibraryLoader("ffmpegJNI") {
|
||||
@Override
|
||||
protected void loadLibrary(String name) {
|
||||
System.loadLibrary(name);
|
||||
}
|
||||
};
|
||||
|
||||
@Nullable private static String version;
|
||||
private static int inputBufferPaddingSize = C.LENGTH_UNSET;
|
||||
|
||||
private FfmpegLibrary() {}
|
||||
|
||||
|
||||
/** Returns whether the JNI adapter and libmpv's FFmpeg libraries can be loaded. */
|
||||
static boolean isAvailable() {
|
||||
return LOADER.isAvailable();
|
||||
}
|
||||
|
||||
/** Returns the linked FFmpeg version. The native libraries must be available. */
|
||||
static String getVersion() {
|
||||
String cachedVersion = version;
|
||||
if (cachedVersion == null) {
|
||||
cachedVersion = ffmpegGetVersion();
|
||||
version = cachedVersion;
|
||||
}
|
||||
return cachedVersion;
|
||||
}
|
||||
|
||||
/** Returns the required FFmpeg input-buffer padding. The native libraries must be available. */
|
||||
static int getInputBufferPaddingSize() {
|
||||
if (inputBufferPaddingSize == C.LENGTH_UNSET) {
|
||||
inputBufferPaddingSize = ffmpegGetInputBufferPaddingSize();
|
||||
}
|
||||
return inputBufferPaddingSize;
|
||||
}
|
||||
|
||||
/** Returns whether the linked FFmpeg build supports the MIME type. */
|
||||
static boolean supportsFormat(String mimeType) {
|
||||
@Nullable String codecName = getCodecName(mimeType);
|
||||
if (codecName == null) {
|
||||
return false;
|
||||
}
|
||||
if (!ffmpegHasDecoder(codecName)) {
|
||||
Log.w(TAG, "No " + codecName + " decoder available in libmpv's FFmpeg build.");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Returns the FFmpeg decoder name for a supported MIME type. */
|
||||
@Nullable
|
||||
static String getCodecName(String mimeType) {
|
||||
switch (mimeType) {
|
||||
case MimeTypes.AUDIO_AAC:
|
||||
return "aac";
|
||||
case MimeTypes.AUDIO_MPEG:
|
||||
case MimeTypes.AUDIO_MPEG_L1:
|
||||
case MimeTypes.AUDIO_MPEG_L2:
|
||||
return "mp3";
|
||||
case MimeTypes.AUDIO_AC3:
|
||||
return "ac3";
|
||||
case MimeTypes.AUDIO_E_AC3:
|
||||
case MimeTypes.AUDIO_E_AC3_JOC:
|
||||
return "eac3";
|
||||
case MimeTypes.AUDIO_TRUEHD:
|
||||
return "truehd";
|
||||
case MimeTypes.AUDIO_DTS:
|
||||
case MimeTypes.AUDIO_DTS_HD:
|
||||
return "dca";
|
||||
case MimeTypes.AUDIO_VORBIS:
|
||||
return "vorbis";
|
||||
case MimeTypes.AUDIO_OPUS:
|
||||
return "opus";
|
||||
case MimeTypes.AUDIO_AMR_NB:
|
||||
return "amrnb";
|
||||
case MimeTypes.AUDIO_AMR_WB:
|
||||
return "amrwb";
|
||||
case MimeTypes.AUDIO_FLAC:
|
||||
return "flac";
|
||||
case MimeTypes.AUDIO_ALAC:
|
||||
return "alac";
|
||||
case MimeTypes.AUDIO_MLAW:
|
||||
return "pcm_mulaw";
|
||||
case MimeTypes.AUDIO_ALAW:
|
||||
return "pcm_alaw";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static native String ffmpegGetVersion();
|
||||
|
||||
private static native int ffmpegGetInputBufferPaddingSize();
|
||||
|
||||
private static native boolean ffmpegHasDecoder(String codecName);
|
||||
}
|
||||
@@ -1,338 +0,0 @@
|
||||
package com.edde746.plezy
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import java.security.MessageDigest
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
internal data class HistoricalExitRecord(
|
||||
val reason: Int,
|
||||
val status: Int,
|
||||
val importance: Int,
|
||||
val timestamp: Long
|
||||
)
|
||||
|
||||
internal object AndroidStartupPhases {
|
||||
const val NATIVE_ON_CREATE = "native_on_create"
|
||||
private val allowed = setOf(
|
||||
NATIVE_ON_CREATE,
|
||||
"dart_main",
|
||||
"runApp",
|
||||
"first_frame",
|
||||
"database_open_started",
|
||||
"database_ready",
|
||||
"credentials_loaded",
|
||||
"binding_started",
|
||||
"binding_settled",
|
||||
"main_screen"
|
||||
)
|
||||
|
||||
fun sanitize(raw: String?): String? = raw?.takeIf(allowed::contains)
|
||||
}
|
||||
|
||||
internal class StartupPhaseStore(
|
||||
readPhase: () -> String?,
|
||||
private val persistPhase: (String) -> Boolean
|
||||
) {
|
||||
val previousPhase: String? = AndroidStartupPhases.sanitize(readPhase())
|
||||
|
||||
@Synchronized
|
||||
fun mark(raw: String?): Boolean {
|
||||
val phase = AndroidStartupPhases.sanitize(raw) ?: return false
|
||||
return persistPhase(phase)
|
||||
}
|
||||
}
|
||||
|
||||
internal data class RuntimeDiagnosticSnapshot(
|
||||
val codecContext: String? = null,
|
||||
val channelCount: Int? = null,
|
||||
val sampleRate: Int? = null,
|
||||
val selectedDecoder: String? = null,
|
||||
val passthroughEnabled: Boolean? = null,
|
||||
val downmixEnabled: Boolean? = null,
|
||||
val normalizationEnabled: Boolean? = null,
|
||||
val uiState: String? = null
|
||||
)
|
||||
|
||||
internal object AndroidRuntimeDiagnostics {
|
||||
const val UI_STARTUP = "startup"
|
||||
const val UI_AUTHENTICATION = "authentication"
|
||||
const val UI_MAIN_SCREEN = "main_screen"
|
||||
const val UI_PLAYER = "player"
|
||||
const val UI_PLAYER_DISPOSED = "player_disposed"
|
||||
|
||||
private const val PREFERENCES_NAME = "plezy_runtime_diagnostics"
|
||||
private const val KEY_CODEC_CONTEXT = "codec_context"
|
||||
private const val KEY_CHANNEL_COUNT = "channel_count"
|
||||
private const val KEY_SAMPLE_RATE = "sample_rate"
|
||||
private const val KEY_SELECTED_DECODER = "selected_decoder"
|
||||
private const val KEY_PASSTHROUGH_ENABLED = "passthrough_enabled"
|
||||
private const val KEY_DOWNMIX_ENABLED = "downmix_enabled"
|
||||
private const val KEY_NORMALIZATION_ENABLED = "normalization_enabled"
|
||||
private const val KEY_UI_STATE = "ui_state"
|
||||
private val allowedCodecContexts = setOf(
|
||||
"audio:aac",
|
||||
"audio:ac3",
|
||||
"audio:eac3",
|
||||
"audio:dts",
|
||||
"audio:truehd",
|
||||
"audio:flac",
|
||||
"audio:pcm",
|
||||
"audio:other",
|
||||
"video:dolby_vision",
|
||||
"video:hevc",
|
||||
"video:avc",
|
||||
"video:other"
|
||||
)
|
||||
private val allowedUiStates = setOf(
|
||||
UI_STARTUP,
|
||||
UI_AUTHENTICATION,
|
||||
UI_MAIN_SCREEN,
|
||||
UI_PLAYER,
|
||||
UI_PLAYER_DISPOSED
|
||||
)
|
||||
private val decoderNamePattern = Regex("[A-Za-z0-9_.:-]{1,96}")
|
||||
private val executor by lazy {
|
||||
Executors.newSingleThreadExecutor { task ->
|
||||
Thread(task, "plezy-runtime-diagnostics").apply { isDaemon = true }
|
||||
}
|
||||
}
|
||||
|
||||
fun codecContextForMime(mimeType: String?): String? {
|
||||
val normalized = mimeType?.lowercase() ?: return null
|
||||
return when (normalized) {
|
||||
"audio/mp4a-latm" -> "audio:aac"
|
||||
"audio/ac3" -> "audio:ac3"
|
||||
"audio/eac3", "audio/eac3-joc" -> "audio:eac3"
|
||||
"audio/vnd.dts", "audio/vnd.dts.hd" -> "audio:dts"
|
||||
"audio/true-hd" -> "audio:truehd"
|
||||
"audio/flac" -> "audio:flac"
|
||||
"audio/raw" -> "audio:pcm"
|
||||
"video/dolby-vision" -> "video:dolby_vision"
|
||||
"video/hevc" -> "video:hevc"
|
||||
"video/avc" -> "video:avc"
|
||||
else -> when {
|
||||
normalized.startsWith("audio/") -> "audio:other"
|
||||
normalized.startsWith("video/") -> "video:other"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sanitizeDecoderName(raw: String?): String? {
|
||||
if (raw == null) return null
|
||||
return raw.takeIf(decoderNamePattern::matches) ?: "unknown"
|
||||
}
|
||||
|
||||
fun sanitizeUiState(raw: String?): String? = raw?.takeIf(allowedUiStates::contains)
|
||||
fun sanitize(snapshot: RuntimeDiagnosticSnapshot): RuntimeDiagnosticSnapshot = RuntimeDiagnosticSnapshot(
|
||||
codecContext = snapshot.codecContext?.takeIf(allowedCodecContexts::contains),
|
||||
channelCount = snapshot.channelCount?.takeIf { it in 1..32 },
|
||||
sampleRate = snapshot.sampleRate?.takeIf { it in 1..768_000 },
|
||||
selectedDecoder = sanitizeDecoderName(snapshot.selectedDecoder),
|
||||
passthroughEnabled = snapshot.passthroughEnabled,
|
||||
downmixEnabled = snapshot.downmixEnabled,
|
||||
normalizationEnabled = snapshot.normalizationEnabled,
|
||||
uiState = sanitizeUiState(snapshot.uiState)
|
||||
)
|
||||
|
||||
fun update(
|
||||
context: Context,
|
||||
codecContext: String? = null,
|
||||
channelCount: Int? = null,
|
||||
sampleRate: Int? = null,
|
||||
selectedDecoder: String? = null,
|
||||
passthroughEnabled: Boolean? = null,
|
||||
downmixEnabled: Boolean? = null,
|
||||
normalizationEnabled: Boolean? = null,
|
||||
uiState: String? = null
|
||||
) {
|
||||
val safeCodecContext = codecContext?.takeIf(allowedCodecContexts::contains)
|
||||
val safeChannelCount = channelCount?.takeIf { it in 1..32 }
|
||||
val safeSampleRate = sampleRate?.takeIf { it in 1..768_000 }
|
||||
val safeDecoder = sanitizeDecoderName(selectedDecoder)
|
||||
val safeUiState = sanitizeUiState(uiState)
|
||||
val applicationContext = context.applicationContext
|
||||
executor.execute {
|
||||
runCatching {
|
||||
applicationContext.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE).edit().apply {
|
||||
safeCodecContext?.let { putString(KEY_CODEC_CONTEXT, it) }
|
||||
safeChannelCount?.let { putInt(KEY_CHANNEL_COUNT, it) }
|
||||
safeSampleRate?.let { putInt(KEY_SAMPLE_RATE, it) }
|
||||
safeDecoder?.let { putString(KEY_SELECTED_DECODER, it) }
|
||||
passthroughEnabled?.let { putBoolean(KEY_PASSTHROUGH_ENABLED, it) }
|
||||
downmixEnabled?.let { putBoolean(KEY_DOWNMIX_ENABLED, it) }
|
||||
normalizationEnabled?.let { putBoolean(KEY_NORMALIZATION_ENABLED, it) }
|
||||
safeUiState?.let { putString(KEY_UI_STATE, it) }
|
||||
}.commit()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearPlayback(context: Context) {
|
||||
val applicationContext = context.applicationContext
|
||||
executor.execute {
|
||||
runCatching {
|
||||
applicationContext.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE).edit()
|
||||
.remove(KEY_CODEC_CONTEXT)
|
||||
.remove(KEY_CHANNEL_COUNT)
|
||||
.remove(KEY_SAMPLE_RATE)
|
||||
.remove(KEY_SELECTED_DECODER)
|
||||
.remove(KEY_PASSTHROUGH_ENABLED)
|
||||
.remove(KEY_DOWNMIX_ENABLED)
|
||||
.remove(KEY_NORMALIZATION_ENABLED)
|
||||
.putString(KEY_UI_STATE, UI_PLAYER_DISPOSED)
|
||||
.commit()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun read(context: Context): RuntimeDiagnosticSnapshot {
|
||||
val preferences = context.applicationContext.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
|
||||
return RuntimeDiagnosticSnapshot(
|
||||
codecContext = runCatching { preferences.getString(KEY_CODEC_CONTEXT, null) }
|
||||
.getOrNull()
|
||||
?.takeIf(allowedCodecContexts::contains),
|
||||
channelCount = runCatching { preferences.getInt(KEY_CHANNEL_COUNT, -1) }.getOrNull()?.takeIf { it in 1..32 },
|
||||
sampleRate = runCatching { preferences.getInt(KEY_SAMPLE_RATE, -1) }.getOrNull()?.takeIf { it in 1..768_000 },
|
||||
selectedDecoder = sanitizeDecoderName(runCatching { preferences.getString(KEY_SELECTED_DECODER, null) }.getOrNull()),
|
||||
passthroughEnabled = readBoolean(preferences, KEY_PASSTHROUGH_ENABLED),
|
||||
downmixEnabled = readBoolean(preferences, KEY_DOWNMIX_ENABLED),
|
||||
normalizationEnabled = readBoolean(preferences, KEY_NORMALIZATION_ENABLED),
|
||||
uiState = sanitizeUiState(runCatching { preferences.getString(KEY_UI_STATE, null) }.getOrNull())
|
||||
)
|
||||
}
|
||||
|
||||
private fun readBoolean(preferences: SharedPreferences, key: String): Boolean? {
|
||||
if (!preferences.contains(key)) return null
|
||||
return runCatching { preferences.getBoolean(key, false) }.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
internal data class PreviousExitReport(
|
||||
val reason: String,
|
||||
val status: Int,
|
||||
val importance: Int,
|
||||
val timestamp: Long,
|
||||
val deviceModel: String,
|
||||
val apiLevel: Int,
|
||||
val abi: String,
|
||||
val lowRam: Boolean,
|
||||
val startupPhase: String?,
|
||||
val runtime: RuntimeDiagnosticSnapshot,
|
||||
val dedupeKey: String
|
||||
) {
|
||||
fun toMap(): Map<String, Any> = buildMap {
|
||||
put("reason", reason)
|
||||
put("status", status)
|
||||
put("importance", importance)
|
||||
put("timestamp", timestamp)
|
||||
put("deviceModel", deviceModel)
|
||||
put("apiLevel", apiLevel)
|
||||
put("abi", abi)
|
||||
put("lowRam", lowRam)
|
||||
startupPhase?.let { put("startupPhase", it) }
|
||||
runtime.codecContext?.let { put("codecContext", it) }
|
||||
runtime.channelCount?.let { put("channelCount", it) }
|
||||
runtime.sampleRate?.let { put("sampleRate", it) }
|
||||
runtime.selectedDecoder?.let { put("selectedDecoder", it) }
|
||||
runtime.passthroughEnabled?.let { put("passthroughEnabled", it) }
|
||||
runtime.downmixEnabled?.let { put("downmixEnabled", it) }
|
||||
runtime.normalizationEnabled?.let { put("normalizationEnabled", it) }
|
||||
runtime.uiState?.let { put("uiState", it) }
|
||||
}
|
||||
}
|
||||
|
||||
internal object AndroidExitReportMapper {
|
||||
private const val REASON_LOW_MEMORY = 3
|
||||
private const val REASON_CRASH = 4
|
||||
private const val REASON_CRASH_NATIVE = 5
|
||||
private const val REASON_ANR = 6
|
||||
private const val REASON_USER_REQUESTED = 10
|
||||
private const val REASON_USER_STOPPED = 11
|
||||
private const val MAX_DEVICE_MODEL_LENGTH = 80
|
||||
private val supportedAbis = setOf("arm64-v8a", "armeabi-v7a", "x86_64", "x86")
|
||||
|
||||
fun map(
|
||||
record: HistoricalExitRecord,
|
||||
deviceModel: String,
|
||||
apiLevel: Int,
|
||||
abi: String,
|
||||
lowRam: Boolean,
|
||||
startupPhase: String? = null,
|
||||
runtime: RuntimeDiagnosticSnapshot = RuntimeDiagnosticSnapshot()
|
||||
): PreviousExitReport {
|
||||
val dedupeKey = sha256(
|
||||
listOf(
|
||||
record.timestamp.toString(),
|
||||
record.reason.toString(),
|
||||
record.status.toString(),
|
||||
record.importance.toString()
|
||||
)
|
||||
)
|
||||
return PreviousExitReport(
|
||||
reason = mapReason(record.reason),
|
||||
status = record.status,
|
||||
importance = record.importance,
|
||||
timestamp = record.timestamp,
|
||||
deviceModel = sanitizeDeviceModel(deviceModel),
|
||||
apiLevel = apiLevel,
|
||||
abi = abi.takeIf(supportedAbis::contains) ?: "unknown",
|
||||
lowRam = lowRam,
|
||||
startupPhase = AndroidStartupPhases.sanitize(startupPhase),
|
||||
runtime = AndroidRuntimeDiagnostics.sanitize(runtime),
|
||||
dedupeKey = dedupeKey
|
||||
)
|
||||
}
|
||||
|
||||
fun mapReason(reason: Int): String = when (reason) {
|
||||
REASON_CRASH -> "crash"
|
||||
REASON_CRASH_NATIVE -> "native_crash"
|
||||
REASON_ANR -> "anr"
|
||||
REASON_LOW_MEMORY -> "low_memory"
|
||||
REASON_USER_REQUESTED, REASON_USER_STOPPED -> "user_requested"
|
||||
else -> "other"
|
||||
}
|
||||
|
||||
fun sanitizeDeviceModel(raw: String): String {
|
||||
val sanitized = buildString(raw.length.coerceAtMost(MAX_DEVICE_MODEL_LENGTH)) {
|
||||
var pendingSpace = false
|
||||
raw.forEach { character ->
|
||||
if (length >= MAX_DEVICE_MODEL_LENGTH) return@forEach
|
||||
if (character.isWhitespace() || Character.isISOControl(character)) {
|
||||
pendingSpace = isNotEmpty()
|
||||
} else {
|
||||
if (pendingSpace && length < MAX_DEVICE_MODEL_LENGTH) append(' ')
|
||||
if (length < MAX_DEVICE_MODEL_LENGTH) append(character)
|
||||
pendingSpace = false
|
||||
}
|
||||
}
|
||||
}.trim()
|
||||
return sanitized.ifEmpty { "unknown" }
|
||||
}
|
||||
|
||||
private fun sha256(fields: List<String>): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
fields.forEach { field ->
|
||||
digest.update(field.length.toString().toByteArray(Charsets.UTF_8))
|
||||
digest.update(':'.code.toByte())
|
||||
digest.update(field.toByteArray(Charsets.UTF_8))
|
||||
digest.update(';'.code.toByte())
|
||||
}
|
||||
return digest.digest().joinToString("") { byte ->
|
||||
(byte.toInt() and 0xff).toString(16).padStart(2, '0')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal class PreviousExitReportStore(
|
||||
private val readDedupeKey: () -> String?,
|
||||
private val persistDedupeKey: (String) -> Boolean
|
||||
) {
|
||||
@Synchronized
|
||||
fun takeIfNew(report: PreviousExitReport): Map<String, Any>? {
|
||||
if (readDedupeKey() == report.dedupeKey) return null
|
||||
if (!persistDedupeKey(report.dedupeKey)) return null
|
||||
return report.toMap()
|
||||
}
|
||||
}
|
||||
@@ -1,256 +0,0 @@
|
||||
package com.edde746.plezy
|
||||
|
||||
import android.app.ActivityManager
|
||||
import android.app.NotificationManager
|
||||
import android.app.usage.UsageStatsManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import android.provider.Settings
|
||||
|
||||
/**
|
||||
* Raw per-signal snapshot of everything the OS is willing to tell us about
|
||||
* whether our own background work will be allowed to run.
|
||||
*
|
||||
* Every field is nullable: a null means "this API level cannot answer", not
|
||||
* "unrestricted". [BackgroundWorkClassifier] treats unknown as OK, because a
|
||||
* warning we cannot substantiate is worse than no warning at all.
|
||||
*/
|
||||
internal data class BackgroundWorkSignals(
|
||||
val sdkInt: Int? = null,
|
||||
val backgroundRestricted: Boolean? = null,
|
||||
val standbyBucket: Int? = null,
|
||||
val notificationsEnabled: Boolean? = null,
|
||||
val downloadChannelBlocked: Boolean? = null,
|
||||
val dataSaverRestricted: Boolean? = null,
|
||||
val ignoringBatteryOptimizations: Boolean? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* Turns [BackgroundWorkSignals] into a verdict plus ordered reasons.
|
||||
*
|
||||
* Deliberately pure and free of `Build.VERSION` reads so the whole decision
|
||||
* table is exercisable from plain JVM unit tests.
|
||||
*/
|
||||
internal object BackgroundWorkClassifier {
|
||||
const val VERDICT_OK = "ok"
|
||||
const val VERDICT_DEGRADED = "degraded"
|
||||
const val VERDICT_BLOCKED = "blocked"
|
||||
|
||||
const val REASON_BACKGROUND_RESTRICTED = "background_restricted"
|
||||
const val REASON_STANDBY_RESTRICTED = "standby_restricted"
|
||||
const val REASON_DOWNLOAD_CHANNEL_BLOCKED = "download_channel_blocked"
|
||||
const val REASON_NOTIFICATIONS_DISABLED = "notifications_disabled"
|
||||
const val REASON_DATA_SAVER = "data_saver"
|
||||
|
||||
/**
|
||||
* `STANDBY_BUCKET_RESTRICTED` (API 30) and `STANDBY_BUCKET_NEVER`. Declared
|
||||
* literally so the classifier stays compilable and testable below those API
|
||||
* levels. `RARE` (40) is intentionally absent — it is reached by ordinary
|
||||
* disuse and says nothing about a user-imposed restriction, so warning on it
|
||||
* would fire on healthy devices.
|
||||
*/
|
||||
const val BUCKET_RESTRICTED = 45
|
||||
const val BUCKET_NEVER = 50
|
||||
|
||||
private val blockingReasons = setOf(
|
||||
REASON_BACKGROUND_RESTRICTED,
|
||||
REASON_STANDBY_RESTRICTED
|
||||
)
|
||||
|
||||
/** Ordered most- to least-actionable; the UI leads with the first entry. */
|
||||
fun reasons(signals: BackgroundWorkSignals): List<String> = buildList {
|
||||
if (signals.backgroundRestricted == true) add(REASON_BACKGROUND_RESTRICTED)
|
||||
if (signals.standbyBucket != null && isRestrictedBucket(signals.standbyBucket)) add(REASON_STANDBY_RESTRICTED)
|
||||
// App-wide denial is more fundamental than a muted channel and routes to
|
||||
// a screen that also exists before the channel has been created.
|
||||
if (signals.notificationsEnabled == false) {
|
||||
add(REASON_NOTIFICATIONS_DISABLED)
|
||||
} else if (signals.downloadChannelBlocked == true) {
|
||||
add(REASON_DOWNLOAD_CHANNEL_BLOCKED)
|
||||
}
|
||||
if (signals.dataSaverRestricted == true) add(REASON_DATA_SAVER)
|
||||
}
|
||||
|
||||
fun verdict(signals: BackgroundWorkSignals, reasons: List<String> = reasons(signals)): String = when {
|
||||
reasons.any(blockingReasons::contains) -> VERDICT_BLOCKED
|
||||
signals.sdkInt != null &&
|
||||
signals.sdkInt >= Build.VERSION_CODES.TIRAMISU &&
|
||||
signals.notificationsEnabled == false -> VERDICT_BLOCKED
|
||||
reasons.isNotEmpty() -> VERDICT_DEGRADED
|
||||
else -> VERDICT_OK
|
||||
}
|
||||
|
||||
fun isRestrictedBucket(bucket: Int): Boolean = bucket == BUCKET_RESTRICTED || bucket == BUCKET_NEVER
|
||||
|
||||
fun toMap(signals: BackgroundWorkSignals): Map<String, Any?> {
|
||||
val reasons = reasons(signals)
|
||||
return mapOf(
|
||||
"verdict" to verdict(signals, reasons),
|
||||
"reasons" to reasons,
|
||||
"sdkInt" to signals.sdkInt,
|
||||
"backgroundRestricted" to signals.backgroundRestricted,
|
||||
"standbyBucket" to signals.standbyBucket,
|
||||
"notificationsEnabled" to signals.notificationsEnabled,
|
||||
"downloadChannelBlocked" to signals.downloadChannelBlocked,
|
||||
"dataSaverRestricted" to signals.dataSaverRestricted,
|
||||
"ignoringBatteryOptimizations" to signals.ignoringBatteryOptimizations
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Settings screen a remedy button can send the user to. */
|
||||
internal enum class BackgroundSettingsTarget(val id: String) {
|
||||
APP_DETAILS("app_details"),
|
||||
APP_NOTIFICATIONS("app_notifications"),
|
||||
NOTIFICATION_CHANNEL("notification_channel");
|
||||
|
||||
companion object {
|
||||
fun fromId(raw: String?): BackgroundSettingsTarget? = entries.firstOrNull { it.id == raw }
|
||||
}
|
||||
}
|
||||
|
||||
/** Declarative intent description, kept Intent-free so it unit-tests on the JVM. */
|
||||
internal data class SettingsIntentSpec(
|
||||
val action: String,
|
||||
val data: String? = null,
|
||||
val stringExtras: Map<String, String> = emptyMap()
|
||||
)
|
||||
|
||||
/**
|
||||
* Ordered candidate intents per target, each falling back to the app details
|
||||
* page — the one Settings screen that exists on every Android build and that
|
||||
* reaches Battery → Unrestricted on One UI, Pixel, and AOSP alike.
|
||||
*
|
||||
* Samsung's own "Background usage limits" screen lives in an unexported
|
||||
* `com.samsung.android.lool` activity that moves between One UI releases, so
|
||||
* it is deliberately not targeted; the user gets written directions instead.
|
||||
*/
|
||||
internal object BackgroundSettingsIntents {
|
||||
fun specsFor(
|
||||
target: BackgroundSettingsTarget,
|
||||
packageName: String,
|
||||
sdkInt: Int
|
||||
): List<SettingsIntentSpec> {
|
||||
val appDetails = SettingsIntentSpec(
|
||||
action = Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
|
||||
data = "package:$packageName"
|
||||
)
|
||||
if (sdkInt < Build.VERSION_CODES.O) return listOf(appDetails)
|
||||
return when (target) {
|
||||
BackgroundSettingsTarget.APP_DETAILS -> listOf(appDetails)
|
||||
BackgroundSettingsTarget.APP_NOTIFICATIONS -> listOf(
|
||||
SettingsIntentSpec(
|
||||
action = Settings.ACTION_APP_NOTIFICATION_SETTINGS,
|
||||
stringExtras = mapOf(Settings.EXTRA_APP_PACKAGE to packageName)
|
||||
),
|
||||
appDetails
|
||||
)
|
||||
BackgroundSettingsTarget.NOTIFICATION_CHANNEL -> listOf(
|
||||
SettingsIntentSpec(
|
||||
action = Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS,
|
||||
stringExtras = mapOf(
|
||||
Settings.EXTRA_APP_PACKAGE to packageName,
|
||||
Settings.EXTRA_CHANNEL_ID to BackgroundWorkDiagnostics.DOWNLOAD_NOTIFICATION_CHANNEL_ID
|
||||
)
|
||||
),
|
||||
SettingsIntentSpec(
|
||||
action = Settings.ACTION_APP_NOTIFICATION_SETTINGS,
|
||||
stringExtras = mapOf(Settings.EXTRA_APP_PACKAGE to packageName)
|
||||
),
|
||||
appDetails
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the OS-visible reasons background downloads may be blocked.
|
||||
*
|
||||
* Every read is for our own package and needs no permission. Notably absent is
|
||||
* `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS`: it is a Play-policy-restricted
|
||||
* permission whose qualifying use cases do not include media downloading.
|
||||
* Whitelist state is reported for support diagnostics only.
|
||||
*/
|
||||
internal object BackgroundWorkDiagnostics {
|
||||
/** `background_downloader`'s channel id (`Notifications.kt`, `nId`). */
|
||||
const val DOWNLOAD_NOTIFICATION_CHANNEL_ID = "background_downloader"
|
||||
|
||||
fun read(context: Context): BackgroundWorkSignals = BackgroundWorkSignals(
|
||||
sdkInt = Build.VERSION.SDK_INT,
|
||||
backgroundRestricted = readBackgroundRestricted(context),
|
||||
standbyBucket = readStandbyBucket(context),
|
||||
notificationsEnabled = readNotificationsEnabled(context),
|
||||
downloadChannelBlocked = readDownloadChannelBlocked(context),
|
||||
dataSaverRestricted = readDataSaverRestricted(context),
|
||||
ignoringBatteryOptimizations = readIgnoringBatteryOptimizations(context)
|
||||
)
|
||||
|
||||
private fun readBackgroundRestricted(context: Context): Boolean? {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) return null
|
||||
return runCatching {
|
||||
(context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager).isBackgroundRestricted
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun readStandbyBucket(context: Context): Int? {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) return null
|
||||
return runCatching {
|
||||
(context.getSystemService(Context.USAGE_STATS_SERVICE) as UsageStatsManager).appStandbyBucket
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun readNotificationsEnabled(context: Context): Boolean? = runCatching {
|
||||
(context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).areNotificationsEnabled()
|
||||
}.getOrNull()
|
||||
|
||||
/**
|
||||
* Null until the plugin has created its channel (first download), which is
|
||||
* correct — there is nothing to warn about before then.
|
||||
*/
|
||||
private fun readDownloadChannelBlocked(context: Context): Boolean? {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return null
|
||||
return runCatching {
|
||||
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
val channel = manager.getNotificationChannel(DOWNLOAD_NOTIFICATION_CHANNEL_ID) ?: return null
|
||||
channel.importance == NotificationManager.IMPORTANCE_NONE
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun readDataSaverRestricted(context: Context): Boolean? {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return null
|
||||
return runCatching {
|
||||
val connectivity = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
connectivity.restrictBackgroundStatus == ConnectivityManager.RESTRICT_BACKGROUND_STATUS_ENABLED
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun readIgnoringBatteryOptimizations(context: Context): Boolean? = runCatching {
|
||||
(context.getSystemService(Context.POWER_SERVICE) as PowerManager)
|
||||
.isIgnoringBatteryOptimizations(context.packageName)
|
||||
}.getOrNull()
|
||||
|
||||
/**
|
||||
* Launches the first candidate Settings screen that resolves. Returns false
|
||||
* when the device has none of them — some TV and Fire OS builds ship without
|
||||
* a battery settings activity entirely.
|
||||
*/
|
||||
fun openSettings(context: Context, target: BackgroundSettingsTarget): Boolean {
|
||||
for (spec in BackgroundSettingsIntents.specsFor(target, context.packageName, Build.VERSION.SDK_INT)) {
|
||||
val launched = runCatching {
|
||||
val intent = Intent(spec.action).apply {
|
||||
spec.data?.let { data = Uri.parse(it) }
|
||||
spec.stringExtras.forEach { (key, value) -> putExtra(key, value) }
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
true
|
||||
}.getOrDefault(false)
|
||||
if (launched) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
package com.edde746.plezy
|
||||
|
||||
import android.graphics.SurfaceTexture
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.TextureView
|
||||
|
||||
internal class DeferredSurfaceTextureListener(
|
||||
private val delegate: TextureView.SurfaceTextureListener,
|
||||
private val handler: Handler = Handler(Looper.getMainLooper()),
|
||||
private val onSurfaceAvailable: () -> Unit = {},
|
||||
private val isCurrentSurface: (SurfaceTexture) -> Boolean
|
||||
) : TextureView.SurfaceTextureListener {
|
||||
|
||||
private var pendingResize: Runnable? = null
|
||||
|
||||
override fun onSurfaceTextureAvailable(surface: SurfaceTexture, width: Int, height: Int) {
|
||||
delegate.onSurfaceTextureAvailable(surface, width, height)
|
||||
onSurfaceAvailable()
|
||||
}
|
||||
|
||||
override fun onSurfaceTextureSizeChanged(surface: SurfaceTexture, width: Int, height: Int) {
|
||||
pendingResize?.let(handler::removeCallbacks)
|
||||
pendingResize = Runnable {
|
||||
pendingResize = null
|
||||
if (isCurrentSurface(surface)) {
|
||||
delegate.onSurfaceTextureSizeChanged(surface, width, height)
|
||||
}
|
||||
}
|
||||
handler.postDelayed(pendingResize!!, RESIZE_DELAY_MILLIS)
|
||||
}
|
||||
|
||||
override fun onSurfaceTextureUpdated(surface: SurfaceTexture) {
|
||||
delegate.onSurfaceTextureUpdated(surface)
|
||||
}
|
||||
|
||||
override fun onSurfaceTextureDestroyed(surface: SurfaceTexture): Boolean {
|
||||
pendingResize?.let(handler::removeCallbacks)
|
||||
pendingResize = null
|
||||
return delegate.onSurfaceTextureDestroyed(surface)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val RESIZE_DELAY_MILLIS = 100L
|
||||
}
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
package com.edde746.plezy
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import androidx.core.content.FileProvider
|
||||
import io.flutter.plugin.common.BinaryMessenger
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import java.io.File
|
||||
|
||||
internal class ExternalPlayerChannel(private val activity: Activity) {
|
||||
companion object {
|
||||
private const val CHANNEL = "com.plezy/external_player"
|
||||
const val REQUEST_CODE = 7461
|
||||
|
||||
private const val API_MX_RETURN_RESULT = "return_result"
|
||||
private const val API_MX_RESULT_ID = "com.mxtech.intent.result.VIEW"
|
||||
private const val API_MX_RESULT_POSITION = "position"
|
||||
private const val API_MX_RESULT_DURATION = "duration"
|
||||
private const val API_MX_RESULT_END_BY = "end_by"
|
||||
private const val API_MX_RESULT_END_BY_PLAYBACK_COMPLETION = "playback_completion"
|
||||
private const val API_MX_TITLE = "title"
|
||||
private const val API_MX_FILENAME = "filename"
|
||||
private const val API_MX_SECURE_URI = "secure_uri"
|
||||
private const val API_VLC_RESULT_POSITION = "extra_position"
|
||||
private const val API_VLC_RESULT_DURATION = "extra_duration"
|
||||
|
||||
private const val API_VIMU_TITLE = "forcename"
|
||||
private const val API_VIMU_SEEK_POSITION = "startfrom"
|
||||
private const val API_VIMU_RESUME = "forceresume"
|
||||
private const val API_VIMU_RESULT_ID = "net.gtvbox.videoplayer.result"
|
||||
private const val API_VIMU_RESULT_ERROR = 4
|
||||
private const val API_VIMU_RESULT_PLAYBACK_COMPLETED = 1
|
||||
|
||||
private val positionExtras = arrayOf(API_MX_RESULT_POSITION, API_VLC_RESULT_POSITION)
|
||||
private val durationExtras = arrayOf(API_MX_RESULT_DURATION, API_VLC_RESULT_DURATION)
|
||||
}
|
||||
|
||||
private var pendingResult: MethodChannel.Result? = null
|
||||
|
||||
fun attach(messenger: BinaryMessenger) {
|
||||
MethodChannel(messenger, CHANNEL).setMethodCallHandler(::onMethodCall)
|
||||
}
|
||||
|
||||
fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
|
||||
if (requestCode != REQUEST_CODE) return false
|
||||
val result = pendingResult
|
||||
pendingResult = null
|
||||
if (result == null) {
|
||||
android.util.Log.w("ExternalPlayerChannel", "Result received without a pending channel call")
|
||||
} else {
|
||||
result.success(buildResult(resultCode, data))
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun dispose() {
|
||||
pendingResult?.error("ACTIVITY_DESTROYED", "Activity was destroyed while external player was active", null)
|
||||
pendingResult = null
|
||||
}
|
||||
|
||||
internal fun buildResult(resultCode: Int, data: Intent?): Map<String, Any?> {
|
||||
val extras = data?.extras
|
||||
val endPosition = firstNumberExtra(extras, positionExtras)
|
||||
val duration = firstNumberExtra(extras, durationExtras)
|
||||
val action = data?.action
|
||||
val playbackCompleted = when (action) {
|
||||
API_MX_RESULT_ID -> extras?.getString(API_MX_RESULT_END_BY) == API_MX_RESULT_END_BY_PLAYBACK_COMPLETION
|
||||
API_VIMU_RESULT_ID -> resultCode == API_VIMU_RESULT_PLAYBACK_COMPLETED
|
||||
else -> false
|
||||
}
|
||||
val playbackError = action == API_VIMU_RESULT_ID && resultCode == API_VIMU_RESULT_ERROR
|
||||
|
||||
return mapOf(
|
||||
"launched" to true,
|
||||
"resultCode" to resultCode,
|
||||
"resultOk" to (resultCode == Activity.RESULT_OK),
|
||||
"action" to action,
|
||||
"positionMs" to endPosition,
|
||||
"durationMs" to duration,
|
||||
"playbackCompleted" to playbackCompleted,
|
||||
"playbackError" to playbackError
|
||||
)
|
||||
}
|
||||
|
||||
private fun firstNumberExtra(extras: Bundle?, keys: Array<String>): Long? {
|
||||
if (extras == null) return null
|
||||
for (key in keys) {
|
||||
@Suppress("DEPRECATION")
|
||||
val value = extras.get(key)
|
||||
when (value) {
|
||||
is Number -> return value.toLong()
|
||||
is String -> value.toLongOrNull()?.let { return it }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
if (call.method != "openVideo") {
|
||||
result.notImplemented()
|
||||
return
|
||||
}
|
||||
|
||||
val filePath = call.argument<String>("filePath")
|
||||
val packageNames = call.argument<List<Any?>>("packages")
|
||||
?.mapNotNull { (it as? String)?.trim()?.takeIf(String::isNotEmpty) }
|
||||
?: emptyList()
|
||||
val title = call.argument<String>("title")?.trim()?.takeIf(String::isNotEmpty)
|
||||
val startPositionMs = call.argument<Number>("startPositionMs")?.toLong() ?: 0L
|
||||
|
||||
if (filePath == null) {
|
||||
result.error("INVALID_ARGUMENT", "filePath is required", null)
|
||||
return
|
||||
}
|
||||
if (pendingResult != null) {
|
||||
result.error("ALREADY_ACTIVE", "An external player is already active", null)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val source = resolveSource(filePath)
|
||||
val targetPackages = if (packageNames.isEmpty()) listOf<String?>(null) else packageNames
|
||||
for (packageName in targetPackages) {
|
||||
try {
|
||||
pendingResult = result
|
||||
activity.startActivityForResult(
|
||||
buildIntent(source, packageName, startPositionMs, title),
|
||||
REQUEST_CODE
|
||||
)
|
||||
return
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
pendingResult = null
|
||||
}
|
||||
}
|
||||
|
||||
val message = if (packageNames.isEmpty()) {
|
||||
"No app found for video"
|
||||
} else {
|
||||
"No app found for packages: ${packageNames.joinToString(", ")}"
|
||||
}
|
||||
result.error("APP_NOT_FOUND", message, null)
|
||||
} catch (error: Exception) {
|
||||
pendingResult = null
|
||||
result.error("LAUNCH_FAILED", error.message ?: error.javaClass.simpleName, null)
|
||||
}
|
||||
}
|
||||
|
||||
private data class Source(val uri: Uri, val grantRead: Boolean, val fileName: String?)
|
||||
|
||||
private fun resolveSource(filePath: String): Source {
|
||||
if (filePath.startsWith("http://") || filePath.startsWith("https://")) {
|
||||
val uri = Uri.parse(filePath)
|
||||
return Source(uri, grantRead = false, fileName = uri.lastPathSegment)
|
||||
}
|
||||
if (filePath.startsWith("content://")) {
|
||||
val uri = Uri.parse(filePath)
|
||||
return Source(uri, grantRead = true, fileName = uri.lastPathSegment)
|
||||
}
|
||||
|
||||
val path = if (filePath.startsWith("file://")) filePath.removePrefix("file://") else filePath
|
||||
val file = File(path)
|
||||
val uri = FileProvider.getUriForFile(activity, "com.edde746.plezy.fileprovider", file)
|
||||
return Source(uri, grantRead = true, fileName = file.name)
|
||||
}
|
||||
|
||||
private fun buildIntent(
|
||||
source: Source,
|
||||
packageName: String?,
|
||||
startPositionMs: Long,
|
||||
title: String?
|
||||
): Intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(source.uri, "video/*")
|
||||
if (source.grantRead) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
packageName?.let(::setPackage)
|
||||
val startPosition = startPositionMs.coerceAtLeast(0).coerceAtMost(Int.MAX_VALUE.toLong()).toInt()
|
||||
if (startPosition > 0) {
|
||||
putExtra(API_MX_RESULT_POSITION, startPosition)
|
||||
putExtra(API_VIMU_SEEK_POSITION, startPosition)
|
||||
}
|
||||
putExtra(API_MX_RETURN_RESULT, true)
|
||||
putExtra(API_MX_SECURE_URI, true)
|
||||
putExtra(API_VIMU_RESUME, false)
|
||||
title?.let {
|
||||
putExtra(API_MX_TITLE, it)
|
||||
putExtra(API_VIMU_TITLE, it)
|
||||
}
|
||||
source.fileName?.let { putExtra(API_MX_FILENAME, it) }
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
package com.edde746.plezy
|
||||
|
||||
internal enum class FlutterRenderer(
|
||||
val diagnosticName: String,
|
||||
val shellArgument: String?
|
||||
) {
|
||||
SKIA("Skia", "--enable-impeller=false"),
|
||||
IMPELLER("Impeller", null)
|
||||
}
|
||||
|
||||
/** Selects the Flutter UI renderer before the engine starts. */
|
||||
internal object FlutterRendererPolicy {
|
||||
private const val ANDROID_12_API = 31
|
||||
|
||||
fun select(
|
||||
isEWaste: Boolean,
|
||||
manufacturer: String,
|
||||
isAndroidTv: Boolean,
|
||||
sdkInt: Int,
|
||||
supportsVulkan11: Boolean,
|
||||
is64Bit: Boolean
|
||||
): FlutterRenderer {
|
||||
if (isEWaste) return FlutterRenderer.SKIA
|
||||
if (manufacturer.equals("NVIDIA", ignoreCase = true)) return FlutterRenderer.SKIA
|
||||
if (manufacturer.equals("Huawei", ignoreCase = true) ||
|
||||
manufacturer.equals("HONOR", ignoreCase = true)
|
||||
) {
|
||||
return FlutterRenderer.SKIA
|
||||
}
|
||||
if (!isAndroidTv) return FlutterRenderer.IMPELLER
|
||||
if (sdkInt < ANDROID_12_API || manufacturer.equals("Amazon", ignoreCase = true) || !supportsVulkan11) {
|
||||
return FlutterRenderer.SKIA
|
||||
}
|
||||
|
||||
// 32-bit Android TV SoCs are the low-memory / low-throughput class. Skia avoids
|
||||
// Impeller/Vulkan's substantially higher raster cost on these devices.
|
||||
if (!is64Bit) return FlutterRenderer.SKIA
|
||||
|
||||
return FlutterRenderer.IMPELLER
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user