Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3a35d56d9 |
@@ -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/maestro_fixtures.py
|
||||
!scripts/maestro/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,124 +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, Plex or Emby?
|
||||
multiple: true
|
||||
options:
|
||||
- Jellyfin
|
||||
- Plex
|
||||
- Emby
|
||||
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,83 +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
|
||||
- Emby
|
||||
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
|
||||
(5d531788). 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.47.1"
|
||||
$expectedCommit = "6655482ec06e547f90abf8ae7590466f4415978d"
|
||||
$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
|
||||
+141
-794
File diff suppressed because it is too large
Load Diff
@@ -1,763 +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:
|
||||
inputs:
|
||||
build_linux_packages:
|
||||
description: >
|
||||
Smoke-build the Linux deb/rpm/pacman packages. Off by default: it needs
|
||||
a release build plus fpm, and build.yml only packages from main, so this
|
||||
is the only way to exercise linux/packaging from a branch.
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
env:
|
||||
# Only place this workflow names the SDK; .github/actions/setup-flutter-git pins the same release.
|
||||
FLUTTER_VERSION: "3.47.1"
|
||||
|
||||
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/checks/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/checks/check_icon_consistency.dart
|
||||
|
||||
- name: Analyze code
|
||||
run: dart run scripts/checks/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@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # 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@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v5
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: "17"
|
||||
|
||||
- name: Verify native formatting
|
||||
run: scripts/format_native.sh --check
|
||||
|
||||
- name: Verify Linux native acquisition and build plan
|
||||
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 \
|
||||
libwayland-dev libegl-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 }}
|
||||
|
||||
# `plezy` is the runner itself. Without it nothing in CI ever compiles
|
||||
# my_application.cc, mpv_plugin.cc or the Wayland video plane — the
|
||||
# reliability test targets each pull in only a couple of translation units,
|
||||
# so a break in the rest of linux/runner reached a release build unseen.
|
||||
- name: Build Linux native reliability tests
|
||||
run: |
|
||||
cmake --build build/linux-native-${{ matrix.sanitizer }} --parallel 2 --target \
|
||||
plezy \
|
||||
mpv_player_lifecycle_test \
|
||||
mpv_player_hdr_output_test \
|
||||
mpv_property_result_contract_test \
|
||||
hdr_metadata_test \
|
||||
plane_geometry_test \
|
||||
video_params_test \
|
||||
plane_render_executor_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@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
|
||||
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
|
||||
|
||||
|
||||
# The only job that checks packaging against a real artifact. build.yml also
|
||||
# packages Linux, but refuses any ref but refs/heads/main, and
|
||||
# check_linux_package_deps.py can only compare a hand-written list against
|
||||
# CMake - it cannot prove the list reaches the artifact, nor see the
|
||||
# transitive libraries the bundled libmpv drags in. This can, by reading the
|
||||
# built bundle back with ldd.
|
||||
#
|
||||
# Runs on every push to main and on request, but not on pull requests: it
|
||||
# needs a release build plus fpm, which is minutes of runner time that most
|
||||
# changes here have no reason to pay. That buys post-merge detection rather
|
||||
# than pre-merge, which is the deliberate trade. Dispatch it from a branch
|
||||
# with build_linux_packages when touching linux/packaging - which is the only
|
||||
# way to exercise it before merging.
|
||||
linux-packages:
|
||||
name: Linux package smoke build
|
||||
if: ${{ inputs.build_linux_packages || github.event_name == 'push' }}
|
||||
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
|
||||
|
||||
# The packaging deps, minus libmpv: this job builds it from source below,
|
||||
# because the distro's is a different version with different windowing
|
||||
# backends, and a package smoke-built against it cannot show that
|
||||
# build-libmpv.sh still works or that the bundle it produces is coherent.
|
||||
- name: Install packaging dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
clang cmake meson ninja-build pkg-config nasm libgtk-3-dev liblzma-dev \
|
||||
libstdc++-12-dev libepoxy-dev libcurl4-openssl-dev libevdev-dev \
|
||||
libasound2-dev libass-dev libfreetype-dev libfontconfig-dev libfribidi-dev \
|
||||
libharfbuzz-dev libegl-dev libgl-dev libgnutls28-dev libpipewire-0.3-dev \
|
||||
libva-dev libxkbcommon-dev libpulse-dev libdbus-1-dev libdrm-dev \
|
||||
libdisplay-info-dev libgbm-dev libwayland-dev wayland-protocols liblcms2-dev libmujs-dev \
|
||||
liblua5.2-dev rpm libarchive-tools imagemagick ruby-dev build-essential
|
||||
sudo gem install fpm --version 1.17.0 --no-document
|
||||
|
||||
# Keyed the same way build.yml keys it, so editing the script or its pinned
|
||||
# inputs is what invalidates the cache - and this branch's whole point is
|
||||
# that those edits get exercised somewhere.
|
||||
- name: Cache libmpv build
|
||||
id: libmpv-cache
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: libmpv-prefix
|
||||
key: ci-libmpv-${{ runner.arch }}-${{ hashFiles('linux/packaging/build-libmpv.sh', 'linux/packaging/native-inputs.json') }}
|
||||
|
||||
- name: Build libmpv
|
||||
if: steps.libmpv-cache.outputs.cache-hit != 'true'
|
||||
run: bash linux/packaging/build-libmpv.sh
|
||||
|
||||
# The windowing backends the runner depends on, read off the library the
|
||||
# script just produced. The plane hands mpv MPV_RENDER_PARAM_WL_DISPLAY, so
|
||||
# a libmpv without Wayland cannot find the VAAPI device and quietly decodes
|
||||
# in software; X11 and VDPAU are gone with the texture path.
|
||||
- name: Check the built libmpv's backends
|
||||
run: |
|
||||
LIB=$(find libmpv-prefix -name 'libmpv.so.2' | head -1)
|
||||
echo "== $LIB =="
|
||||
ldd "$LIB" | grep -iE 'wayland|libX11|vdpau' || echo '(none)'
|
||||
ldd "$LIB" | grep -q libwayland-client || {
|
||||
echo "::error::the built libmpv does not link libwayland-client, so the video plane cannot work"
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Build the release bundle
|
||||
run: |
|
||||
flutter pub get --enforce-lockfile --no-example
|
||||
flutter build linux --release
|
||||
env:
|
||||
PKG_CONFIG_PATH: ${{ github.workspace }}/libmpv-prefix/lib/pkgconfig:${{ github.workspace }}/libmpv-prefix/lib/x86_64-linux-gnu/pkgconfig
|
||||
|
||||
# Mirrors build.yml: resolve the bundle completely, then package from it.
|
||||
# libmpv travels with us, so nothing depends on a host one - which also
|
||||
# removes the transitive pull of everything libmpv links, hence bundle-libs
|
||||
# before packaging rather than after.
|
||||
- name: Copy libmpv into the bundle
|
||||
run: |
|
||||
BUNDLE_LIB=build/linux/x64/release/bundle/lib
|
||||
LIBMPV_DIR=$(dirname "$(find libmpv-prefix -name 'libmpv.so' | head -1)")
|
||||
cp -a "$LIBMPV_DIR"/libmpv.so* "$BUNDLE_LIB/"
|
||||
cp -a libmpv-prefix/lib/libshaderc_shared.so* "$BUNDLE_LIB/"
|
||||
|
||||
- name: Bundle shared libraries
|
||||
run: bash linux/packaging/bundle-libs.sh build/linux/x64/release/bundle
|
||||
|
||||
- name: Copy wrapper script into the bundle
|
||||
run: cp linux/packaging/plezy.sh build/linux/x64/release/bundle/plezy.sh
|
||||
|
||||
# Derives what the resolved bundle still needs from the host and proves
|
||||
# every one of those libraries is declared. This is the check that would
|
||||
# have caught bundling libmpv without also declaring what libmpv links.
|
||||
- name: Verify every unbundled library the bundle needs is declared
|
||||
run: python3 linux/packaging/check-bundle-host-deps.py build/linux/x64/release/bundle
|
||||
|
||||
# OUTPUT_DIR defaults to the repo root; name it so the paths below are not
|
||||
# a guess about where fpm dropped things. The host-dependency guard is
|
||||
# skipped because the named step above just ran it against this same
|
||||
# bundle; the internal run exists for by-hand packaging outside CI.
|
||||
- name: Build the packages
|
||||
run: |
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
python3 linux/packaging/build-packages.py
|
||||
env:
|
||||
OUTPUT_DIR: ${{ github.workspace }}/packages
|
||||
PLEZY_SKIP_HOST_DEP_CHECK: "1"
|
||||
|
||||
# The guard above reconciles the depends lists with the staged bundle; only
|
||||
# the packages themselves can show that list survived fpm. Every name comes
|
||||
# from build-packages.py, so adding a library there is verified here without
|
||||
# a second edit - and this job is off by default, so a hand-copied list
|
||||
# would rot unseen. The release job in build.yml runs the same script
|
||||
# against the artifacts users install, so the assertions cannot drift.
|
||||
- name: Verify the declared dependencies reached the package metadata
|
||||
run: python3 linux/packaging/check-package-deps.py "${{ github.workspace }}/packages"
|
||||
|
||||
# Same resolved bundle the packages were cut from, so the tarball is not a
|
||||
# second, differently-assembled artifact. It is the one to put on a USB for
|
||||
# a foreign machine, because it needs nothing installed.
|
||||
- name: Create the tarball
|
||||
run: |
|
||||
BUNDLE_DIR=build/linux/x64/release/bundle
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
tar -czf "$OUTPUT_DIR/plezy-linux-x64.tar.gz" -C "$BUNDLE_DIR" .
|
||||
echo "=== libmpv travelling in every artifact ==="
|
||||
ldd "$BUNDLE_DIR/lib/libmpv.so" | grep -iE 'wayland|libX11|vdpau' || echo '(none)'
|
||||
env:
|
||||
OUTPUT_DIR: ${{ github.workspace }}/packages
|
||||
|
||||
- name: Upload the packages
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: linux-packages-smoke
|
||||
path: ${{ github.workspace }}/packages/plezy-linux-x64.*
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
@@ -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
|
||||
+3
-24
@@ -18,7 +18,9 @@ migrate_working_dir/
|
||||
*.iws
|
||||
.idea/
|
||||
|
||||
# Keep .vscode/ available for contributors' local configuration.
|
||||
# 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
|
||||
@@ -30,26 +32,6 @@ migrate_working_dir/
|
||||
.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
|
||||
@@ -72,6 +54,3 @@ duplication-report/
|
||||
**/fastlane/**/
|
||||
**/fastlane/report.xml
|
||||
maestro/**/*.png
|
||||
|
||||
server/deploy.sh
|
||||
server/Caddyfile
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"threshold": 5,
|
||||
"reporters": ["html", "console"],
|
||||
"ignore": [
|
||||
"**/*.g.dart",
|
||||
"**/*.freezed.dart",
|
||||
"**/*.mocks.dart",
|
||||
"**/generated/**",
|
||||
"**/*.config.dart"
|
||||
],
|
||||
"absolute": true,
|
||||
"gitignore": true,
|
||||
"format": ["dart"],
|
||||
"minLines": 5,
|
||||
"minTokens": 50,
|
||||
"output": "./duplication-report",
|
||||
"mode": "strict",
|
||||
"formatsExts": {
|
||||
"dart": ["dart"]
|
||||
}
|
||||
}
|
||||
@@ -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/maestro_fixtures.py scripts/maestro/maestro_real_jellyfin.py scripts/maestro/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,76 +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"
|
||||
# Wait for the row before opening the sheet so occlusion is tested against the
|
||||
# grid, not an absent or stale match.
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Alpha Archive.*"
|
||||
timeout: 15000
|
||||
- pressKey: "Remote Dpad Center"
|
||||
- extendedWaitUntil:
|
||||
visible: "Title"
|
||||
timeout: 10000
|
||||
# The grid remains mounted under the Stack; BlockSemantics is required for
|
||||
# occluded rows to disappear from Maestro's accessibility tree.
|
||||
- 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,72 +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"
|
||||
# TV playback starts with chrome hidden (#1765), so wait for its loading label
|
||||
# rather than treating the Pause button as readiness. A failed open reaches the
|
||||
# transport assertions below.
|
||||
- extendedWaitUntil:
|
||||
notVisible: "Overview"
|
||||
timeout: 30000
|
||||
- extendedWaitUntil:
|
||||
notVisible: "Loading video"
|
||||
timeout: 30000
|
||||
- assertNotVisible: "(?s)^(Play|Pause)$"
|
||||
# Transport keys keep chrome hidden (#1676); pause first so seeks cannot race
|
||||
# the end of this short fixture.
|
||||
- pressKey: "Remote Media Play Pause"
|
||||
- extendedWaitUntil:
|
||||
visible: "Paused"
|
||||
timeout: 10000
|
||||
- assertNotVisible: "(?s)^Play$"
|
||||
# D-pad seeks use the cumulative skip badge, not the scrub bar.
|
||||
- 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 is the explicit way to restore the chrome.
|
||||
- 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,71 +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
|
||||
# Reach Search by D-pad: taps switch to pointer mode and hide the TV rail label,
|
||||
# while percentage coordinates are not portable across TV sizes.
|
||||
- pressKey: "Remote Dpad Left"
|
||||
- extendedWaitUntil:
|
||||
visible: "Search"
|
||||
timeout: 15000
|
||||
- repeat:
|
||||
times: 12
|
||||
while:
|
||||
notVisible:
|
||||
text: "Search"
|
||||
focused: true
|
||||
commands:
|
||||
- pressKey: "Remote Dpad Down"
|
||||
- pressKey: "Remote Dpad Center"
|
||||
- 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$"
|
||||
# The player starts with chrome hidden (#1765); wait for its loading label
|
||||
# rather than using Pause as readiness.
|
||||
- extendedWaitUntil:
|
||||
notVisible: "(?s)^Play S1E1$"
|
||||
timeout: 30000
|
||||
- extendedWaitUntil:
|
||||
notVisible: "Loading video"
|
||||
timeout: 30000
|
||||
- pressKey: "Remote Media Fast Forward"
|
||||
- pressKey: "Remote Media Fast Forward"
|
||||
# Wait for Cancel, not Next Episode (the credits-skip action), before pressing Back.
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Cancel$"
|
||||
timeout: 20000
|
||||
- runFlow:
|
||||
when:
|
||||
platform: iOS
|
||||
commands:
|
||||
- tapOn: "Cancel"
|
||||
- runFlow:
|
||||
when:
|
||||
platform: Android
|
||||
commands:
|
||||
- pressKey: "back"
|
||||
# Dismissal is animated, so give it a bounded wait rather than one instant look.
|
||||
- extendedWaitUntil:
|
||||
notVisible: "(?s)^Cancel$"
|
||||
timeout: 10000
|
||||
- assertNotVisible: "Overview"
|
||||
- pressKey: "Remote Dpad Up"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Maestro Episode 1.*"
|
||||
timeout: 10000
|
||||
@@ -1,34 +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.*"
|
||||
# All seeded titles share dateadded, so accept any playable Recently Added movie
|
||||
# instead of relying on an unstable tie-break.
|
||||
- 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,79 +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: phone Settings must host the sheet so one Back
|
||||
# dismisses only the sheet, while BlockSemantics hides rows behind the barrier.
|
||||
# Start clean because preceding TV flows may leave Force TV mode enabled.
|
||||
- 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 removes covered rows from semantics; the sheet title remains
|
||||
# visible because it belongs to the sheet.
|
||||
- assertNotVisible: "(?s)^Services.*"
|
||||
- assertNotVisible: "(?s)^Video Playback.*"
|
||||
- assertNotVisible: "(?s)^Appearance.*"
|
||||
- assertNotVisible: "(?s)^Connections.*"
|
||||
- back
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- assertNotVisible: "Maestro Movies"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Manage Libraries.*"
|
||||
timeout: 10000
|
||||
- assertVisible: "(?s)^Services.*"
|
||||
- assertVisible: "(?s)^Video Playback.*"
|
||||
- assertNotVisible: "Discover"
|
||||
# Reopening verifies the one-shot pop deduplication marker was not stranded.
|
||||
- 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,102 +0,0 @@
|
||||
appId: com.edde746.plezy
|
||||
name: An explicit track choice survives the pending automatic pass
|
||||
|
||||
tags:
|
||||
- e2e
|
||||
- regression
|
||||
- playback
|
||||
- subtitles
|
||||
---
|
||||
# Guards 468d6804: a user pick must retire TrackManager's pending automatic
|
||||
# selection pass, or the server preference can overwrite it up to 30s later.
|
||||
# The codec fixture supplies non-default audio and subtitle choices, and clean
|
||||
# state prevents remembered selections from making the assertions 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,81 +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: selecting Turkish and reading root navigation
|
||||
# labels proves the generated locale is wired end to end. Start clean because
|
||||
# the language preference persists and affects every later selector.
|
||||
- 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"
|
||||
# Restore English before leaving the device; the locale preference outlives launchApp.
|
||||
- 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,132 +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.
|
||||
# This flow intentionally does not pin 15b54e2e's row density: semantics stay
|
||||
# stable across that change, and Maestro has no DPR-relative height assertion.
|
||||
# Use D-pad only; a tap switches InputModeTracker to pointer mode and removes
|
||||
# the TV number spinner.
|
||||
- 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,17 +0,0 @@
|
||||
appId: com.edde746.plezy
|
||||
---
|
||||
# Reach a signed-in Home without repeating onboarding. A plain launch preserves
|
||||
# stored state (~16s versus ~59s for retyping credentials); fall back to full
|
||||
# onboarding when no session exists so each flow remains runnable alone.
|
||||
- launchApp
|
||||
# Wait for the splash to resolve before deciding whether onboarding is needed.
|
||||
- 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,48 +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
|
||||
# Optional taps still pay Maestro's full search cost when they miss; guard them
|
||||
# with one 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,51 +0,0 @@
|
||||
appId: com.edde746.plezy
|
||||
---
|
||||
# Onboard a device that reports itself as a TV; unlike the phone flow, it starts
|
||||
# in TV layout and needs no Force TV mode walk.
|
||||
- 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"
|
||||
# Quick Connect is awkward on a remote and needs another device, so use the
|
||||
# username form as fallback.
|
||||
- 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,46 +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
|
||||
# Codec cards retain watch state and resume position between flows, so accept
|
||||
# any valid watch-state label to keep this subflow rerunnable.
|
||||
- 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,23 +0,0 @@
|
||||
appId: com.edde746.plezy
|
||||
---
|
||||
# Reach phone Settings from signed-in Home. The profile menu is an overlay
|
||||
# sheet; retry while the previous route settles.
|
||||
- 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,10 +0,0 @@
|
||||
{
|
||||
"indentation" : {
|
||||
"spaces" : 2
|
||||
},
|
||||
"lineLength" : 120,
|
||||
"maximumBlankLines" : 1,
|
||||
"respectsExistingLineBreaks" : true,
|
||||
"rules" : {},
|
||||
"version" : 1
|
||||
}
|
||||
-147
@@ -1,147 +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
|
||||
|
||||
### AI-assisted contributions
|
||||
|
||||
AI-assisted pull requests must state in the PR description which model(s) were used (e.g. Claude Sonnet 4.5, GPT-5, Gemini 3 Pro).
|
||||
|
||||
### 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/maestro/run_maestro.py basic # Basic user flows
|
||||
python3 scripts/maestro/run_maestro.py catalog # Catalog and music flows
|
||||
python3 scripts/maestro/run_maestro.py media # Codec playback and track selection
|
||||
```
|
||||
|
||||
Run one flow with `--flow`:
|
||||
|
||||
```bash
|
||||
python3 scripts/maestro/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 groups are declared in `scripts/maestro/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.
|
||||
|
||||
The profile-isolation and profile-teardown regressions create and remove profile connections, so they are a destructive
|
||||
manual target rather than an automatic group. Run them only against the pre-seeded Jellyfin fixture and a disposable
|
||||
emulator, using the required opt-in:
|
||||
|
||||
```bash
|
||||
python3 scripts/maestro/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/checks/check_container_image_pins.py`, `python3 scripts/checks/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.18.0"
|
||||
sha256 "affa0922fb33b6ca79a0d6ce7e5042539097a0ea097f011c9a4cfdbe0e822f94"
|
||||
|
||||
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
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
# Privacy Policy for Plezy
|
||||
|
||||
**Last Updated: October 22, 2025**
|
||||
|
||||
## Introduction
|
||||
|
||||
Plezy ("we", "our", or "the app") is a third-party Plex client that allows you to access and stream content from your Plex Media Server. This privacy policy explains how we handle your information when you use our app.
|
||||
|
||||
## Information We Collect
|
||||
|
||||
### Authentication Information
|
||||
When you sign in to Plezy using your Plex account, we collect and process:
|
||||
- Plex authentication tokens
|
||||
- Plex account username
|
||||
- Plex server connection information
|
||||
|
||||
### Local Network Information
|
||||
To connect to your Plex Media Server on your local network, we may access:
|
||||
- Local network device discovery information
|
||||
- IP addresses of Plex servers on your network
|
||||
|
||||
### Usage Information
|
||||
The app stores locally on your device:
|
||||
- Your authentication session
|
||||
- App preferences and settings
|
||||
- Recently accessed content history
|
||||
|
||||
## How We Use Your Information
|
||||
|
||||
We use the collected information solely to:
|
||||
- Authenticate you with your Plex account
|
||||
- Connect to and communicate with your Plex Media Server
|
||||
- Display your media library and stream content
|
||||
- Maintain your session and app preferences
|
||||
|
||||
## Data Storage and Security
|
||||
|
||||
- **Authentication tokens** are stored securely on your device using platform-specific secure storage mechanisms
|
||||
- **App preferences** are stored locally on your device
|
||||
- **No data** is transmitted to our servers - all communication is directly between your device and your Plex Media Server or Plex's authentication services
|
||||
|
||||
## Third-Party Services
|
||||
|
||||
### Plex
|
||||
Plezy uses Plex's authentication and media services. When you use this app:
|
||||
- You authenticate directly with Plex's servers
|
||||
- Your media streaming occurs between your device and your Plex Media Server
|
||||
- Plex's own privacy policy applies to their services: https://www.plex.tv/about/privacy-legal/
|
||||
|
||||
We do not control and are not responsible for Plex's data practices.
|
||||
|
||||
## Data Sharing
|
||||
|
||||
We do not:
|
||||
- Sell your personal information
|
||||
- Share your information with third parties for marketing purposes
|
||||
- Transmit your data to our own servers
|
||||
- Track your viewing habits outside of local app functionality
|
||||
|
||||
Your data is only shared with:
|
||||
- **Plex services** for authentication and media server communication (as required for the app to function)
|
||||
|
||||
## Your Rights and Choices
|
||||
|
||||
You can:
|
||||
- **Sign out** at any time to remove your authentication session from the device
|
||||
- **Uninstall the app** to remove all locally stored data
|
||||
- **Manage your Plex account** directly through Plex's services
|
||||
|
||||
## Children's Privacy
|
||||
|
||||
Plezy does not knowingly collect personal information from children under 13. The app relies on Plex account authentication, and users must comply with Plex's terms of service and age requirements.
|
||||
|
||||
## Changes to This Privacy Policy
|
||||
|
||||
We may update this privacy policy from time to time. We will notify users of any material changes by updating the "Last Updated" date at the top of this policy.
|
||||
|
||||
## Data Retention
|
||||
|
||||
- Authentication tokens are retained on your device until you sign out or uninstall the app
|
||||
- Local preferences and settings are retained until you uninstall the app
|
||||
- No data is retained on our servers as we do not operate any backend services
|
||||
|
||||
## International Data Transfers
|
||||
|
||||
As we do not operate backend servers, there are no international data transfers from our side. Any data transfers occur directly between your device and Plex's services, subject to Plex's privacy policy.
|
||||
|
||||
## Legal Compliance
|
||||
|
||||
This app complies with:
|
||||
- General Data Protection Regulation (GDPR)
|
||||
- California Consumer Privacy Act (CCPA)
|
||||
- Children's Online Privacy Protection Act (COPPA)
|
||||
- Other applicable privacy laws
|
||||
|
||||
## Your Consent
|
||||
|
||||
By using Plezy, you consent to this privacy policy and the processing of your information as described herein.
|
||||
@@ -3,206 +3,106 @@
|
||||
Plezy
|
||||
</h1>
|
||||
|
||||
A modern client for Plex, Jellyfin, and Emby 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>
|
||||
Plezy is a modern Plex media client that provides a seamless streaming experience across desktop and mobile platforms. Built with Flutter, it offers native performance and a clean, intuitive interface for browsing and playing your Plex media library.
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/readme-showcase.webp" alt="Plezy mobile screenshots" width="900" />
|
||||
<img src="assets/screenshots/macos-home.png" alt="Plezy macOS Home Screen" width="800" />
|
||||
</p>
|
||||
|
||||
*See more screenshots in the [screenshots folder](assets/screenshots/#readme)*
|
||||
|
||||
## 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>
|
||||
### Mobile
|
||||
<a href='https://apps.apple.com/us/app/id6754315964'><img height='60' alt='Download on the App Store' src='./assets/app-store-badge.png'/></a>
|
||||
<a href='https://play.google.com/store/apps/details?id=com.edde746.plezy'><img height='60' alt='Get it on Google Play' src='./assets/play-store-badge.png'/></a>
|
||||
|
||||
| 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) |
|
||||
> Google Play version is in closed testing ([required by Google](https://support.google.com/googleplay/android-developer/answer/14151465#overview)). Join the [Google Group](https://groups.google.com/g/plezy-testers-2) to get access.
|
||||
|
||||
Package managers:
|
||||
### Desktop
|
||||
- [Windows (x64)](https://github.com/edde746/plezy/releases/latest/download/plezy-windows-installer.exe)
|
||||
- [macOS (Universal)](https://github.com/edde746/plezy/releases/latest/download/plezy-macos.zip)
|
||||
- [Linux (x64)](https://github.com/edde746/plezy/releases/latest/download/plezy-linux.tar.gz)
|
||||
|
||||
- [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
|
||||
```
|
||||
- [Pacman](https://archlinux.org/packages/extra/x86_64/plezy/) (Arch Linux) - Official package:
|
||||
```bash
|
||||
sudo pacman -S plezy
|
||||
```
|
||||
- **WinGet** (Windows):
|
||||
```bash
|
||||
winget install edde746.Plezy
|
||||
```
|
||||
> Download the latest release from the [Releases page](https://github.com/edde746/plezy/releases)
|
||||
|
||||
## 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, Jellyfin, and Emby 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[^mb]
|
||||
- Extras — trailers, deleted scenes, behind-the-scenes
|
||||
### 🔐 Authentication & Server Management
|
||||
- Sign in with Plex
|
||||
- Automatic server discovery with smart connection selection
|
||||
- Persistent sessions with auto-login
|
||||
|
||||
### <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
|
||||
### 📚 Media Browsing
|
||||
- Browse libraries with rich metadata
|
||||
- Discover featured content
|
||||
- Advanced search across all media
|
||||
- Season and episode navigation
|
||||
|
||||
### <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]
|
||||
### 🎬 Video Playback
|
||||
- Wide codec support including HEVC, AV1, VP9, and more
|
||||
- Advanced subtitle rendering with full ASS/SSA support
|
||||
- Audio and subtitle track selection with user profile preferences
|
||||
- Playback progress sync and resume functionality
|
||||
- Auto-play next episode
|
||||
|
||||
### <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
|
||||
## Prerequisites
|
||||
|
||||
### <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
|
||||
- Flutter SDK 3.8.1 or higher
|
||||
- A Plex account
|
||||
- Access to a Plex Media Server (local or remote)
|
||||
|
||||
### <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, Jellyfin, and Emby side by side
|
||||
- Profiles with per-profile downloads, watch state, and settings; Plex Home switching with PIN
|
||||
- Jellyfin and Emby local-server discovery and multiple URLs per server; Quick Connect sign-in[^jf]
|
||||
- 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.
|
||||
[^mb]: Jellyfin and Emby only.
|
||||
[^plex]: Plex only.
|
||||
[^connect]: Requires connecting the service under Settings > Services.
|
||||
[^hdr]: In-app HDR toggle on Windows, macOS, iOS, tvOS, and Linux — Linux needs a colour-managed Wayland compositor. 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.47.0+
|
||||
- A Plex account, or a Jellyfin or Emby server with user credentials
|
||||
|
||||
### Setup
|
||||
## Installation
|
||||
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
git clone https://github.com/edde746/plezy.git
|
||||
cd plezy
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
flutter pub get
|
||||
scripts/codegen.sh
|
||||
```
|
||||
|
||||
3. Generate required code:
|
||||
```bash
|
||||
dart run build_runner build
|
||||
```
|
||||
|
||||
4. Run the application:
|
||||
```bash
|
||||
flutter run
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
### Code Generation
|
||||
|
||||
After modifying model classes or other generated sources:
|
||||
The project uses code generation for JSON serialization. After modifying model classes, run:
|
||||
|
||||
```bash
|
||||
scripts/codegen.sh
|
||||
dart run build_runner build --delete-conflicting-outputs
|
||||
```
|
||||
|
||||
After modifying translations:
|
||||
## Building for Production
|
||||
|
||||
### Android
|
||||
```bash
|
||||
dart run slang
|
||||
flutter build apk --release
|
||||
# or
|
||||
flutter build appbundle --release
|
||||
```
|
||||
|
||||
### Local Checks
|
||||
|
||||
### Desktop
|
||||
```bash
|
||||
scripts/ci_checks.sh
|
||||
flutter build macos --release
|
||||
flutter build windows --release
|
||||
flutter build linux --release
|
||||
```
|
||||
|
||||
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/maestro/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), [Jellyfin](https://jellyfin.org), and [Emby](https://emby.media)
|
||||
- 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)
|
||||
- Media playback powered by [MediaKit](https://github.com/media-kit/media-kit)
|
||||
- Designed for [Plex Media Server](https://www.plex.tv)
|
||||
|
||||
@@ -1,90 +1 @@
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
analyzer:
|
||||
exclude:
|
||||
- "**/*.g.dart"
|
||||
- "**/*.freezed.dart"
|
||||
- build/**
|
||||
- android/**
|
||||
- ios/**
|
||||
- web/**
|
||||
- windows/**
|
||||
- macos/**
|
||||
- linux/**
|
||||
|
||||
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
|
||||
|
||||
# DCL's analyzer 10 plugin crashes on Linux; CI runs check-unused commands directly.
|
||||
dart_code_linter:
|
||||
rules-exclude:
|
||||
- "test/**"
|
||||
metrics-exclude:
|
||||
- "test/**"
|
||||
|
||||
extends:
|
||||
- package:dart_code_linter/presets/recommended.yaml
|
||||
|
||||
rules:
|
||||
# Flutter-specific rules.
|
||||
- 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 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
|
||||
|
||||
# Disabled noisy rules.
|
||||
- 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,5 +1,8 @@
|
||||
gradle-wrapper.jar
|
||||
/.gradle
|
||||
/captures/
|
||||
/gradlew
|
||||
/gradlew.bat
|
||||
/local.properties
|
||||
GeneratedPluginRegistrant.java
|
||||
.cxx/
|
||||
|
||||
@@ -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
|
||||
+43
-539
@@ -1,560 +1,64 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
import java.io.FileInputStream
|
||||
|
||||
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.2.2"
|
||||
val mpvSha256 = "0207bb46660c239268c4c17bddc7fd570722513344b136c7c5336aca135dc8a0"
|
||||
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"
|
||||
|
||||
// Dev-only escape hatch: point the build at a locally built libmpv AAR
|
||||
// (libmpv-android's build-local.sh output) to test fork patches before a
|
||||
// release is pinned. The URL + sha256 above stay authoritative otherwise.
|
||||
val localMpvAar: String? = (project.findProperty("plezy.localMpvAar") as String?)
|
||||
?: System.getenv("PLEZY_LOCAL_MPV_AAR")
|
||||
|
||||
val media3Version = "1.11.0"
|
||||
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)
|
||||
inputs.property("localOverride", localMpvAar ?: "")
|
||||
localMpvAar?.let { inputs.file(it) }
|
||||
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)
|
||||
if (localMpvAar != null) {
|
||||
File(localMpvAar).copyTo(stagedAar, overwrite = true)
|
||||
File(staging, ".manifest").writeText("version=local\nsource=$localMpvAar\n")
|
||||
} else {
|
||||
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()
|
||||
}
|
||||
}
|
||||
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")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.edde746.plezy"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
buildToolsVersion = "36.1.0"
|
||||
ndkVersion = "29.0.14206865"
|
||||
namespace = "com.edde746.plezy"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
// Android Automotive OS driver-distraction state (CarUxRestrictionsManager). This is a platform
|
||||
// stub, not a shipped dependency: the classes exist only on AAOS images, so every use is guarded
|
||||
// by FEATURE_AUTOMOTIVE and the manifest declares `uses-library android.car required=false`.
|
||||
useLibrary("android.car")
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.edde746.plezy"
|
||||
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}"
|
||||
)
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_11.toString()
|
||||
}
|
||||
|
||||
// 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"
|
||||
}
|
||||
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 = flutter.minSdkVersion
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
}
|
||||
}
|
||||
|
||||
// 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"
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
val keystoreProperties = Properties()
|
||||
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
|
||||
|
||||
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")
|
||||
keyAlias = keystoreProperties["keyAlias"] as String
|
||||
keyPassword = keystoreProperties["keyPassword"] as String
|
||||
storeFile = file(keystoreProperties["storeFile"] as String)
|
||||
storePassword = keystoreProperties["storePassword"] as String
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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"))
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
|
||||
// Only used to cancel the legacy periodic shelf refresh job (2.13.0's
|
||||
// removed ShelfRefreshWorker) that WorkManager persisted on updated
|
||||
// devices. Same version background_downloader pins, so the merged
|
||||
// classpath stays coherent.
|
||||
implementation("androidx.work:work-runtime-ktx:2.11.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")
|
||||
testImplementation("androidx.work:work-testing:2.11.0")
|
||||
androidTestImplementation("androidx.test:runner:1.7.0")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.3.0")
|
||||
source = "../.."
|
||||
}
|
||||
|
||||
@@ -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
-37
@@ -1,37 +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 { *; }
|
||||
|
||||
# MatroskaExtractor.init is final and its ExtractorOutput / subtitle scratch buffer live
|
||||
# in private fields, so the extractor wrappers reach both by name: AssMatroskaExtractor
|
||||
# (android/libass/.../media/extractor/AssMatroskaExtractor.kt) resolves extractorOutput
|
||||
# and subtitleSample with getDeclaredField to redirect ASS subtitle samples, and
|
||||
# MatroskaLatmSupport (android/app/.../exoplayer/MatroskaLatmSupport.kt) resolves
|
||||
# extractorOutput the same way to wrap LATM tracks.
|
||||
#
|
||||
# R8 renaming either field makes getDeclaredField throw; AssMatroskaExtractor resolves
|
||||
# them in its companion object, so the throw surfaces as ExceptionInInitializerError
|
||||
# while constructing the extractor — every MKV direct-play, release builds only. Only
|
||||
# the *names* need pinning: MatroskaExtractor uses both fields itself, so they survive
|
||||
# shrinking through the compile-time subclass references.
|
||||
-keepclassmembernames class androidx.media3.extractor.mkv.MatroskaExtractor {
|
||||
private androidx.media3.extractor.ExtractorOutput extractorOutput;
|
||||
private androidx.media3.common.util.ParsableByteArray subtitleSample;
|
||||
}
|
||||
@@ -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.
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))
|
||||
}
|
||||
}
|
||||
-92
@@ -1,92 +0,0 @@
|
||||
package com.edde746.plezy.car
|
||||
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* Verifies the platform half of the driver-distraction gate on a real device.
|
||||
*
|
||||
* Everything here is invisible to the Dart unit tests: `android.car` is a compile-time stub, so a
|
||||
* missing `useLibrary`, a wrong `uses-library` declaration, a car service that refuses to connect,
|
||||
* or an unresolvable default display all compile fine and merely make [CarRestrictionsMonitor.start]
|
||||
* return false — at which point Plezy silently falls back to lifecycle gating and parked background
|
||||
* audio never works, with no crash to notice.
|
||||
*
|
||||
* On a non-automotive device the monitor must stay inert instead of throwing, which is the other
|
||||
* half of the contract.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class CarRestrictionsMonitorTest {
|
||||
|
||||
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
private val isCar = context.packageManager.hasSystemFeature(PackageManager.FEATURE_AUTOMOTIVE)
|
||||
|
||||
@Test
|
||||
fun reportsSupportOnlyOnACarAndNeverThrows() {
|
||||
val monitor = CarRestrictionsMonitor(context)
|
||||
val answered = CountDownLatch(1)
|
||||
try {
|
||||
val started = monitor.start { answered.countDown() }
|
||||
if (isCar) {
|
||||
assertTrue("android.car is present but the UX-restriction signal did not connect", started)
|
||||
// The connect never blocks, so readiness arrives on the main thread while this test thread
|
||||
// waits. A timeout here means the car service never handed over a verdict.
|
||||
assertTrue("car service never reported its UX restrictions", answered.await(10, TimeUnit.SECONDS))
|
||||
assertTrue("a verdict arrived without marking the monitor supported", monitor.supported)
|
||||
} else {
|
||||
assertFalse("a non-automotive device must not claim car restrictions", started)
|
||||
assertFalse(monitor.supported)
|
||||
}
|
||||
} finally {
|
||||
monitor.release()
|
||||
}
|
||||
assertFalse("release() must drop support so a stale verdict cannot leak", monitor.supported)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aParkedEmulatorReportsNoDistractionOptimization() {
|
||||
if (!isCar) return
|
||||
val monitor = CarRestrictionsMonitor(context)
|
||||
val answered = CountDownLatch(1)
|
||||
try {
|
||||
assertTrue(monitor.start { answered.countDown() })
|
||||
assertTrue("car service never reported its UX restrictions", answered.await(10, TimeUnit.SECONDS))
|
||||
// The suite runs on a parked vehicle (no VHAL driving injection), which is the state that
|
||||
// must permit background audio. A restricted verdict here means the gate would keep music
|
||||
// tied to the foreground exactly as before.
|
||||
assertFalse(
|
||||
"parked car reported that distraction optimization is required",
|
||||
monitor.requiresDistractionOptimization
|
||||
)
|
||||
} finally {
|
||||
monitor.release()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theServiceConnectionRouteUsedBelowAndroidElevenAlsoReportsTheVehicle() {
|
||||
if (!isCar) return
|
||||
// No Android 9 or 10 Automotive image is published, so the only way to exercise the path those
|
||||
// head units take is to force it here: the deprecated API is present on every version.
|
||||
val monitor = CarRestrictionsMonitor(context, forceLegacyConnect = true)
|
||||
val answered = CountDownLatch(1)
|
||||
try {
|
||||
assertTrue("the ServiceConnection route failed to start", monitor.start { answered.countDown() })
|
||||
assertTrue("car service never reported through the legacy route", answered.await(20, TimeUnit.SECONDS))
|
||||
assertTrue(monitor.supported)
|
||||
assertFalse(
|
||||
"parked car reported that distraction optimization is required",
|
||||
monitor.requiresDistractionOptimization
|
||||
)
|
||||
} finally {
|
||||
monitor.release()
|
||||
}
|
||||
}
|
||||
}
|
||||
-299
@@ -1,299 +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.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MimeTypes
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.datasource.DefaultDataSource
|
||||
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.analytics.AnalyticsListener
|
||||
import androidx.media3.exoplayer.audio.AudioCapabilities
|
||||
import androidx.media3.exoplayer.audio.AudioSink
|
||||
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.AtomicInteger
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* The hardware half of the #1790 contract, and the half a JVM fake cannot reach.
|
||||
*
|
||||
* `DefaultAudioSink` charges a static, process-wide counter on every flush and only discharges it
|
||||
* from `Listener::onReleased` — the same callback the player surfaces as
|
||||
* [AnalyticsListener.onAudioTrackReleased]. Any lasting imbalance stops media3 escalating audio
|
||||
* failures at all, which is how a failed `AudioTrack` turned into a permanent buffering hang.
|
||||
*
|
||||
* `onAudioTrackInitialized` fires once per acquisition and `onAudioTrackReleased` once per answered
|
||||
* flush, so counting both against a real sink, across the reuse and eviction cycles the wrapper's
|
||||
* cache actually creates, measures the invariant directly.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class AudioOutputReleaseAccountingTest {
|
||||
|
||||
private companion object {
|
||||
const val SEEK_COUNT = 4
|
||||
const val STATE_TIMEOUT_SECONDS = 20L
|
||||
}
|
||||
|
||||
@Test
|
||||
fun everyFlushIsAnsweredAcrossSeeksAndAConfigChange() {
|
||||
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
val context = instrumentation.targetContext
|
||||
// Different channel counts give the sink two different output configurations, so the second
|
||||
// item cannot reuse the first one's parked AudioTrack and has to evict it.
|
||||
val surround = copyFixture(context, "ffmpeg/surround_5_1.flac")
|
||||
val stereo = copyFixture(context, "ffmpeg/stereo.flac")
|
||||
|
||||
val playbackThread = HandlerThread("plezy-release-accounting-test").apply { start() }
|
||||
val handler = Handler(playbackThread.looper)
|
||||
val playerReference = AtomicReference<ExoPlayer>()
|
||||
val errorReference = AtomicReference<Throwable>()
|
||||
val initialized = AtomicInteger()
|
||||
val released = AtomicInteger()
|
||||
|
||||
try {
|
||||
handler.runAndWait {
|
||||
val factory = PlezyRenderersFactory(context).apply {
|
||||
setEnableDecoderFallback(true)
|
||||
setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON)
|
||||
// Decoded PCM is the only output the cache parks, so it is the path whose reuse and
|
||||
// eviction the accounting has to survive.
|
||||
shouldBlockDirectAudioOutput = { format ->
|
||||
format.sampleMimeType != null && format.sampleMimeType != MimeTypes.AUDIO_RAW
|
||||
}
|
||||
}
|
||||
val player = ExoPlayer.Builder(context, factory).setLooper(playbackThread.looper).build()
|
||||
playerReference.set(player)
|
||||
player.addAnalyticsListener(object : AnalyticsListener {
|
||||
override fun onAudioTrackInitialized(
|
||||
eventTime: AnalyticsListener.EventTime,
|
||||
audioTrackConfig: AudioSink.AudioTrackConfig
|
||||
) {
|
||||
initialized.incrementAndGet()
|
||||
}
|
||||
|
||||
override fun onAudioTrackReleased(
|
||||
eventTime: AnalyticsListener.EventTime,
|
||||
audioTrackConfig: AudioSink.AudioTrackConfig
|
||||
) {
|
||||
released.incrementAndGet()
|
||||
}
|
||||
|
||||
override fun onPlayerErrorChanged(
|
||||
eventTime: AnalyticsListener.EventTime,
|
||||
error: androidx.media3.common.PlaybackException?
|
||||
) {
|
||||
if (error != null) errorReference.set(error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
val player = playerReference.get()
|
||||
play(handler, player, surround)
|
||||
awaitReady(handler, player)
|
||||
|
||||
// Each seek flushes the sink: the output is parked and handed straight back, so every one of
|
||||
// them has to settle its own flush or the counter drifts up for the rest of the process.
|
||||
repeat(SEEK_COUNT) {
|
||||
handler.runAndWait { player.seekTo(0) }
|
||||
awaitReady(handler, player)
|
||||
}
|
||||
|
||||
val afterSeeks = initialized.get() - released.get()
|
||||
assertNull("playback failed before the config change", errorReference.get())
|
||||
assertTrue("expected the seeks to rebuild the audio output", initialized.get() > 1)
|
||||
assertEquals(
|
||||
"after $SEEK_COUNT seeks only the live acquisition may be outstanding " +
|
||||
"(initialized=${initialized.get()}, released=${released.get()})",
|
||||
1,
|
||||
afterSeeks
|
||||
)
|
||||
|
||||
// Config change: the parked 5.1 track cannot serve stereo, so it is evicted for real.
|
||||
play(handler, player, stereo)
|
||||
awaitReady(handler, player)
|
||||
|
||||
assertNull("playback failed after the config change", errorReference.get())
|
||||
assertEquals(
|
||||
"an eviction must not leave a second acquisition outstanding " +
|
||||
"(initialized=${initialized.get()}, released=${released.get()})",
|
||||
1,
|
||||
initialized.get() - released.get()
|
||||
)
|
||||
} finally {
|
||||
handler.runAndWait { playerReference.get()?.release() }
|
||||
playbackThread.quitSafely()
|
||||
playbackThread.join(TimeUnit.SECONDS.toMillis(5))
|
||||
surround.delete()
|
||||
stereo.delete()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The same invariant on a real bitstream route, which is the output the reporter's device was
|
||||
* failing to build. Needs a live HDMI sink that advertises encoded surround, so it skips on a
|
||||
* phone or a TV set to PCM rather than passing without having exercised anything.
|
||||
*/
|
||||
@Test
|
||||
fun everyFlushIsAnsweredOnABitstreamRoute() {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
val movieAttributes = AudioAttributes.Builder()
|
||||
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
|
||||
.setUsage(C.USAGE_MEDIA)
|
||||
.build()
|
||||
val capabilities = AudioCapabilities.getCapabilities(context, movieAttributes, null)
|
||||
val fixture = when {
|
||||
capabilities.supportsEncoding(C.ENCODING_E_AC3) -> "ffmpeg/surround_5_1_eac3.mka"
|
||||
capabilities.supportsEncoding(C.ENCODING_DTS) -> "ffmpeg/surround_5_1_dts.mka"
|
||||
else -> null
|
||||
}
|
||||
assumeTrue("the current audio route does not advertise E-AC3 or DTS bitstream", fixture != null)
|
||||
|
||||
val media = copyFixture(context, fixture!!)
|
||||
val playbackThread = HandlerThread("plezy-bitstream-accounting-test").apply { start() }
|
||||
val handler = Handler(playbackThread.looper)
|
||||
val playerReference = AtomicReference<ExoPlayer>()
|
||||
val errorReference = AtomicReference<Throwable>()
|
||||
val initialized = AtomicInteger()
|
||||
val released = AtomicInteger()
|
||||
val sawEncodedOutput = AtomicReference(false)
|
||||
|
||||
try {
|
||||
handler.runAndWait {
|
||||
val factory = PlezyRenderersFactory(context).apply {
|
||||
setEnableDecoderFallback(true)
|
||||
setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON)
|
||||
shouldBlockDirectAudioOutput = { false }
|
||||
}
|
||||
val player = ExoPlayer.Builder(context, factory).setLooper(playbackThread.looper).build()
|
||||
playerReference.set(player)
|
||||
player.addAnalyticsListener(object : AnalyticsListener {
|
||||
override fun onAudioTrackInitialized(
|
||||
eventTime: AnalyticsListener.EventTime,
|
||||
audioTrackConfig: AudioSink.AudioTrackConfig
|
||||
) {
|
||||
initialized.incrementAndGet()
|
||||
if (!isPcmEncoding(audioTrackConfig.encoding)) sawEncodedOutput.set(true)
|
||||
}
|
||||
|
||||
override fun onAudioTrackReleased(
|
||||
eventTime: AnalyticsListener.EventTime,
|
||||
audioTrackConfig: AudioSink.AudioTrackConfig
|
||||
) {
|
||||
released.incrementAndGet()
|
||||
}
|
||||
|
||||
override fun onPlayerErrorChanged(
|
||||
eventTime: AnalyticsListener.EventTime,
|
||||
error: androidx.media3.common.PlaybackException?
|
||||
) {
|
||||
if (error != null) errorReference.set(error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
val player = playerReference.get()
|
||||
play(handler, player, media)
|
||||
awaitReady(handler, player)
|
||||
repeat(SEEK_COUNT) {
|
||||
handler.runAndWait { player.seekTo(0) }
|
||||
awaitReady(handler, player)
|
||||
}
|
||||
|
||||
assertNull("bitstream playback failed", errorReference.get())
|
||||
assumeTrue(
|
||||
"the route advertised encoded surround but the sink still decoded to PCM",
|
||||
sawEncodedOutput.get()
|
||||
)
|
||||
assertEquals(
|
||||
"a bitstream output must answer every flush too " +
|
||||
"(initialized=${initialized.get()}, released=${released.get()})",
|
||||
1,
|
||||
initialized.get() - released.get()
|
||||
)
|
||||
} finally {
|
||||
handler.runAndWait { playerReference.get()?.release() }
|
||||
playbackThread.quitSafely()
|
||||
playbackThread.join(TimeUnit.SECONDS.toMillis(5))
|
||||
media.delete()
|
||||
}
|
||||
}
|
||||
|
||||
private fun play(handler: Handler, player: ExoPlayer, file: File) {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
handler.runAndWait {
|
||||
val source = ProgressiveMediaSource.Factory(
|
||||
DefaultDataSource.Factory(context),
|
||||
DefaultExtractorsFactory()
|
||||
).createMediaSource(MediaItem.fromUri(Uri.fromFile(file)))
|
||||
player.setMediaSource(source)
|
||||
player.prepare()
|
||||
player.play()
|
||||
}
|
||||
}
|
||||
|
||||
/** Waits for the player to reach READY, which is the point an AudioTrack has been acquired. */
|
||||
private fun awaitReady(handler: Handler, player: ExoPlayer) {
|
||||
val ready = CountDownLatch(1)
|
||||
val listener = object : Player.Listener {
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
if (playbackState == Player.STATE_READY || playbackState == Player.STATE_ENDED) ready.countDown()
|
||||
}
|
||||
}
|
||||
handler.runAndWait {
|
||||
if (player.playbackState == Player.STATE_READY || player.playbackState == Player.STATE_ENDED) {
|
||||
ready.countDown()
|
||||
} else {
|
||||
player.addListener(listener)
|
||||
}
|
||||
}
|
||||
val reached = ready.await(STATE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
handler.runAndWait { player.removeListener(listener) }
|
||||
assertTrue("timed out waiting for the player to become ready", reached)
|
||||
// The AudioTrack is acquired on the first buffer handed to the sink, which lands just after
|
||||
// READY; give the playback thread a beat to get there before counting.
|
||||
handler.runAndWait { }
|
||||
Thread.sleep(250)
|
||||
handler.runAndWait { }
|
||||
}
|
||||
|
||||
private fun Handler.runAndWait(block: () -> Unit) {
|
||||
val done = CountDownLatch(1)
|
||||
val failure = AtomicReference<Throwable>()
|
||||
post {
|
||||
try {
|
||||
block()
|
||||
} catch (error: Throwable) {
|
||||
failure.set(error)
|
||||
} finally {
|
||||
done.countDown()
|
||||
}
|
||||
}
|
||||
assertTrue("timed out running on the playback thread", done.await(STATE_TIMEOUT_SECONDS, TimeUnit.SECONDS))
|
||||
failure.get()?.let { throw it }
|
||||
}
|
||||
|
||||
private fun copyFixture(targetContext: Context, fixture: String): File {
|
||||
val output = File.createTempFile("release-accounting-", null, targetContext.cacheDir)
|
||||
InstrumentationRegistry.getInstrumentation().context.assets.open(fixture).use { input ->
|
||||
output.outputStream().use(input::copyTo)
|
||||
}
|
||||
return output
|
||||
}
|
||||
}
|
||||
-152
@@ -1,152 +0,0 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.util.Log
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MimeTypes
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.Tracks
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
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.AtomicReference
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* Pins the side-loaded subtitle identity contract against the real media3 the
|
||||
* app links, on a real device.
|
||||
*
|
||||
* `ExoPlayerCore` tags each `MediaItem.SubtitleConfiguration` with
|
||||
* [ExternalSubtitleIds.idFor] and recovers it from the `Format` the track
|
||||
* selector reports. media3 does not hand that id back verbatim:
|
||||
* `DefaultMediaSourceFactory` merges side-loaded subtitles with the primary
|
||||
* source, and `MergingMediaPeriod` prefixes every child format id with its
|
||||
* period index. Matching the raw id therefore classifies every sidecar as an
|
||||
* embedded track and drops its URI, which is what broke Plex sidecar
|
||||
* subtitles (#1713).
|
||||
*
|
||||
* A JVM test can only assert the parsing rule. This asserts that the rule
|
||||
* still matches what media3 actually emits.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ExternalSubtitleIdentityTest {
|
||||
|
||||
private companion object {
|
||||
const val TAG = "ExternalSubtitleIdentityTest"
|
||||
const val SRT = "1\n00:00:00,500 --> 00:00:05,000\nplezy sidecar identity\n\n"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sideLoadedSubtitleIdSurvivesMedia3Merging() {
|
||||
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
val context = instrumentation.targetContext
|
||||
val media = copyAsset(instrumentation.context, context, "ffmpeg/planar_5_1.m4a")
|
||||
val subtitle = File.createTempFile("sidecar-", ".srt", context.cacheDir).apply {
|
||||
writeText(SRT)
|
||||
}
|
||||
|
||||
val thread = HandlerThread("plezy-sidecar-identity-test").apply { start() }
|
||||
val handler = Handler(thread.looper)
|
||||
val settled = CountDownLatch(1)
|
||||
val playerRef = AtomicReference<ExoPlayer>()
|
||||
val errorRef = AtomicReference<Throwable>()
|
||||
val textFormatId = AtomicReference<String>()
|
||||
val textGroupCount = AtomicReference(0)
|
||||
|
||||
handler.post {
|
||||
try {
|
||||
val player = ExoPlayer.Builder(context).setLooper(thread.looper).build()
|
||||
playerRef.set(player)
|
||||
player.addListener(object : Player.Listener {
|
||||
override fun onPlayerError(error: PlaybackException) {
|
||||
errorRef.set(error)
|
||||
settled.countDown()
|
||||
}
|
||||
|
||||
override fun onTracksChanged(tracks: Tracks) {
|
||||
val text = tracks.groups.filter { it.type == C.TRACK_TYPE_TEXT }
|
||||
if (text.isEmpty()) return
|
||||
textGroupCount.set(text.size)
|
||||
textFormatId.set(text.first().mediaTrackGroup.getFormat(0).id)
|
||||
settled.countDown()
|
||||
}
|
||||
})
|
||||
// setMediaItem (not setMediaSource) so DefaultMediaSourceFactory owns
|
||||
// the subtitle configuration exactly as ExoPlayerCore.open does.
|
||||
player.setMediaItem(
|
||||
MediaItem.Builder()
|
||||
.setUri(Uri.fromFile(media))
|
||||
.setSubtitleConfigurations(
|
||||
listOf(
|
||||
MediaItem.SubtitleConfiguration.Builder(Uri.fromFile(subtitle))
|
||||
.setId(ExternalSubtitleIds.idFor(0))
|
||||
.setLabel("Plezy sidecar")
|
||||
.setLanguage("en")
|
||||
.setMimeType(MimeTypes.APPLICATION_SUBRIP)
|
||||
.setSelectionFlags(C.SELECTION_FLAG_DEFAULT)
|
||||
.build()
|
||||
)
|
||||
)
|
||||
.build()
|
||||
)
|
||||
player.prepare()
|
||||
} catch (error: Throwable) {
|
||||
errorRef.set(error)
|
||||
settled.countDown()
|
||||
}
|
||||
}
|
||||
|
||||
val finished = settled.await(30, TimeUnit.SECONDS)
|
||||
val released = CountDownLatch(1)
|
||||
handler.post {
|
||||
playerRef.get()?.release()
|
||||
thread.quitSafely()
|
||||
released.countDown()
|
||||
}
|
||||
val teardownFinished = released.await(5, TimeUnit.SECONDS)
|
||||
thread.join(5_000)
|
||||
media.delete()
|
||||
subtitle.delete()
|
||||
|
||||
assertTrue("Player teardown timed out", teardownFinished)
|
||||
assertNull("Playback failed", errorRef.get())
|
||||
assertTrue("Timed out before any text track group was reported", finished)
|
||||
assertEquals("Expected exactly one side-loaded text group", 1, textGroupCount.get())
|
||||
|
||||
val reportedId = textFormatId.get()
|
||||
Log.i(TAG, "media3 reported side-loaded subtitle Format.id=$reportedId")
|
||||
assertNotNull("Side-loaded subtitle reported a null Format.id", reportedId)
|
||||
|
||||
// The contract ExoPlayerCore depends on.
|
||||
assertTrue(
|
||||
"Side-loaded subtitle was not recognised as external (Format.id=$reportedId)",
|
||||
ExternalSubtitleIds.isExternal(reportedId)
|
||||
)
|
||||
assertEquals(
|
||||
"Side-loaded subtitle did not resolve to its configuration index (Format.id=$reportedId)",
|
||||
0,
|
||||
ExternalSubtitleIds.indexOf(reportedId)
|
||||
)
|
||||
}
|
||||
|
||||
private fun copyAsset(instrumentationContext: Context, targetContext: Context, asset: String): File {
|
||||
val output = File.createTempFile("sidecar-primary-", null, targetContext.cacheDir)
|
||||
instrumentationContext.assets.open(asset).use { input ->
|
||||
output.outputStream().use(input::copyTo)
|
||||
}
|
||||
return output
|
||||
}
|
||||
}
|
||||
-171
@@ -1,171 +0,0 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.util.Log
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.datasource.DefaultDataSource
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.source.ProgressiveMediaSource
|
||||
import androidx.media3.extractor.Extractor
|
||||
import androidx.media3.extractor.ExtractorsFactory
|
||||
import androidx.media3.extractor.mkv.MatroskaExtractor
|
||||
import androidx.media3.extractor.text.DefaultSubtitleParserFactory
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.edde746.plezy.libass.media.AssHandler
|
||||
import com.edde746.plezy.libass.media.parser.AssSubtitleParserFactory
|
||||
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.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* On-device coverage for MKV files whose SeekHead references Tracks after the Clusters (#1969).
|
||||
*
|
||||
* media3 1.11.0 builds the Matroska seek map at the end of the Cues element, which for these files
|
||||
* is before the Tracks element parsed, so the map permanently reports unseekable and ExoPlayer
|
||||
* coerces every seek to t=0 (ProgressiveMediaPeriod; tracking issue androidx/media #3377). The
|
||||
* first test is a canary asserting the upstream defect against a stock MatroskaExtractor — when it
|
||||
* fails after a media3 upgrade, the TrackAwareSeekMap repair in [CuelessSeekExtractorWrapper] can
|
||||
* be retired. The second test drives the production wrapper stack and requires the seek to
|
||||
* actually land.
|
||||
*/
|
||||
class MatroskaLateTracksSeekDeviceTest {
|
||||
private companion object {
|
||||
const val TAG = "MkvLateTracksSeek"
|
||||
const val SEEK_TARGET_MS = 500L
|
||||
const val SETTLE_MS = 2_000L
|
||||
}
|
||||
|
||||
private class Session(
|
||||
val handler: Handler,
|
||||
val thread: HandlerThread,
|
||||
val player: AtomicReference<ExoPlayer?>,
|
||||
val fixture: File
|
||||
)
|
||||
|
||||
@Test
|
||||
fun stockMatroskaExtractorSnapsSeeksToStart() {
|
||||
val result = runSeekScenario("stock") { MatroskaExtractor(DefaultSubtitleParserFactory()) }
|
||||
assertFalse(
|
||||
"upstream media3 now reports tracks-after-clusters MKVs seekable — " +
|
||||
"the TrackAwareSeekMap repair in CuelessSeekExtractorWrapper can be retired",
|
||||
result.seekable
|
||||
)
|
||||
assertTrue(
|
||||
"unseekable media must snap the seek to the start, position=${result.positionAfterSeekMs}ms",
|
||||
result.positionAfterSeekMs < SEEK_TARGET_MS / 2
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun wrappedExtractorKeepsSeekPosition() {
|
||||
val result = runSeekScenario("wrapped") {
|
||||
val assHandler = AssHandler()
|
||||
CuelessSeekExtractorWrapper(ZlibMatroskaExtractor(AssSubtitleParserFactory(assHandler), assHandler))
|
||||
}
|
||||
assertTrue("wrapped extractor must report the item seekable", result.seekable)
|
||||
assertTrue(
|
||||
"seek to ${SEEK_TARGET_MS}ms must hold, position=${result.positionAfterSeekMs}ms",
|
||||
result.positionAfterSeekMs >= SEEK_TARGET_MS / 2
|
||||
)
|
||||
}
|
||||
|
||||
private class ScenarioResult(val seekable: Boolean, val positionAfterSeekMs: Long)
|
||||
|
||||
private fun runSeekScenario(label: String, extractorFactory: () -> Extractor): ScenarioResult {
|
||||
val session = openPaused(label, extractorFactory)
|
||||
try {
|
||||
val seekable = onPlayerThread(session) { it.isCurrentMediaItemSeekable }
|
||||
session.handler.post { session.player.get()?.seekTo(SEEK_TARGET_MS) }
|
||||
// The masked position is the seek target; the snap surfaces once the period resolves the
|
||||
// seek, so give the load a moment before sampling the settled position.
|
||||
Thread.sleep(SETTLE_MS)
|
||||
val positionAfterSeekMs = onPlayerThread(session) { it.currentPosition }
|
||||
Log.i(TAG, "==== $label: seekable=$seekable position=${positionAfterSeekMs}ms ====")
|
||||
return ScenarioResult(seekable, positionAfterSeekMs)
|
||||
} finally {
|
||||
teardown(session)
|
||||
}
|
||||
}
|
||||
|
||||
private fun openPaused(label: String, extractorFactory: () -> Extractor): Session {
|
||||
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
val context = instrumentation.targetContext
|
||||
val fixture = copyFixture(context)
|
||||
val thread = HandlerThread("mkv-late-tracks-$label").apply { start() }
|
||||
val handler = Handler(thread.looper)
|
||||
val player = AtomicReference<ExoPlayer?>(null)
|
||||
val ready = CountDownLatch(1)
|
||||
val failed = AtomicBoolean(false)
|
||||
|
||||
handler.post {
|
||||
val exo = ExoPlayer.Builder(context).build()
|
||||
player.set(exo)
|
||||
exo.addListener(
|
||||
object : Player.Listener {
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
if (playbackState == Player.STATE_READY) ready.countDown()
|
||||
}
|
||||
|
||||
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
|
||||
Log.e(TAG, "$label player error", error)
|
||||
failed.set(true)
|
||||
ready.countDown()
|
||||
}
|
||||
}
|
||||
)
|
||||
val source = ProgressiveMediaSource.Factory(
|
||||
DefaultDataSource.Factory(context),
|
||||
ExtractorsFactory { arrayOf(extractorFactory()) }
|
||||
).createMediaSource(MediaItem.fromUri(Uri.fromFile(fixture)))
|
||||
exo.setMediaSource(source)
|
||||
exo.playWhenReady = false
|
||||
exo.prepare()
|
||||
}
|
||||
|
||||
val session = Session(handler, thread, player, fixture)
|
||||
if (!ready.await(30, TimeUnit.SECONDS) || failed.get()) {
|
||||
teardown(session)
|
||||
error("$label playback never became ready")
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
private fun <T> onPlayerThread(session: Session, read: (ExoPlayer) -> T): T {
|
||||
val value = AtomicReference<T>()
|
||||
val done = CountDownLatch(1)
|
||||
session.handler.post {
|
||||
session.player.get()?.let { value.set(read(it)) }
|
||||
done.countDown()
|
||||
}
|
||||
assertTrue("player thread stalled", done.await(5, TimeUnit.SECONDS))
|
||||
return checkNotNull(value.get())
|
||||
}
|
||||
|
||||
private fun teardown(session: Session) {
|
||||
val done = CountDownLatch(1)
|
||||
session.handler.post {
|
||||
session.player.get()?.release()
|
||||
done.countDown()
|
||||
}
|
||||
done.await(10, TimeUnit.SECONDS)
|
||||
session.thread.quitSafely()
|
||||
session.thread.join(5_000)
|
||||
session.fixture.delete()
|
||||
}
|
||||
|
||||
private fun copyFixture(context: android.content.Context): File {
|
||||
val output = File.createTempFile("mkv-late-tracks-", ".mkv", context.cacheDir)
|
||||
InstrumentationRegistry.getInstrumentation().context.assets
|
||||
.open("ffmpeg/matroska_tracks_at_end.mkv")
|
||||
.use { input -> output.outputStream().use { input.copyTo(it) } }
|
||||
return output
|
||||
}
|
||||
}
|
||||
-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
|
||||
}
|
||||
}
|
||||
-311
@@ -1,311 +0,0 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.util.Log
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.datasource.DefaultDataSource
|
||||
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.analytics.AnalyticsListener
|
||||
import androidx.media3.exoplayer.audio.AudioSink
|
||||
import androidx.media3.exoplayer.source.ProgressiveMediaSource
|
||||
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
|
||||
import androidx.media3.extractor.DefaultExtractorsFactory
|
||||
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.AtomicReference
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Speed changes arrive while the carrier is already live (#1804). Nothing re-asks the sink unless
|
||||
* capabilities are invalidated, so this drives the real path: play on the carrier, change speed,
|
||||
* and require the renderer to actually move TrueHD onto a decoder and keep the clock advancing.
|
||||
*
|
||||
* Skips itself on hardware that never takes the carrier; there is no transition to observe there.
|
||||
*/
|
||||
class TrueHdSpeedTransitionTest {
|
||||
private companion object {
|
||||
const val TAG = "TrueHdSpeed"
|
||||
const val SETTLE_MS = 4_000L
|
||||
}
|
||||
|
||||
@Test
|
||||
fun leavingUnitSpeedMovesTrueHdOffTheCarrierAndKeepsPlaying() {
|
||||
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
val context = instrumentation.targetContext
|
||||
val fixture = copyFixture(context)
|
||||
val thread = HandlerThread("truehd-speed-test").apply { start() }
|
||||
val handler = Handler(thread.looper)
|
||||
|
||||
val playing = CountDownLatch(1)
|
||||
val audioDecoder = AtomicReference<String?>(null)
|
||||
val trackEncoding = AtomicReference<Int>(-1)
|
||||
val player = AtomicReference<ExoPlayer?>(null)
|
||||
|
||||
handler.post {
|
||||
val factory = PlezyRenderersFactory(context).apply {
|
||||
setEnableDecoderFallback(true)
|
||||
setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON)
|
||||
}
|
||||
// Mirrors ExoPlayerCore: without this flag DefaultTrackSelector silently drops every
|
||||
// renderer-capability invalidation, so the carrier could never be re-evaluated.
|
||||
val selector = DefaultTrackSelector(context).apply {
|
||||
setParameters(
|
||||
buildUponParameters().setAllowInvalidateSelectionsOnRendererCapabilitiesChange(true)
|
||||
)
|
||||
}
|
||||
val exo = ExoPlayer.Builder(context, factory).setTrackSelector(selector).build()
|
||||
player.set(exo)
|
||||
exo.addAnalyticsListener(
|
||||
object : AnalyticsListener {
|
||||
override fun onAudioDecoderInitialized(
|
||||
eventTime: AnalyticsListener.EventTime,
|
||||
decoderName: String,
|
||||
initializedTimestampMs: Long,
|
||||
initializationDurationMs: Long
|
||||
) {
|
||||
audioDecoder.set(decoderName)
|
||||
Log.i(TAG, "audio decoder: $decoderName")
|
||||
}
|
||||
|
||||
override fun onAudioTrackInitialized(
|
||||
eventTime: AnalyticsListener.EventTime,
|
||||
config: AudioSink.AudioTrackConfig
|
||||
) {
|
||||
trackEncoding.set(config.sampleRate)
|
||||
Log.i(TAG, "AudioTrack rate=${config.sampleRate} buffer=${config.bufferSize}")
|
||||
}
|
||||
|
||||
override fun onIsPlayingChanged(eventTime: AnalyticsListener.EventTime, isPlaying: Boolean) {
|
||||
if (isPlaying) playing.countDown()
|
||||
}
|
||||
}
|
||||
)
|
||||
val source = ProgressiveMediaSource.Factory(
|
||||
DefaultDataSource.Factory(context),
|
||||
DefaultExtractorsFactory()
|
||||
).createMediaSource(MediaItem.fromUri(Uri.fromFile(fixture)))
|
||||
exo.setMediaSource(source)
|
||||
exo.prepare()
|
||||
exo.playWhenReady = true
|
||||
}
|
||||
|
||||
assertTrue("playback never started", playing.await(30, TimeUnit.SECONDS))
|
||||
Thread.sleep(SETTLE_MS)
|
||||
|
||||
val carrierEncoding = trackEncoding.get()
|
||||
val decoderBefore = audioDecoder.get()
|
||||
Log.i(TAG, "==== BEFORE: rate=$carrierEncoding decoder=$decoderBefore ====")
|
||||
|
||||
// AudioTrackConfig is built from OutputConfig, which stays PCM16 by design; the real
|
||||
// AudioTrack format is swapped in the builder modifier. The observable carrier signature is
|
||||
// therefore the 192kHz carrier rate with no decoder instantiated.
|
||||
if (carrierEncoding != IecCarrier.SAMPLE_RATE || decoderBefore != null) {
|
||||
Log.i(TAG, "==== SKIPPED: device does not take the carrier (rate=$carrierEncoding) ====")
|
||||
teardown(handler, player, thread, fixture)
|
||||
return
|
||||
}
|
||||
|
||||
val positionBefore = positionOf(handler, player)
|
||||
handler.post { player.get()?.setPlaybackSpeed(1.5f) }
|
||||
Thread.sleep(SETTLE_MS)
|
||||
|
||||
val encodingAfter = trackEncoding.get()
|
||||
val decoderAfter = audioDecoder.get()
|
||||
val positionAfter = positionOf(handler, player)
|
||||
Log.i(
|
||||
TAG,
|
||||
"==== AFTER: rate=$encodingAfter decoder=$decoderAfter " +
|
||||
"position=${positionBefore}ms -> ${positionAfter}ms ===="
|
||||
)
|
||||
|
||||
// Returning to 1x has to re-offer the carrier, or one speed nudge costs Atmos for the session.
|
||||
trackEncoding.set(-1)
|
||||
handler.post { player.get()?.setPlaybackSpeed(1f) }
|
||||
Thread.sleep(SETTLE_MS)
|
||||
val rateRestored = trackEncoding.get()
|
||||
Log.i(TAG, "==== RESTORED: rate=$rateRestored ====")
|
||||
|
||||
teardown(handler, player, thread, fixture)
|
||||
|
||||
assertEquals(
|
||||
"returning to 1x must put TrueHD back on the carrier",
|
||||
IecCarrier.SAMPLE_RATE,
|
||||
rateRestored
|
||||
)
|
||||
assertNotEquals(
|
||||
"TrueHD must leave the IEC 61937 carrier when speed leaves 1x",
|
||||
IecCarrier.SAMPLE_RATE,
|
||||
encodingAfter
|
||||
)
|
||||
assertTrue("a decoder must take over the TrueHD track", decoderAfter != null)
|
||||
assertTrue("the clock must keep advancing after the switch", positionAfter > positionBefore)
|
||||
// The whole point of the switch: the speed the user asked for has to actually apply. At 1.5x
|
||||
// the media clock must outrun the 4s of wall time spent waiting.
|
||||
assertTrue(
|
||||
"playback must run faster than real time after the switch, advanced " +
|
||||
"${positionAfter - positionBefore}ms in ${SETTLE_MS}ms",
|
||||
positionAfter - positionBefore > SETTLE_MS * 6 / 5
|
||||
)
|
||||
}
|
||||
|
||||
private fun positionOf(handler: Handler, player: AtomicReference<ExoPlayer?>): Long {
|
||||
val value = AtomicReference(0L)
|
||||
val done = CountDownLatch(1)
|
||||
handler.post {
|
||||
value.set(player.get()?.currentPosition ?: 0L)
|
||||
done.countDown()
|
||||
}
|
||||
done.await(5, TimeUnit.SECONDS)
|
||||
return value.get()
|
||||
}
|
||||
|
||||
private fun teardown(
|
||||
handler: Handler,
|
||||
player: AtomicReference<ExoPlayer?>,
|
||||
thread: HandlerThread,
|
||||
fixture: File
|
||||
) {
|
||||
val done = CountDownLatch(1)
|
||||
handler.post {
|
||||
player.get()?.release()
|
||||
done.countDown()
|
||||
}
|
||||
done.await(10, TimeUnit.SECONDS)
|
||||
thread.quitSafely()
|
||||
thread.join(5_000)
|
||||
fixture.delete()
|
||||
}
|
||||
|
||||
private fun copyFixture(
|
||||
context: android.content.Context,
|
||||
asset: String = "ffmpeg/truehd_speed_repro.mka"
|
||||
): File {
|
||||
val output = File.createTempFile("truehd-speed-", null, context.cacheDir)
|
||||
InstrumentationRegistry.getInstrumentation().context.assets
|
||||
.open(asset)
|
||||
.use { input -> output.outputStream().use { input.copyTo(it) } }
|
||||
return output
|
||||
}
|
||||
|
||||
/**
|
||||
* The container announces 48kHz, which selection trusts, but the bitstream is genuinely 44.1kHz
|
||||
* TrueHD, which the carrier does not cover (#1804). The packer emits nothing in that state, so
|
||||
* the sink has to hand the stream to the decoder rather than play silence.
|
||||
*/
|
||||
@Test
|
||||
fun aRateFamilyMismatchFallsBackToTheDecoderInsteadOfGoingSilent() {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
if (!supportsIecCarrier(context)) {
|
||||
Log.i(TAG, "==== MISMATCH SKIPPED: device has no carrier route ====")
|
||||
return
|
||||
}
|
||||
val fixture = copyFixture(context, "ffmpeg/truehd_mismatch_repro.mka")
|
||||
val thread = HandlerThread("truehd-mismatch-test").apply { start() }
|
||||
val handler = Handler(thread.looper)
|
||||
|
||||
val playing = CountDownLatch(1)
|
||||
val audioDecoder = AtomicReference<String?>(null)
|
||||
val trackRate = AtomicReference(-1)
|
||||
val rateSequence = java.util.Collections.synchronizedList(mutableListOf<Int>())
|
||||
val diagnostics = java.util.Collections.synchronizedList(mutableListOf<String>())
|
||||
val player = AtomicReference<ExoPlayer?>(null)
|
||||
|
||||
handler.post {
|
||||
val factory = PlezyRenderersFactory(context).apply {
|
||||
setEnableDecoderFallback(true)
|
||||
setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON)
|
||||
audioDiagnosticsLogger = { _, _, message ->
|
||||
diagnostics.add(message)
|
||||
Log.i(TAG, "mismatch sink: $message")
|
||||
}
|
||||
}
|
||||
val selector = DefaultTrackSelector(context).apply {
|
||||
setParameters(
|
||||
buildUponParameters().setAllowInvalidateSelectionsOnRendererCapabilitiesChange(true)
|
||||
)
|
||||
}
|
||||
val exo = ExoPlayer.Builder(context, factory).setTrackSelector(selector).build()
|
||||
player.set(exo)
|
||||
exo.addAnalyticsListener(
|
||||
object : AnalyticsListener {
|
||||
override fun onAudioDecoderInitialized(
|
||||
eventTime: AnalyticsListener.EventTime,
|
||||
decoderName: String,
|
||||
initializedTimestampMs: Long,
|
||||
initializationDurationMs: Long
|
||||
) {
|
||||
audioDecoder.set(decoderName)
|
||||
Log.i(TAG, "mismatch audio decoder: $decoderName")
|
||||
}
|
||||
|
||||
override fun onAudioTrackInitialized(
|
||||
eventTime: AnalyticsListener.EventTime,
|
||||
config: AudioSink.AudioTrackConfig
|
||||
) {
|
||||
trackRate.set(config.sampleRate)
|
||||
rateSequence.add(config.sampleRate)
|
||||
Log.i(TAG, "mismatch AudioTrack rate=${config.sampleRate}")
|
||||
}
|
||||
|
||||
override fun onAudioInputFormatChanged(
|
||||
eventTime: AnalyticsListener.EventTime,
|
||||
format: androidx.media3.common.Format,
|
||||
decoderReuseEvaluation: androidx.media3.exoplayer.DecoderReuseEvaluation?
|
||||
) {
|
||||
Log.i(TAG, "mismatch INPUT format: mime=${format.sampleMimeType} rate=${format.sampleRate} ch=${format.channelCount}")
|
||||
}
|
||||
|
||||
override fun onIsPlayingChanged(eventTime: AnalyticsListener.EventTime, isPlaying: Boolean) {
|
||||
if (isPlaying) playing.countDown()
|
||||
}
|
||||
}
|
||||
)
|
||||
val source = ProgressiveMediaSource.Factory(
|
||||
DefaultDataSource.Factory(context),
|
||||
DefaultExtractorsFactory()
|
||||
).createMediaSource(MediaItem.fromUri(Uri.fromFile(fixture)))
|
||||
exo.setMediaSource(source)
|
||||
exo.prepare()
|
||||
exo.playWhenReady = true
|
||||
}
|
||||
|
||||
assertTrue("playback never started", playing.await(30, TimeUnit.SECONDS))
|
||||
Thread.sleep(SETTLE_MS)
|
||||
val positionFirst = positionOf(handler, player)
|
||||
Thread.sleep(SETTLE_MS)
|
||||
val positionSecond = positionOf(handler, player)
|
||||
|
||||
val decoder = audioDecoder.get()
|
||||
val rate = trackRate.get()
|
||||
Log.i(
|
||||
TAG,
|
||||
"==== MISMATCH RESULT: rates=$rateSequence decoder=$decoder rate=$rate " +
|
||||
"position=${positionFirst}ms -> ${positionSecond}ms ===="
|
||||
)
|
||||
teardown(handler, player, thread, fixture)
|
||||
|
||||
// Without this the test would also pass on a build where the carrier was never selected at all:
|
||||
// the mismatch fires on the first access unit, before the carrier writes a burst, so no 192kHz
|
||||
// AudioTrack is ever opened and the rate sequence alone cannot tell the two apart.
|
||||
assertTrue(
|
||||
"the carrier must have been entered and then left at runtime, saw: $diagnostics",
|
||||
diagnostics.any { it.contains("via IEC 61937 carrier") } &&
|
||||
diagnostics.any { it.contains("44.1kHz-family rate its container did not") }
|
||||
)
|
||||
assertTrue("a decoder must take the stream over instead of the carrier", decoder != null)
|
||||
assertNotEquals(
|
||||
"the stream must not still be riding the carrier",
|
||||
IecCarrier.SAMPLE_RATE,
|
||||
rate
|
||||
)
|
||||
assertTrue("playback must keep advancing after the fallback", positionSecond > positionFirst)
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,11 @@
|
||||
<!-- android:installLocation="auto" is what makes the app eligible to be moved to adoptable
|
||||
storage (a USB drive adopted by an Android TV, an adopted SD card), which relocates the
|
||||
private data directory — and therefore downloads — along with the APK. Plezy declares none
|
||||
of the components this is unsafe for: no app widget, IME, live wallpaper, device admin,
|
||||
accessibility service, sync adapter or account authenticator. -->
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:installLocation="auto">
|
||||
<!-- Allow minSdk=25 despite libmpv-android declaring minSdk=26 -->
|
||||
<uses-sdk tools:overrideLibrary="dev.jdtech.mpv" />
|
||||
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- Internet access permissions -->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
|
||||
|
||||
|
||||
<!-- Media session permissions for OS media controls -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
|
||||
<uses-permission android:name="android.permission.REORDER_TASKS"/>
|
||||
|
||||
<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" />
|
||||
@@ -26,80 +13,32 @@
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" tools:node="remove" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" tools:node="remove" />
|
||||
|
||||
|
||||
<uses-feature android:name="android.software.leanback" android:required="false" />
|
||||
<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA"/>
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
|
||||
|
||||
<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">
|
||||
<!-- Driver-distraction state on Android Automotive OS. Optional: the platform library is
|
||||
absent on every other form factor, and CarRestrictionsMonitor falls back silently. -->
|
||||
<uses-library android:name="android.car" android:required="false" />
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:taskAffinity=""
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:supportsPictureInPicture="true"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|navigation|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|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"/>
|
||||
<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>
|
||||
<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
|
||||
@@ -125,15 +64,5 @@
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:scheme="http" />
|
||||
</intent>
|
||||
<!-- Required to resolve external video player apps on Android 11+ -->
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:mimeType="video/*" />
|
||||
</intent>
|
||||
<!-- 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);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user