Compare commits

..
Author SHA1 Message Date
github-actions[bot] 977414bc3c Update appcast for 2.19.1 2026-09-07 20:20:33 +00:00
2420 changed files with 21 additions and 762812 deletions
-9
View File
@@ -1,9 +0,0 @@
BasedOnStyle: Google
AlignAfterOpenBracket: AlwaysBreak
ColumnLimit: 120
IndentWidth: 2
ContinuationIndentWidth: 4
DerivePointerAlignment: false
LineEnding: LF
PointerAlignment: Left
SortIncludes: true
-7
View File
@@ -1,7 +0,0 @@
**
!.maestro/
!.maestro/jellyfin-demo/
!.maestro/jellyfin-demo/**
!scripts/
!scripts/maestro/maestro_fixtures.py
!scripts/maestro/maestro_real_jellyfin.py
-28
View File
@@ -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
-17
View File
@@ -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
-21
View File
@@ -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"
-2
View File
@@ -1,2 +0,0 @@
github: edde746
liberapay: edde746
-124
View File
@@ -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
-5
View File
@@ -1,5 +0,0 @@
blank_issues_enabled: false
contact_links:
- name: Questions and Discussions
url: https://github.com/edde746/plezy/discussions
about: Ask questions and discuss ideas with the community
@@ -1,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"
}
-6
View File
@@ -1,6 +0,0 @@
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: monthly
File diff suppressed because it is too large Load Diff
-763
View File
@@ -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
-88
View File
@@ -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"
-139
View File
@@ -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
-77
View File
@@ -1,77 +0,0 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# Keep .vscode/ available for contributors' local configuration.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/debug-info/
# Windows Store packaging output (windows/build-msix.ps1)
/staging-msix/
/plezy-windows.msixbundle
/AppxManifest.*.xml
# tvOS: build artifacts and generated assets (regenerated by xcode_appletv.sh)
tvos/build/
tvos/Pods/
tvos/Podfile.lock
tvos/Runner.xcworkspace/xcuserdata/
tvos/Runner.xcodeproj/xcuserdata/
tvos/Runner.xcodeproj/project.xcworkspace/xcuserdata/
tvos/Flutter/Generated.xcconfig
tvos/Flutter/flutter_export_environment.sh
tvos/Flutter/libPods-Runner.a
tvos/tvos_flutter_assets/
tvos/Runner/GeneratedPluginRegistrant.h
tvos/Runner/GeneratedPluginRegistrant.m
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release
# Code duplication reports
duplication-report/
.env.*
.env
!.env.example
**/fastlane/**/
**/fastlane/report.xml
maestro/**/*.png
server/deploy.sh
server/Caddyfile
-15
View File
@@ -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
-43
View File
@@ -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"
-13
View File
@@ -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.*"
-33
View File
@@ -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.*"
-33
View File
@@ -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.*"
-57
View File
@@ -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
-11
View File
@@ -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
-87
View File
@@ -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
-23
View File
@@ -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
-56
View File
@@ -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"]
-17
View File
@@ -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 "$@"
-46
View File
@@ -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
-12
View File
@@ -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
-28
View File
@@ -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
-28
View File
@@ -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
-28
View File
@@ -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
-28
View File
@@ -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
-29
View File
@@ -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
-28
View File
@@ -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
-38
View File
@@ -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
-54
View File
@@ -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"
-11
View File
@@ -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.*"
-17
View File
@@ -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
-48
View File
@@ -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
-46
View File
@@ -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$"
-23
View File
@@ -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
-45
View File
@@ -1,45 +0,0 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "077b4a4ce10a07b82caa6897f0c626f9c0a3ac90"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: 077b4a4ce10a07b82caa6897f0c626f9c0a3ac90
base_revision: 077b4a4ce10a07b82caa6897f0c626f9c0a3ac90
- platform: android
create_revision: 077b4a4ce10a07b82caa6897f0c626f9c0a3ac90
base_revision: 077b4a4ce10a07b82caa6897f0c626f9c0a3ac90
- platform: ios
create_revision: 077b4a4ce10a07b82caa6897f0c626f9c0a3ac90
base_revision: 077b4a4ce10a07b82caa6897f0c626f9c0a3ac90
- platform: linux
create_revision: 077b4a4ce10a07b82caa6897f0c626f9c0a3ac90
base_revision: 077b4a4ce10a07b82caa6897f0c626f9c0a3ac90
- platform: macos
create_revision: 077b4a4ce10a07b82caa6897f0c626f9c0a3ac90
base_revision: 077b4a4ce10a07b82caa6897f0c626f9c0a3ac90
- platform: web
create_revision: 077b4a4ce10a07b82caa6897f0c626f9c0a3ac90
base_revision: 077b4a4ce10a07b82caa6897f0c626f9c0a3ac90
- platform: windows
create_revision: 077b4a4ce10a07b82caa6897f0c626f9c0a3ac90
base_revision: 077b4a4ce10a07b82caa6897f0c626f9c0a3ac90
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'
-10
View File
@@ -1,10 +0,0 @@
{
"indentation" : {
"spaces" : 2
},
"lineLength" : 120,
"maximumBlankLines" : 1,
"respectsExistingLineBreaks" : true,
"rules" : {},
"version" : 1
}
-147
View File
@@ -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
-35
View File
@@ -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
-674
View File
@@ -1,674 +0,0 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
-208
View File
@@ -1,208 +0,0 @@
<h1>
<img src="assets/plezy.png" alt="Plezy Logo" height="24" style="vertical-align: middle;" />
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>
<p align="center">
<img src="assets/readme-showcase.webp" alt="Plezy mobile screenshots" width="900" />
</p>
## Download
<a href='https://apps.apple.com/app/apple-store/id6754315964?pt=128238902&ct=GitHub&mt=8'><img height='60' alt='Download on the App Store' src='./assets/app-store-badge.png'/></a>
<a href='https://play.google.com/store/apps/details?id=com.edde746.plezy&referrer=utm_source%3Dgithub%26utm_campaign%3Dreadme_badge'><img height='60' alt='Get it on Google Play' src='./assets/play-store-badge.png'/></a>
<a href='https://www.amazon.com/gp/product/B0GK65CVS1'><img height='60' alt='Available at the Amazon App Store' src='./assets/amazon-badge.png'/></a>
<a href='https://get.microsoft.com/installer/download/9n5r1s1t68h7?referrer=appbadge&cid=github'><img height='60' alt='Get it from Microsoft' src='./assets/microsoft-badge.png'/></a>
| Platform | Download |
| --- | --- |
| macOS | [DMG (x64, arm64)](https://github.com/edde746/plezy/releases/latest/download/plezy-macos.dmg) |
| Linux x64 | [.deb](https://github.com/edde746/plezy/releases/latest/download/plezy-linux-x64.deb) · [.rpm](https://github.com/edde746/plezy/releases/latest/download/plezy-linux-x64.rpm) · [.pkg.tar.zst](https://github.com/edde746/plezy/releases/latest/download/plezy-linux-x64.pkg.tar.zst) · [portable tar.gz](https://github.com/edde746/plezy/releases/latest/download/plezy-linux-x64.tar.gz) |
| Linux arm64 | [.deb](https://github.com/edde746/plezy/releases/latest/download/plezy-linux-arm64.deb) · [.rpm](https://github.com/edde746/plezy/releases/latest/download/plezy-linux-arm64.rpm) · [.pkg.tar.zst](https://github.com/edde746/plezy/releases/latest/download/plezy-linux-arm64.pkg.tar.zst) · [portable tar.gz](https://github.com/edde746/plezy/releases/latest/download/plezy-linux-arm64.tar.gz) |
Package managers:
- [Nix](https://search.nixos.org/packages?channel=unstable&query=plezy) - Community package by [@mio-19](https://github.com/mio-19) and [@MiniHarinn](https://github.com/MiniHarinn)
- **Homebrew** (macOS):
```bash
brew tap edde746/plezy https://github.com/edde746/plezy
brew install --cask plezy
```
- [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
```
## 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
### <img src="assets/readme_icons/explore.svg" height="20" alt="" align="center" /> Explore & Requests
- Explore tab — watchlist, trending, popular, and recommendation rows from Plex Discover[^plex], Trakt, MyAnimeList, AniList, Simkl, and Seerr[^connect]
- Search any connected catalog source
- Catalog titles matched back to your own libraries by external ID
- Seerr — request movies and shows with per-season, 4K, and advanced destination options, and see request status inline
- Watchlist sync — add and remove titles on Plex, Trakt, MyAnimeList, AniList, and Simkl from anywhere in the app
### <img src="assets/readme_icons/playback.svg" height="20" alt="" align="center" /> Playback
- Wide codec support (HEVC, AV1, VP9, and more)
- HDR and Dolby Vision[^hdr]
- Direct play, or transcode presets from 240p/320 kbps to 1080p/20 Mbps
- Multi-version switching with per-version file details
- Full ASS/SSA subtitles with customizable styling
- Online subtitle search & download[^plex]
- Audio & subtitle choices remembered per title, or follow the server's per-episode selections
- Progress sync and resume
- Auto-play next episode with skip intro / skip credits
- Chapter navigation with thumbnail scrub previews
- Playback speed from 0.25x to 8x, audio sync offset, sleep timer (fixed durations or end of video)
- Video zoom 50-200% with pinch, presets, and hotkeys
- Audio passthrough[^pass], stereo downmix with center-channel boost, and loudness normalization
- File Info sheet — every version, file, and stream the server reports
- Ambient lighting and GLSL shader presets[^mpv]
- Picture-in-Picture[^pip]
- Refresh-rate matching[^rrm]
- External player launch (VLC, MX Player, etc.) with progress sync back[^android]
### <img src="assets/readme_icons/music.svg" height="20" alt="" align="center" /> Music
- Music libraries — artist, album, and track browsing with square artwork
- Album and artist screens with play, shuffle, and Instant Mix
- Gapless playback with a full play queue — reorder, remove, play next, add to queue
- Now Playing with synced lyrics[^lyrics], persistent mini-player, and sleep timer
- Background playback with lock-screen, media-key, and notification controls[^bgaudio]
- Offline playback of downloaded albums and tracks
- Streaming quality presets — Original, 320, 192, or 128 kbps
### <img src="assets/readme_icons/live-tv.svg" height="20" alt="" align="center" /> Live TV & DVR
- Live TV channel browsing, tuning, and favorites
- EPG guide with What's On and per-show schedules
- DVR recording rules, scheduled recordings, and a rememberable recording target library[^plex]
- Multi-server Live TV support where available
### <img src="assets/readme_icons/downloads.svg" height="20" alt="" align="center" /> Downloads & Offline
- Download movies, shows, and music for offline playback[^dl]
- Background queue with pause / resume
- Sync rules for automatic downloads, with per-show "Include Specials"
- Offline browsing with watch state sync-back on reconnect
### <img src="assets/readme_icons/watch-together.svg" height="20" alt="" align="center" /> Watch Together
- Synchronized playback with friends
- Real-time play / pause / seek sync
### <img src="assets/readme_icons/integrations.svg" height="20" alt="" align="center" /> Integrations
- Discord Rich Presence[^desktop]
- Trakt, MyAnimeList, AniList, and Simkl — ratings, watched sync, and real-time scrobbling[^rt]
- Plezy Remote — control desktop and TV from mobile
- Watch Next row and tvOS Top Shelf[^shelf]
### <img src="assets/readme_icons/customization.svg" height="20" alt="" align="center" /> Platform & Customization
- Desktop, mobile, and TV — full D-pad, keyboard, and gamepad support
- Multiple servers at once — Plex, 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
```bash
git clone https://github.com/edde746/plezy.git
cd plezy
flutter pub get
scripts/codegen.sh
flutter run
```
### Code Generation
After modifying model classes or other generated sources:
```bash
scripts/codegen.sh
```
After modifying translations:
```bash
dart run slang
```
### Local Checks
```bash
scripts/ci_checks.sh
```
To install the same pre-commit checks locally:
```bash
scripts/setup_hooks.sh
```
End-to-end tests (Android emulator plus a Dockerized Jellyfin fixture):
```bash
python3 scripts/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)
-90
View File
@@ -1,90 +0,0 @@
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
-11
View File
@@ -1,11 +0,0 @@
/.gradle
/captures/
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks
-10
View File
@@ -1,10 +0,0 @@
# Autogenerated by fastlane
#
# Ensure this file is checked in to source control!
source "https://rubygems.org"
gem 'fastlane'
plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile')
eval_gemfile(plugins_path) if File.exist?(plugins_path)
-244
View File
@@ -1,244 +0,0 @@
GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.8)
abbrev (0.1.2)
addressable (2.8.8)
public_suffix (>= 2.0.2, < 8.0)
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.4.0)
aws-partitions (1.1215.0)
aws-sdk-core (3.242.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
base64
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.122.0)
aws-sdk-core (~> 3, >= 3.241.4)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.213.0)
aws-sdk-core (~> 3, >= 3.241.4)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4)
base64 (0.2.0)
benchmark (0.5.0)
bigdecimal (4.0.1)
claide (1.1.0)
colored (1.2)
colored2 (3.1.2)
commander (4.6.0)
highline (~> 2.0.0)
csv (3.3.5)
declarative (0.0.20)
digest-crc (0.7.0)
rake (>= 12.0.0, < 14.0.0)
domain_name (0.6.20240107)
dotenv (2.8.1)
emoji_regex (3.2.3)
excon (0.112.0)
faraday (1.10.5)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1)
faraday-httpclient (~> 1.0)
faraday-multipart (~> 1.0)
faraday-net_http (~> 1.0)
faraday-net_http_persistent (~> 1.0)
faraday-patron (~> 1.0)
faraday-rack (~> 1.0)
faraday-retry (~> 1.0)
ruby2_keywords (>= 0.0.4)
faraday-cookie_jar (0.0.8)
faraday (>= 0.8.0)
http-cookie (>= 1.0.0)
faraday-em_http (1.0.0)
faraday-em_synchrony (1.0.1)
faraday-excon (1.1.0)
faraday-httpclient (1.0.1)
faraday-multipart (1.2.0)
multipart-post (~> 2.0)
faraday-net_http (1.0.2)
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
faraday-retry (1.0.3)
faraday_middleware (1.2.1)
faraday (~> 1.0)
fastimage (2.4.0)
fastlane (2.232.1)
CFPropertyList (>= 2.3, < 4.0.0)
abbrev (~> 0.1.2)
addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0)
aws-sdk-s3 (~> 1.197)
babosa (>= 1.0.3, < 2.0.0)
base64 (~> 0.2.0)
benchmark (>= 0.1.0)
bundler (>= 1.17.3, < 5.0.0)
colored (~> 1.2)
commander (~> 4.6)
csv (~> 3.3)
dotenv (>= 2.1.1, < 3.0.0)
emoji_regex (>= 0.1, < 4.0)
excon (>= 0.71.0, < 1.0.0)
faraday (~> 1.0)
faraday-cookie_jar (~> 0.0.6)
faraday_middleware (~> 1.0)
fastimage (>= 2.1.0, < 3.0.0)
fastlane-sirp (>= 1.0.0)
gh_inspector (>= 1.1.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.3)
google-apis-playcustomapp_v1 (~> 0.1)
google-cloud-env (>= 1.6.0, <= 2.1.1)
google-cloud-storage (~> 1.31)
highline (~> 2.0)
http-cookie (~> 1.0.5)
json (< 3.0.0)
jwt (>= 2.1.0, < 3)
logger (>= 1.6, < 2.0)
mini_magick (>= 4.9.4, < 5.0.0)
multipart-post (>= 2.0.0, < 3.0.0)
mutex_m (~> 0.3.0)
naturally (~> 2.2)
nkf (~> 0.2.0)
optparse (>= 0.1.1, < 1.0.0)
ostruct (>= 0.1.0)
plist (>= 3.1.0, < 4.0.0)
rubyzip (>= 2.0.0, < 3.0.0)
security (= 0.1.5)
simctl (~> 1.6.3)
terminal-notifier (>= 2.0.0, < 3.0.0)
terminal-table (~> 3)
tty-screen (>= 0.6.3, < 1.0.0)
tty-spinner (>= 0.8.0, < 1.0.0)
word_wrap (~> 1.0.0)
xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.4.1)
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
fastlane-plugin-amazon_appstore (1.4.0)
faraday (~> 1.0)
faraday_middleware (~> 1.0)
fastlane-sirp (1.0.0)
sysrandom (~> 1.0)
gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.96.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-core (0.18.0)
addressable (~> 2.5, >= 2.5.1)
googleauth (~> 1.9)
httpclient (>= 2.8.3, < 3.a)
mini_mime (~> 1.0)
mutex_m
representable (~> 3.0)
retriable (>= 2.0, < 4.a)
google-apis-iamcredentials_v1 (0.26.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-playcustomapp_v1 (0.17.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-storage_v1 (0.60.0)
google-apis-core (>= 0.15.0, < 2.a)
google-cloud-core (1.8.0)
google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0)
google-cloud-env (2.1.1)
faraday (>= 1.0, < 3.a)
google-cloud-errors (1.5.0)
google-cloud-storage (1.58.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-core (>= 0.18, < 2)
google-apis-iamcredentials_v1 (~> 0.18)
google-apis-storage_v1 (>= 0.42)
google-cloud-core (~> 1.6)
googleauth (~> 1.9)
mini_mime (~> 1.0)
googleauth (1.11.2)
faraday (>= 1.0, < 3.a)
google-cloud-env (~> 2.1)
jwt (>= 1.4, < 3.0)
multi_json (~> 1.11)
os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a)
highline (2.0.3)
http-cookie (1.0.8)
domain_name (~> 0.5)
httpclient (2.9.0)
mutex_m
jmespath (1.6.2)
json (2.18.1)
jwt (2.10.2)
base64
logger (1.7.0)
mini_magick (4.13.2)
mini_mime (1.1.5)
multi_json (1.19.1)
multipart-post (2.4.1)
mutex_m (0.3.0)
nanaimo (0.4.0)
naturally (2.3.0)
nkf (0.2.0)
optparse (0.8.1)
os (1.1.4)
ostruct (0.6.3)
plist (3.7.2)
public_suffix (7.0.2)
rake (13.3.1)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
retriable (3.1.2)
rexml (3.4.4)
rouge (3.28.0)
ruby2_keywords (0.0.5)
rubyzip (2.4.1)
security (0.1.5)
signet (0.21.0)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 4.0)
multi_json (~> 1.10)
simctl (1.6.10)
CFPropertyList
naturally
sysrandom (1.0.5)
terminal-notifier (2.0.0)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
trailblazer-option (0.1.2)
tty-cursor (0.7.1)
tty-screen (0.8.2)
tty-spinner (0.9.3)
tty-cursor (~> 0.7)
uber (0.1.0)
unicode-display_width (2.6.0)
word_wrap (1.0.0)
xcodeproj (1.27.0)
CFPropertyList (>= 2.3.3, < 4.0)
atomos (~> 0.1.3)
claide (>= 1.0.2, < 2.0)
colored2 (~> 3.1)
nanaimo (~> 0.4.0)
rexml (>= 3.3.6, < 4.0)
xcpretty (0.4.1)
rouge (~> 3.28.0)
xcpretty-travis-formatter (1.0.1)
xcpretty (~> 0.2, >= 0.0.7)
PLATFORMS
arm64-darwin-25
ruby
DEPENDENCIES
fastlane
fastlane-plugin-amazon_appstore
BUNDLED WITH
2.7.2
-560
View File
@@ -1,560 +0,0 @@
import java.io.FileInputStream
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.security.MessageDigest
import java.util.Properties
import java.util.UUID
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
fun verifySha256(file: File, expected: String, identity: String) {
val digest = MessageDigest.getInstance("SHA-256")
file.inputStream().buffered().use { input ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
while (true) {
val count = input.read(buffer)
if (count < 0) break
digest.update(buffer, 0, count)
}
}
val actual = digest.digest().joinToString("") {
(it.toInt() and 0xff).toString(16).padStart(2, '0')
}
if (actual != expected) {
throw GradleException("SHA-256 mismatch for $identity: expected $expected, got $actual")
}
}
fun promoteDirectory(staging: File, destination: File) {
val backup = File(destination.parentFile, "${destination.name}.backup-${UUID.randomUUID()}")
val hadDestination = destination.exists()
try {
if (hadDestination) {
Files.move(destination.toPath(), backup.toPath(), StandardCopyOption.ATOMIC_MOVE)
}
try {
Files.move(staging.toPath(), destination.toPath(), StandardCopyOption.ATOMIC_MOVE)
} catch (promotionFailure: Exception) {
if (hadDestination && backup.exists()) {
try {
Files.move(backup.toPath(), destination.toPath(), StandardCopyOption.ATOMIC_MOVE)
} catch (restoreFailure: Exception) {
promotionFailure.addSuppressed(restoreFailure)
}
}
throw promotionFailure
}
if (hadDestination && backup.exists() && !backup.deleteRecursively()) {
throw GradleException("Failed to remove obsolete native artifact backup at ${backup.absolutePath}")
}
} finally {
staging.deleteRecursively()
}
}
plugins {
id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
val mpvVersion = "v1.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()
}
}
}
android {
namespace = "com.edde746.plezy"
compileSdk = flutter.compileSdkVersion
buildToolsVersion = "36.1.0"
ndkVersion = "29.0.14206865"
// 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}"
)
}
}
if (System.getenv("AMAZON") != null) {
versionCode = (flutter.versionCode ?: 0) + 3000
ndk {
abiFilters += listOf("armeabi-v7a", "arm64-v8a")
}
}
}
externalNativeBuild {
cmake {
path = file("src/main/cpp/CMakeLists.txt")
version = "4.1.2"
}
}
signingConfigs {
create("release") {
val keystorePropertiesFile = rootProject.file("key.properties")
if (keystorePropertiesFile.exists()) {
val keystoreProperties = Properties()
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
keyAlias = keystoreProperties["keyAlias"] as String
keyPassword = keystoreProperties["keyPassword"] as String
storeFile = file(keystoreProperties["storeFile"] as String)
storePassword = keystoreProperties["storePassword"] as String
}
}
}
buildTypes {
release {
// Only use release signing if key.properties exists (not in CI/CD)
val keystorePropertiesFile = rootProject.file("key.properties")
if (keystorePropertiesFile.exists()) {
signingConfig = signingConfigs.getByName("release")
}
// If key.properties doesn't exist, it will use debug signing for CI builds
ndk {
debugSymbolLevel = "FULL"
}
}
// Instrumentation target that runs R8 (see testBuildType below).
//
// R8 only ever ran on `release`, so every gate in this repository exercised code
// the shipped APK does not contain: reflective lookups, JNI callbacks and native
// library loading can all break under shrinking while every debug check passes.
// #1703 shipped that way — DefaultRenderersFactory's Class.forName for the bundled
// FFmpeg audio renderer failed in release builds only.
//
// Inherits release's minification and keep rules (the Flutter plugin has already
// installed them by the time this block runs) but stays debuggable and debug-signed,
// so it is an ordinary test artifact and never a publishable one. Debuggable also
// makes the Flutter plugin treat it as debug mode, so it uses debug Dart artifacts.
create("minified") {
initWith(getByName("release"))
isDebuggable = true
// Resource shrinking is orthogonal to the reachability this variant guards and
// would only slow the instrumentation build down.
isShrinkResources = false
testProguardFiles("proguard-test-rules.pro")
proguardFile("proguard-instrumentation-rules.pro")
// Release has no signing config unless key.properties exists, which would leave
// this variant unsigned and uninstallable in CI.
signingConfig = signingConfigs.getByName("debug")
// Plugin subprojects only publish debug and release variants.
matchingFallbacks += listOf("debug", "release")
ndk {
debugSymbolLevel = "NONE"
}
}
}
// Instrumentation normally runs against `debug`; the R8 reachability gate opts into the
// minified variant with -Pplezy.testBuildType=minified. Only one build type can host
// androidTest, and the existing playback suites need media3 builder APIs the app itself
// never calls — which R8 legitimately shrinks — so they stay on debug.
testBuildType = (findProperty("plezy.testBuildType") as String?) ?: "debug"
packaging {
jniLibs {
// pickFirst only suppresses the duplicate libc++ merge error; the
// sourceSets rule below makes libmpv's newer runtime win for
// std::from_chars<float>, while older native consumers remain ABI-compatible.
pickFirsts.add("lib/*/libc++_shared.so")
}
}
sourceSets {
getByName("main") {
// PROJECT-scope jniLibs merge ahead of subprojects/AARs, so dependency
// order cannot accidentally select the older libc++ copy.
jniLibs.srcDir(File(mpvDir, "libcxx/jni"))
}
}
lint {
// Enforce the app-owned minSdk boundary without auditing upstream AndroidX.
checkDependencies = false
checkOnly += setOf("NewApi")
}
}
// BackgroundWorkDiagnostics routes users to background_downloader's private
// notification channel. Fail the build if an upstream ref changes that ID.
val verifyBackgroundDownloaderNotificationChannel = tasks.register("verifyBackgroundDownloaderNotificationChannel") {
val expectedChannelId = "background_downloader"
val downloaderProject = rootProject.findProject(":background_downloader")
val notificationsSource = downloaderProject?.projectDir?.resolve(
"src/main/kotlin/com/bbflight/background_downloader/Notifications.kt"
)
notificationsSource?.let(inputs::file)
doLast {
if (notificationsSource == null) {
logger.lifecycle("Skipping downloader channel verification: Flutter plugin project is not configured")
return@doLast
}
val actualChannelId = Regex(
"""private const val notificationChannelId\s*=\s*"([^"]+)""""
).find(notificationsSource.readText())?.groupValues?.get(1)
?: throw GradleException("Could not locate background_downloader's notification channel ID")
if (actualChannelId != expectedChannelId) {
throw GradleException(
"background_downloader channel ID changed from $expectedChannelId to $actualChannelId; " +
"update BackgroundWorkDiagnostics and its tests"
)
}
}
}
tasks.named("preBuild").configure {
dependsOn(verifyBackgroundDownloaderNotificationChannel)
}
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
}
}
flutter {
source = "../.."
}
tasks.matching { it.name.contains("CMake") || it.name.contains("externalNative") }.configureEach {
dependsOn(downloadLibdovi, prepareMpvFfmpegDevelopment)
}
tasks.matching { it.name.startsWith("pre") && it.name.endsWith("Build") }.configureEach {
dependsOn(downloadLibmpv, extractMpvLibcxx, prepareMpvFfmpegDevelopment)
}
// Gradle snapshots jniLibs source dirs before task execution; this keeps the
// extracted libmpv libc++ directory present during input discovery.
tasks.matching { it.name.startsWith("merge") && it.name.endsWith("JniLibFolders") }.configureEach {
dependsOn(extractMpvLibcxx)
}
dependencies {
implementation(files(File(mpvDir, mpvAar)))
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0")
// Android TV Watch Next integration
implementation("androidx.tvprovider:tvprovider:1.1.0")
// 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")
}
@@ -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.**
-37
View File
@@ -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;
}
-11
View File
@@ -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
@@ -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;
}
}
@@ -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))
}
}
@@ -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()
}
}
}
@@ -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
}
}
@@ -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
}
}
@@ -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
}
}
@@ -1,149 +0,0 @@
package com.edde746.plezy.exoplayer
import android.content.Context
import android.net.Uri
import android.os.Handler
import android.os.HandlerThread
import androidx.media3.common.MediaItem
import androidx.media3.common.MimeTypes
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.audio.ChannelMixingMatrix
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.ProgressiveMediaSource
import androidx.media3.extractor.DefaultExtractorsFactory
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import java.io.File
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class PlezyAudioModePlaybackTest {
private enum class AudioMode {
PASSTHROUGH_ALLOWED,
FORCE_DECODED,
DOWNMIX_NORMALIZED,
DOWNMIX_UNNORMALIZED,
NORMALIZATION
}
@Test
fun appAudioPipelinePlaysAcrossOutputModes() {
playToEnd("ffmpeg/surround_5_1_dts.mka", AudioMode.PASSTHROUGH_ALLOWED)
playToEnd("ffmpeg/surround_5_1_truehd.mka", AudioMode.FORCE_DECODED)
playToEnd("ffmpeg/surround_5_1.flac", AudioMode.DOWNMIX_NORMALIZED)
playToEnd("ffmpeg/surround_7_1.flac", AudioMode.DOWNMIX_UNNORMALIZED)
playToEnd("ffmpeg/surround_5_1_eac3.mka", AudioMode.NORMALIZATION)
}
private fun playToEnd(fixture: String, mode: AudioMode) {
val instrumentation = InstrumentationRegistry.getInstrumentation()
val context = instrumentation.targetContext
val fixtureFile = copyFixture(instrumentation.context, context, fixture)
val playbackThread = HandlerThread("plezy-audio-mode-test").apply { start() }
val handler = Handler(playbackThread.looper)
val completed = CountDownLatch(1)
val playerReference = AtomicReference<ExoPlayer>()
val errorReference = AtomicReference<Throwable>()
val outputPolicyConsulted = AtomicBoolean(false)
val normalizationAttachAttempted = AtomicBoolean(false)
val normalization = AudioNormalizationEffect { _, _, _ -> }
val downmixActive = AtomicBoolean(false)
handler.post {
try {
val factory = PlezyRenderersFactory(context).apply {
setEnableDecoderFallback(true)
setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON)
shouldBlockDirectAudioOutput = { format ->
val encoded = format.sampleMimeType != null && format.sampleMimeType != MimeTypes.AUDIO_RAW
if (encoded) outputPolicyConsulted.set(true)
encoded && mode != AudioMode.PASSTHROUGH_ALLOWED
}
if (mode == AudioMode.DOWNMIX_NORMALIZED || mode == AudioMode.DOWNMIX_UNNORMALIZED) {
val normalize = mode == AudioMode.DOWNMIX_NORMALIZED
for (channelCount in DownmixMatrices.MIN_DOWNMIX_INPUT_CHANNELS..DownmixMatrices.MAX_DOWNMIX_INPUT_CHANNELS) {
val coefficients = DownmixMatrices.stereoCoefficients(channelCount, centerBoostDb = 6, normalize = normalize)!!
channelMixProcessor.putChannelMixingMatrix(ChannelMixingMatrix(channelCount, 2, coefficients))
}
}
}
val player = ExoPlayer.Builder(context, factory)
.setLooper(playbackThread.looper)
.build()
playerReference.set(player)
player.addListener(object : Player.Listener {
override fun onAudioSessionIdChanged(audioSessionId: Int) {
if (mode == AudioMode.NORMALIZATION) {
normalizationAttachAttempted.set(true)
normalization.attach(audioSessionId, channelCount = 6)
}
}
override fun onPlayerError(error: PlaybackException) {
errorReference.set(error)
completed.countDown()
}
override fun onPlaybackStateChanged(playbackState: Int) {
if (playbackState == Player.STATE_ENDED) {
downmixActive.set(factory.channelMixProcessor.isActive)
completed.countDown()
}
}
})
val source = ProgressiveMediaSource.Factory(
DefaultDataSource.Factory(context),
DefaultExtractorsFactory()
).createMediaSource(MediaItem.fromUri(Uri.fromFile(fixtureFile)))
player.setMediaSource(source)
player.prepare()
player.play()
} catch (error: Throwable) {
errorReference.set(error)
completed.countDown()
}
}
val finished = completed.await(20, TimeUnit.SECONDS)
val released = CountDownLatch(1)
handler.post {
playerReference.get()?.release()
normalization.release()
playbackThread.quitSafely()
released.countDown()
}
val teardownFinished = released.await(5, TimeUnit.SECONDS)
playbackThread.join(5_000)
val fixtureDeleted = fixtureFile.delete()
assertTrue("Player teardown timed out for $mode / $fixture", teardownFinished)
assertTrue("Playback timed out for $mode / $fixture", finished)
assertNull("Playback failed for $mode / $fixture", errorReference.get())
assertTrue("Audio output policy was not consulted for $mode / $fixture", outputPolicyConsulted.get())
if (mode == AudioMode.DOWNMIX_NORMALIZED || mode == AudioMode.DOWNMIX_UNNORMALIZED) {
assertTrue("Downmix processor was inactive for $mode / $fixture", downmixActive.get())
}
if (mode == AudioMode.NORMALIZATION) {
assertTrue("Normalization did not receive an audio session", normalizationAttachAttempted.get())
}
assertTrue("Fixture cleanup failed for $mode / $fixture", fixtureDeleted)
}
private fun copyFixture(instrumentationContext: Context, targetContext: Context, fixture: String): File {
val output = File.createTempFile("audio-mode-fixture-", null, targetContext.cacheDir)
instrumentationContext.assets.open(fixture).use { input ->
output.outputStream().use(input::copyTo)
}
return output
}
}
@@ -1,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,7 +0,0 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
-139
View File
@@ -1,139 +0,0 @@
<!-- 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" />
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
<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" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" tools:node="remove" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" tools:node="remove" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" tools:node="remove" />
<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" />
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:supportsPictureInPicture="true"
android:configChanges="orientation|keyboardHidden|keyboard|navigation|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<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
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
<!-- Required for url_launcher to open HTTPS URLs -->
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="https" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="http" />
</intent>
<!-- Required to resolve external video player apps on Android 11+ -->
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:mimeType="video/*" />
</intent>
<!-- Required to identify HOME launchers as shelf artwork consumers. -->
<intent>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.HOME" />
</intent>
</queries>
</manifest>
-39
View File
@@ -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)
-132
View File
@@ -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;
}
}
}

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