Compare commits
80 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a1285dfa13 | |||
| c19c87c435 | |||
| 5d9f1ccb6d | |||
| c8fdef8bd1 | |||
| 4c99b78366 | |||
| f4c3a5d0c3 | |||
| 4839c0e6f6 | |||
| d0f68cbbcd | |||
| 13d1aa5738 | |||
| f508d3213f | |||
| a69a83b545 | |||
| d0fa8bd3ee | |||
| 8e6e8bb25c | |||
| 180ac3aa61 | |||
| c3fa6c1514 | |||
| bf9be59f0b | |||
| a3332aedae | |||
| 069fcdfca6 | |||
| f58d91ba19 | |||
| f4850625bd | |||
| f5f186b691 | |||
| 573b2af334 | |||
| 01428ced58 | |||
| 6198da3daf | |||
| c511462536 | |||
| d6647b9183 | |||
| 2003d8a75c | |||
| 838a1239cf | |||
| 0290bf7285 | |||
| e28160ebb3 | |||
| 0b7e4a00ee | |||
| ab6790ef6c | |||
| 52856d4b6b | |||
| 6bbd2a9ed6 | |||
| ade4266336 | |||
| dc834ea478 | |||
| 4516bd48e1 | |||
| 4c9c7e606e | |||
| b12c7d3deb | |||
| 8a4b0c7bae | |||
| d490397908 | |||
| a41f679516 | |||
| e6d9454251 | |||
| 9ca672d434 | |||
| b8fd652cb8 | |||
| 5042ffd935 | |||
| 8bef4e27f9 | |||
| b664ae373d | |||
| f97b5a3783 | |||
| 0052a6ae30 | |||
| 062a54e3a5 | |||
| 966758e757 | |||
| 07afa8ee26 | |||
| b2451e6aea | |||
| 8926d82a80 | |||
| 4543a3f529 | |||
| c299a41a67 | |||
| 349d16382e | |||
| a5bb5ec4d5 | |||
| 6cb97959a2 | |||
| c78ddc40cb | |||
| 58ccd530fc | |||
| 6dd2213a20 | |||
| cbcd7a5c40 | |||
| be09f9f345 | |||
| 021a2261f6 | |||
| 433a23da7a | |||
| a3d3d4738c | |||
| a02d0a2e9f | |||
| 89d45f2a55 | |||
| 0ab97b597c | |||
| bf07700c74 | |||
| 655ec31ef9 | |||
| 04302075b5 | |||
| a1b890ef42 | |||
| bdcbb2d3a7 | |||
| 1dc8dc7f0d | |||
| 7effdb4030 | |||
| f30a65d507 | |||
| 774988edd4 |
@@ -103,6 +103,23 @@ jobs:
|
|||||||
# AAB for Play; a universal APK (all ABIs) for direct sideload/testing — same upload key.
|
# AAB for Play; a universal APK (all ABIs) for direct sideload/testing — same upload key.
|
||||||
./gradlew :app:bundleRelease :app:assembleRelease --stacktrace
|
./gradlew :app:bundleRelease :app:assembleRelease --stacktrace
|
||||||
|
|
||||||
|
# Attach the built APK to the workflow run so it's a one-click sideload download straight from the
|
||||||
|
# run page (no need to know the generic-registry URL) — on EVERY trigger, incl. a PR or a
|
||||||
|
# workflow_dispatch. The debug APK is built on every run; the signed universal release APK exists
|
||||||
|
# only on a main/tag push (its build step above is push-gated), so grab whichever were produced.
|
||||||
|
- name: Attach APK(s) to the workflow run
|
||||||
|
if: always()
|
||||||
|
# v3, not v4: Gitea's artifact backend identifies as GHES, which upload-artifact@v4 refuses
|
||||||
|
# (same reason as apple.yml / *-screenshots.yml). Download is a zip of the matched APK(s).
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: punktfunk-android-apk
|
||||||
|
path: |
|
||||||
|
clients/android/app/build/outputs/apk/release/*.apk
|
||||||
|
clients/android/app/build/outputs/apk/debug/*.apk
|
||||||
|
if-no-files-found: warn
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
# Publish BEFORE the Play upload so artifacts land even while the Play step is still failing.
|
# Publish BEFORE the Play upload so artifacts land even while the Play step is still failing.
|
||||||
# Generic registry is public for reads — matches windows-msix.yml / deb.yml (REGISTRY_TOKEN, user enricobuehler).
|
# Generic registry is public for reads — matches windows-msix.yml / deb.yml (REGISTRY_TOKEN, user enricobuehler).
|
||||||
# main = canary store + `canary/` sideload alias; a `vX.Y.Z` tag = `latest/` alias + attached
|
# main = canary store + `canary/` sideload alias; a `vX.Y.Z` tag = `latest/` alias + attached
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ jobs:
|
|||||||
- name: Install build + runtime-dev deps
|
- name: Install build + runtime-dev deps
|
||||||
run: |
|
run: |
|
||||||
pacman -Syu --noconfirm --needed \
|
pacman -Syu --noconfirm --needed \
|
||||||
git nodejs rust clang cmake nasm pkgconf python \
|
git nodejs rust clang cmake nasm pkgconf python vulkan-headers \
|
||||||
gtk4 libadwaita sdl3 ffmpeg pipewire wayland libxkbcommon opus libei \
|
gtk4 libadwaita sdl3 ffmpeg pipewire wayland libxkbcommon opus libei \
|
||||||
mesa libglvnd unzip libarchive
|
mesa libglvnd unzip libarchive
|
||||||
# bun builds the punktfunk-web console AND is vendored as its runtime (PF_WITH_WEB=1);
|
# bun builds the punktfunk-web console AND is vendored as its runtime (PF_WITH_WEB=1);
|
||||||
|
|||||||
@@ -60,8 +60,10 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
apt-get update
|
apt-get update
|
||||||
# python3 is used by scripts/ci/gitea-release.sh for the stable-tag release attach.
|
# python3 is used by scripts/ci/gitea-release.sh for the stable-tag release attach.
|
||||||
|
# libvulkan-dev: /usr/include/vulkan/vulkan.h for the client's pf-ffvk bindgen
|
||||||
|
# (FFmpeg's hwcontext_vulkan.h includes it).
|
||||||
apt-get install -y --no-install-recommends dpkg-dev python3 \
|
apt-get install -y --no-install-recommends dpkg-dev python3 \
|
||||||
libgtk-4-dev libadwaita-1-dev libsdl3-dev
|
libgtk-4-dev libadwaita-1-dev libsdl3-dev libvulkan-dev
|
||||||
|
|
||||||
# Share ci.yml's cache keys so the release build reuses its registry + target artifacts.
|
# Share ci.yml's cache keys so the release build reuses its registry + target artifacts.
|
||||||
- name: Cache keys
|
- name: Cache keys
|
||||||
@@ -86,7 +88,10 @@ jobs:
|
|||||||
PUNKTFUNK_BUILD_VERSION: ${{ env.VERSION }} # stamped into the binary (build.rs)
|
PUNKTFUNK_BUILD_VERSION: ${{ env.VERSION }} # stamped into the binary (build.rs)
|
||||||
run: |
|
run: |
|
||||||
git config --global --add safe.directory "$PWD"
|
git config --global --add safe.directory "$PWD"
|
||||||
cargo build --release -p punktfunk-host -p punktfunk-client-linux --locked
|
# punktfunk-client-session is the Vulkan/Skia streamer the shell execs for a connect —
|
||||||
|
# both client binaries must ship (build-client-deb.sh installs both).
|
||||||
|
cargo build --release --locked \
|
||||||
|
-p punktfunk-host -p punktfunk-client-linux -p punktfunk-client-session
|
||||||
|
|
||||||
- name: Build + smoke-boot web console (bun preset)
|
- name: Build + smoke-boot web console (bun preset)
|
||||||
# Gate the .deb on a real bun boot: the punktfunk-web .deb runs the Nitro `bun` preset
|
# Gate the .deb on a real bun boot: the punktfunk-web .deb runs the Nitro `bun` preset
|
||||||
|
|||||||
@@ -25,9 +25,15 @@ on:
|
|||||||
branches: [main]
|
branches: [main]
|
||||||
# The flatpak is the CLIENT — only rebuild when the client/core/manifest change, not on every
|
# The flatpak is the CLIENT — only rebuild when the client/core/manifest change, not on every
|
||||||
# design/host push (this is a heavy flatpak-builder run). Tags (v*, the client release) build too.
|
# design/host push (this is a heavy flatpak-builder run). Tags (v*, the client release) build too.
|
||||||
|
# The bundle ships BOTH client binaries (shell + Vulkan session), so every crate in either
|
||||||
|
# binary's dependency closure must be listed here.
|
||||||
paths:
|
paths:
|
||||||
- 'clients/linux/**'
|
- 'clients/linux/**'
|
||||||
|
- 'clients/session/**'
|
||||||
- 'crates/punktfunk-core/**'
|
- 'crates/punktfunk-core/**'
|
||||||
|
- 'crates/pf-client-core/**'
|
||||||
|
- 'crates/pf-presenter/**'
|
||||||
|
- 'crates/pf-console-ui/**'
|
||||||
- 'packaging/flatpak/**'
|
- 'packaging/flatpak/**'
|
||||||
- 'Cargo.lock'
|
- 'Cargo.lock'
|
||||||
- '.gitea/workflows/flatpak.yml'
|
- '.gitea/workflows/flatpak.yml'
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
apt-get update
|
apt-get update
|
||||||
apt-get install -y --no-install-recommends \
|
apt-get install -y --no-install-recommends \
|
||||||
libgtk-4-dev libadwaita-1-dev libsdl3-dev \
|
libgtk-4-dev libadwaita-1-dev libsdl3-dev libvulkan-dev \
|
||||||
xvfb x11-utils imagemagick scrot \
|
xvfb x11-utils imagemagick scrot \
|
||||||
libgl1-mesa-dri mesa-vulkan-drivers \
|
libgl1-mesa-dri mesa-vulkan-drivers \
|
||||||
adwaita-icon-theme fonts-cantarell fonts-dejavu-core
|
adwaita-icon-theme fonts-cantarell fonts-dejavu-core
|
||||||
|
|||||||
@@ -54,7 +54,9 @@ jobs:
|
|||||||
- name: Prep
|
- name: Prep
|
||||||
run: |
|
run: |
|
||||||
git config --global --add safe.directory "$PWD"
|
git config --global --add safe.directory "$PWD"
|
||||||
dnf -y install gtk4-devel libadwaita-devel SDL3-devel
|
# vulkan-headers: the client's pf-ffvk crate runs bindgen over FFmpeg's
|
||||||
|
# libavutil/hwcontext_vulkan.h (#include <vulkan/vulkan.h>).
|
||||||
|
dnf -y install gtk4-devel libadwaita-devel SDL3-devel vulkan-headers
|
||||||
# sysext build (packaging/bazzite/build-sysext.sh): squashfs + SELinux labeling.
|
# sysext build (packaging/bazzite/build-sysext.sh): squashfs + SELinux labeling.
|
||||||
dnf -y install squashfs-tools cpio libselinux-utils selinux-policy-targeted
|
dnf -y install squashfs-tools cpio libselinux-utils selinux-policy-targeted
|
||||||
# bun builds the punktfunk-web console (--with web). Baked into the image; install it
|
# bun builds the punktfunk-web console (--with web). Baked into the image; install it
|
||||||
|
|||||||
@@ -33,7 +33,12 @@ on:
|
|||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
paths:
|
||||||
- 'clients/windows/**'
|
- 'clients/windows/**'
|
||||||
|
- 'clients/session/**'
|
||||||
- 'crates/punktfunk-core/**'
|
- 'crates/punktfunk-core/**'
|
||||||
|
- 'crates/pf-client-core/**'
|
||||||
|
- 'crates/pf-presenter/**'
|
||||||
|
- 'crates/pf-console-ui/**'
|
||||||
|
- 'crates/pf-ffvk/**'
|
||||||
- 'Cargo.lock'
|
- 'Cargo.lock'
|
||||||
- 'Cargo.toml'
|
- 'Cargo.toml'
|
||||||
- '.gitea/workflows/windows-msix.yml'
|
- '.gitea/workflows/windows-msix.yml'
|
||||||
@@ -57,10 +62,15 @@ jobs:
|
|||||||
target: x86_64-pc-windows-msvc
|
target: x86_64-pc-windows-msvc
|
||||||
ffmpeg: C:\Users\Public\ffmpeg
|
ffmpeg: C:\Users\Public\ffmpeg
|
||||||
td: C:\t
|
td: C:\t
|
||||||
|
session_flags: ''
|
||||||
- arch: arm64
|
- arch: arm64
|
||||||
target: aarch64-pc-windows-msvc
|
target: aarch64-pc-windows-msvc
|
||||||
ffmpeg: C:\Users\Public\ffmpeg-arm64
|
ffmpeg: C:\Users\Public\ffmpeg-arm64
|
||||||
td: C:\t-a64
|
td: C:\t-a64
|
||||||
|
# No skia-binaries prebuilt for aarch64-pc-windows-msvc: the session ships
|
||||||
|
# without the Skia console UI on ARM64 (streaming unaffected) — flip when
|
||||||
|
# rust-skia adds the target.
|
||||||
|
session_flags: '--no-default-features'
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
@@ -78,6 +88,8 @@ jobs:
|
|||||||
"CARGO_WORKSPACE_DIR=$env:GITHUB_WORKSPACE" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
"CARGO_WORKSPACE_DIR=$env:GITHUB_WORKSPACE" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||||
"CARGO_TARGET_DIR=${{ matrix.td }}" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
"CARGO_TARGET_DIR=${{ matrix.td }}" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||||
"FFMPEG_DIR=${{ matrix.ffmpeg }}" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
"FFMPEG_DIR=${{ matrix.ffmpeg }}" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||||
|
# pf-ffvk's bindgen needs Vulkan headers (arch-independent; provisioned alongside FFmpeg).
|
||||||
|
"PF_FFVK_VULKAN_INCLUDE=C:\Users\Public\vulkan-headers\include" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||||
rustup target add ${{ matrix.target }}
|
rustup target add ${{ matrix.target }}
|
||||||
$pf = & "$env:GITHUB_WORKSPACE/scripts/ci/pf-version.ps1" # single source of truth: base is one minor ahead of the latest stable tag
|
$pf = & "$env:GITHUB_WORKSPACE/scripts/ci/pf-version.ps1" # single source of truth: base is one minor ahead of the latest stable tag
|
||||||
$parts = if ($env:GITHUB_REF -like 'refs/tags/v*') {
|
$parts = if ($env:GITHUB_REF -like 'refs/tags/v*') {
|
||||||
@@ -92,9 +104,11 @@ jobs:
|
|||||||
"MSIX_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
"MSIX_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||||
Write-Output "MSIX version $v arch ${{ matrix.arch }} target ${{ matrix.target }}"
|
Write-Output "MSIX version $v arch ${{ matrix.arch }} target ${{ matrix.target }}"
|
||||||
|
|
||||||
|
# Both client binaries — the shell spawns punktfunk-session.exe (a package sibling)
|
||||||
|
# for every stream. --no-default-features on ARM64 is a no-op for the shell.
|
||||||
- name: Build (release)
|
- name: Build (release)
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: cargo build --release -p punktfunk-client-windows --target ${{ matrix.target }}
|
run: cargo build --release -p punktfunk-client-windows -p punktfunk-client-session ${{ matrix.session_flags }} --target ${{ matrix.target }}
|
||||||
|
|
||||||
- name: Pack + sign MSIX
|
- name: Pack + sign MSIX
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
# Windows client CI — runs on a self-hosted windows-amd64 runner (host mode; the generic runner +
|
# Windows client CI — runs on a self-hosted windows-amd64 runner (host mode; the generic runner +
|
||||||
# toolchain come from unom/infra's windows-runner/; punktfunk's own extras - FFmpeg, WDK, Inno
|
# toolchain come from unom/infra's windows-runner/; punktfunk's own extras - FFmpeg,
|
||||||
# Setup, the ARM64 rustup target - self-provision via the "Ensure Windows toolchain" step below, a
|
# Vulkan-Headers, WDK, Inno Setup, the ARM64 rustup target - self-provision via the "Ensure
|
||||||
# fast no-op once already present, so any runner with that label works with no manual dispatch
|
# Windows toolchain" step below, a fast no-op once already present, so any runner with that label
|
||||||
# step first). Build + clippy + fmt + test the WinUI 3 client
|
# works with no manual dispatch step first). Build + clippy + fmt + test BOTH client binaries:
|
||||||
# (windows-reactor + D3D11/SwapChainPanel + WASAPI + SDL3).
|
# the WinUI 3 shell (windows-reactor + WASAPI + SDL3) and the punktfunk-session Vulkan client
|
||||||
|
# (pf-presenter/pf-client-core/pf-console-ui/pf-ffvk — every stream runs in it, spawned by the
|
||||||
|
# shell). ARM64 note: rust-skia publishes no aarch64-pc-windows-msvc prebuilt binaries, so the
|
||||||
|
# session builds --no-default-features there (no Skia console UI; streaming is unaffected) —
|
||||||
|
# flip when skia-binaries adds the target.
|
||||||
#
|
#
|
||||||
# Two architectures from ONE x64 runner: x86_64-pc-windows-msvc natively and
|
# Two architectures from ONE x64 runner: x86_64-pc-windows-msvc natively and
|
||||||
# aarch64-pc-windows-msvc by cross-compiling. The x64 MSVC toolset ships an ARM64 cross compiler
|
# aarch64-pc-windows-msvc by cross-compiling. The x64 MSVC toolset ships an ARM64 cross compiler
|
||||||
@@ -40,14 +44,24 @@ on:
|
|||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
paths:
|
||||||
- 'clients/windows/**'
|
- 'clients/windows/**'
|
||||||
|
- 'clients/session/**'
|
||||||
- 'crates/punktfunk-core/**'
|
- 'crates/punktfunk-core/**'
|
||||||
|
- 'crates/pf-client-core/**'
|
||||||
|
- 'crates/pf-presenter/**'
|
||||||
|
- 'crates/pf-console-ui/**'
|
||||||
|
- 'crates/pf-ffvk/**'
|
||||||
- 'Cargo.lock'
|
- 'Cargo.lock'
|
||||||
- 'Cargo.toml'
|
- 'Cargo.toml'
|
||||||
- '.gitea/workflows/windows.yml'
|
- '.gitea/workflows/windows.yml'
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- 'clients/windows/**'
|
- 'clients/windows/**'
|
||||||
|
- 'clients/session/**'
|
||||||
- 'crates/punktfunk-core/**'
|
- 'crates/punktfunk-core/**'
|
||||||
|
- 'crates/pf-client-core/**'
|
||||||
|
- 'crates/pf-presenter/**'
|
||||||
|
- 'crates/pf-console-ui/**'
|
||||||
|
- 'crates/pf-ffvk/**'
|
||||||
- 'Cargo.lock'
|
- 'Cargo.lock'
|
||||||
- 'Cargo.toml'
|
- 'Cargo.toml'
|
||||||
- '.gitea/workflows/windows.yml'
|
- '.gitea/workflows/windows.yml'
|
||||||
@@ -78,6 +92,8 @@ jobs:
|
|||||||
# Per-arch FFmpeg import libs (provision-windows-punktfunk-extras.ps1 fetches both).
|
# Per-arch FFmpeg import libs (provision-windows-punktfunk-extras.ps1 fetches both).
|
||||||
$ff = if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { 'C:\Users\Public\ffmpeg-arm64' } else { 'C:\Users\Public\ffmpeg' }
|
$ff = if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { 'C:\Users\Public\ffmpeg-arm64' } else { 'C:\Users\Public\ffmpeg' }
|
||||||
"FFMPEG_DIR=$ff" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
"FFMPEG_DIR=$ff" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||||
|
# pf-ffvk's bindgen needs Vulkan headers (arch-independent; provisioned alongside FFmpeg).
|
||||||
|
"PF_FFVK_VULKAN_INCLUDE=C:\Users\Public\vulkan-headers\include" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||||
# $ff\bin on PATH too (not just FFMPEG_DIR, which only satisfies the linker): the test
|
# $ff\bin on PATH too (not just FFMPEG_DIR, which only satisfies the linker): the test
|
||||||
# binary needs the actual DLLs to load at runtime. Set here rather than relying on the
|
# binary needs the actual DLLs to load at runtime. Set here rather than relying on the
|
||||||
# daemon's own env (project-env.ps1) - on a freshly cloned/registered runner the daemon
|
# daemon's own env (project-env.ps1) - on a freshly cloned/registered runner the daemon
|
||||||
@@ -89,20 +105,29 @@ jobs:
|
|||||||
cargo --version
|
cargo --version
|
||||||
Write-Output "target ${{ matrix.target }} target-dir $td ffmpeg $ff"
|
Write-Output "target ${{ matrix.target }} target-dir $td ffmpeg $ff"
|
||||||
|
|
||||||
|
# Both client binaries. ARM64: no skia-binaries prebuilt for the target, so the session
|
||||||
|
# drops its `ui` feature there (pf-console-ui excluded; --no-default-features is a no-op
|
||||||
|
# for the shell, which has no features).
|
||||||
- name: Build
|
- name: Build
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: cargo build -p punktfunk-client-windows --target ${{ matrix.target }}
|
run: |
|
||||||
|
$sf = @(); if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { $sf = @('--no-default-features') }
|
||||||
|
cargo build -p punktfunk-client-windows -p punktfunk-client-session @sf --target ${{ matrix.target }}
|
||||||
|
|
||||||
- name: Clippy (-D warnings)
|
- name: Clippy (-D warnings)
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: cargo clippy -p punktfunk-client-windows --all-targets --target ${{ matrix.target }} -- -D warnings
|
run: |
|
||||||
|
$pkgs = @('-p','punktfunk-client-windows','-p','punktfunk-client-session','-p','pf-client-core','-p','pf-presenter','-p','pf-ffvk')
|
||||||
|
$sf = @()
|
||||||
|
if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { $sf = @('--no-default-features') } else { $pkgs += @('-p','pf-console-ui') }
|
||||||
|
cargo clippy @pkgs --all-targets @sf --target ${{ matrix.target }} -- -D warnings
|
||||||
|
|
||||||
- name: Rustfmt check
|
- name: Rustfmt check
|
||||||
if: matrix.target == 'x86_64-pc-windows-msvc'
|
if: matrix.target == 'x86_64-pc-windows-msvc'
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: cargo fmt -p punktfunk-client-windows -- --check
|
run: cargo fmt -p punktfunk-client-windows -p punktfunk-client-session -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-ffvk -- --check
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
if: matrix.target == 'x86_64-pc-windows-msvc'
|
if: matrix.target == 'x86_64-pc-windows-msvc'
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: cargo test -p punktfunk-client-windows --target ${{ matrix.target }}
|
run: cargo test -p punktfunk-client-windows -p punktfunk-client-session -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-ffvk --target ${{ matrix.target }}
|
||||||
|
|||||||
@@ -34,3 +34,7 @@ __pycache__/
|
|||||||
|
|
||||||
# Claude Code project instructions — local to each dev box, not part of the repo.
|
# Claude Code project instructions — local to each dev box, not part of the repo.
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
|
|
||||||
|
# Local flatpak-builder output (build-flatpak.sh) — ostree repo + build dir at the repo root.
|
||||||
|
.flatpak-repo/
|
||||||
|
.flatpak-build/
|
||||||
|
|||||||
Generated
+267
-25
@@ -472,6 +472,8 @@ dependencies = [
|
|||||||
"cexpr",
|
"cexpr",
|
||||||
"clang-sys",
|
"clang-sys",
|
||||||
"itertools 0.13.0",
|
"itertools 0.13.0",
|
||||||
|
"log",
|
||||||
|
"prettyplease",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"regex",
|
"regex",
|
||||||
@@ -889,9 +891,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crossbeam-epoch"
|
name = "crossbeam-epoch"
|
||||||
version = "0.9.18"
|
version = "0.9.20"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
|
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crossbeam-utils",
|
"crossbeam-utils",
|
||||||
]
|
]
|
||||||
@@ -1144,6 +1146,9 @@ name = "fastrand"
|
|||||||
version = "2.4.1"
|
version = "2.4.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.3.4",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fec-rs"
|
name = "fec-rs"
|
||||||
@@ -1193,6 +1198,16 @@ dependencies = [
|
|||||||
"rustc_version",
|
"rustc_version",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "filetime"
|
||||||
|
version = "0.2.29"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "find-msvc-tools"
|
name = "find-msvc-tools"
|
||||||
version = "0.1.9"
|
version = "0.1.9"
|
||||||
@@ -1221,6 +1236,7 @@ version = "0.12.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be"
|
checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"fastrand",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-sink",
|
"futures-sink",
|
||||||
"spin",
|
"spin",
|
||||||
@@ -1247,6 +1263,15 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fragile"
|
||||||
|
version = "2.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8878864ba14bb86e818a412bfd6f18f9eabd4ec0f008a28e8f7eb61db532fcf9"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fs-err"
|
name = "fs-err"
|
||||||
version = "3.3.0"
|
version = "3.3.0"
|
||||||
@@ -2129,7 +2154,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "latency-probe"
|
name = "latency-probe"
|
||||||
version = "0.8.2"
|
version = "0.8.4"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lazy_static"
|
name = "lazy_static"
|
||||||
@@ -2261,7 +2286,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "loss-harness"
|
name = "loss-harness"
|
||||||
version = "0.8.2"
|
version = "0.8.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"punktfunk-core",
|
"punktfunk-core",
|
||||||
]
|
]
|
||||||
@@ -2382,8 +2407,6 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "ndk"
|
name = "ndk"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags",
|
||||||
"jni-sys 0.3.1",
|
"jni-sys 0.3.1",
|
||||||
@@ -2740,6 +2763,42 @@ version = "2.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pf-client-core"
|
||||||
|
version = "0.8.4"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"async-channel",
|
||||||
|
"ffmpeg-next",
|
||||||
|
"mdns-sd",
|
||||||
|
"opus",
|
||||||
|
"pf-ffvk",
|
||||||
|
"pipewire",
|
||||||
|
"punktfunk-core",
|
||||||
|
"rustls",
|
||||||
|
"sdl3",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tracing",
|
||||||
|
"ureq",
|
||||||
|
"wasapi",
|
||||||
|
"windows 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pf-console-ui"
|
||||||
|
version = "0.8.4"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"ash",
|
||||||
|
"pf-client-core",
|
||||||
|
"pf-presenter",
|
||||||
|
"punktfunk-core",
|
||||||
|
"sdl3",
|
||||||
|
"skia-safe",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-driver-proto"
|
name = "pf-driver-proto"
|
||||||
version = "0.0.1"
|
version = "0.0.1"
|
||||||
@@ -2747,6 +2806,30 @@ dependencies = [
|
|||||||
"bytemuck",
|
"bytemuck",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pf-ffvk"
|
||||||
|
version = "0.8.4"
|
||||||
|
dependencies = [
|
||||||
|
"ash",
|
||||||
|
"bindgen",
|
||||||
|
"pkg-config",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pf-presenter"
|
||||||
|
version = "0.8.4"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"ash",
|
||||||
|
"async-channel",
|
||||||
|
"pf-client-core",
|
||||||
|
"pf-ffvk",
|
||||||
|
"punktfunk-core",
|
||||||
|
"sdl3",
|
||||||
|
"tracing",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pin-project-lite"
|
name = "pin-project-lite"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -2869,13 +2952,23 @@ dependencies = [
|
|||||||
"zerocopy",
|
"zerocopy",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "prettyplease"
|
||||||
|
version = "0.2.37"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro-crate"
|
name = "proc-macro-crate"
|
||||||
version = "3.5.0"
|
version = "3.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
|
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"toml_edit",
|
"toml_edit 0.25.12+spec-1.1.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2908,7 +3001,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-android"
|
name = "punktfunk-client-android"
|
||||||
version = "0.8.2"
|
version = "0.8.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android_logger",
|
"android_logger",
|
||||||
"jni",
|
"jni",
|
||||||
@@ -2916,6 +3009,7 @@ dependencies = [
|
|||||||
"log",
|
"log",
|
||||||
"mdns-sd",
|
"mdns-sd",
|
||||||
"ndk",
|
"ndk",
|
||||||
|
"ndk-sys",
|
||||||
"opus",
|
"opus",
|
||||||
"punktfunk-core",
|
"punktfunk-core",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -2923,30 +3017,38 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-linux"
|
name = "punktfunk-client-linux"
|
||||||
version = "0.8.2"
|
version = "0.8.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
"ffmpeg-next",
|
|
||||||
"gtk4",
|
"gtk4",
|
||||||
"khronos-egl",
|
|
||||||
"libadwaita",
|
"libadwaita",
|
||||||
"mdns-sd",
|
"pf-client-core",
|
||||||
"opus",
|
|
||||||
"pipewire",
|
|
||||||
"punktfunk-core",
|
"punktfunk-core",
|
||||||
"rustls",
|
"relm4",
|
||||||
"sdl3",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"ureq",
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "punktfunk-client-session"
|
||||||
|
version = "0.8.4"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"pf-client-core",
|
||||||
|
"pf-console-ui",
|
||||||
|
"pf-presenter",
|
||||||
|
"punktfunk-core",
|
||||||
|
"serde_json",
|
||||||
|
"tracing",
|
||||||
|
"tracing-subscriber",
|
||||||
|
"winresource",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-windows"
|
name = "punktfunk-client-windows"
|
||||||
version = "0.8.2"
|
version = "0.8.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
@@ -2954,6 +3056,7 @@ dependencies = [
|
|||||||
"ffmpeg-next",
|
"ffmpeg-next",
|
||||||
"mdns-sd",
|
"mdns-sd",
|
||||||
"opus",
|
"opus",
|
||||||
|
"pf-client-core",
|
||||||
"punktfunk-core",
|
"punktfunk-core",
|
||||||
"sdl3",
|
"sdl3",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -2969,7 +3072,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-core"
|
name = "punktfunk-core"
|
||||||
version = "0.8.2"
|
version = "0.8.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -3000,7 +3103,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-host"
|
name = "punktfunk-host"
|
||||||
version = "0.8.2"
|
version = "0.8.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
@@ -3072,7 +3175,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-probe"
|
name = "punktfunk-probe"
|
||||||
version = "0.8.2"
|
version = "0.8.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"mdns-sd",
|
"mdns-sd",
|
||||||
@@ -3086,7 +3189,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-tray"
|
name = "punktfunk-tray"
|
||||||
version = "0.8.2"
|
version = "0.8.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ksni",
|
"ksni",
|
||||||
@@ -3371,6 +3474,41 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "relm4"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6420f090f0545e9ec9656469d139a4e1b66ff9c30b808fe2247892724f71a198"
|
||||||
|
dependencies = [
|
||||||
|
"flume",
|
||||||
|
"fragile",
|
||||||
|
"futures",
|
||||||
|
"gtk4",
|
||||||
|
"libadwaita",
|
||||||
|
"once_cell",
|
||||||
|
"relm4-css",
|
||||||
|
"relm4-macros",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "relm4-css"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f3b81d263f784b103c815afa29124486b59741eca069ce7a5999efb14f13c368"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "relm4-macros"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "36c9dbf50a60c82375e66b61d522c936b187a11b25c0a42e91c516326ad24a4f"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ring"
|
name = "ring"
|
||||||
version = "0.17.14"
|
version = "0.17.14"
|
||||||
@@ -3691,6 +3829,7 @@ version = "0.6.6+SDL-3.4.10"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "04e7f134def04ed72e6f55187c6c29c72f7dab5d359c4be0dd49c9b97fef59c7"
|
checksum = "04e7f134def04ed72e6f55187c6c29c72f7dab5d359c4be0dd49c9b97fef59c7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"ash",
|
||||||
"cc",
|
"cc",
|
||||||
"cmake",
|
"cmake",
|
||||||
"pkg-config",
|
"pkg-config",
|
||||||
@@ -3813,6 +3952,15 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_spanned"
|
||||||
|
version = "0.6.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_spanned"
|
name = "serde_spanned"
|
||||||
version = "1.1.1"
|
version = "1.1.1"
|
||||||
@@ -3898,6 +4046,35 @@ version = "1.0.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
|
checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "skia-bindings"
|
||||||
|
version = "0.87.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "704242769235d2ffe66a2a0a3002661262fc4af08d32807c362d7b0160ee703c"
|
||||||
|
dependencies = [
|
||||||
|
"bindgen",
|
||||||
|
"cc",
|
||||||
|
"flate2",
|
||||||
|
"heck",
|
||||||
|
"lazy_static",
|
||||||
|
"pkg-config",
|
||||||
|
"regex",
|
||||||
|
"serde_json",
|
||||||
|
"tar",
|
||||||
|
"toml 0.8.23",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "skia-safe"
|
||||||
|
version = "0.87.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0f7d94f3e7537c71ad4cf132eb26e3be8c8a886ed3649c4525c089041fc312b2"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"lazy_static",
|
||||||
|
"skia-bindings",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "slab"
|
name = "slab"
|
||||||
version = "0.4.12"
|
version = "0.4.12"
|
||||||
@@ -4033,6 +4210,17 @@ dependencies = [
|
|||||||
"version-compare",
|
"version-compare",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tar"
|
||||||
|
version = "0.4.46"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
|
||||||
|
dependencies = [
|
||||||
|
"filetime",
|
||||||
|
"libc",
|
||||||
|
"xattr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "target-lexicon"
|
name = "target-lexicon"
|
||||||
version = "0.13.5"
|
version = "0.13.5"
|
||||||
@@ -4218,6 +4406,18 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml"
|
||||||
|
version = "0.8.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_spanned 0.6.9",
|
||||||
|
"toml_datetime 0.6.11",
|
||||||
|
"toml_edit 0.22.27",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml"
|
name = "toml"
|
||||||
version = "0.9.12+spec-1.1.0"
|
version = "0.9.12+spec-1.1.0"
|
||||||
@@ -4226,7 +4426,7 @@ checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
"serde_spanned",
|
"serde_spanned 1.1.1",
|
||||||
"toml_datetime 0.7.5+spec-1.1.0",
|
"toml_datetime 0.7.5+spec-1.1.0",
|
||||||
"toml_parser",
|
"toml_parser",
|
||||||
"toml_writer",
|
"toml_writer",
|
||||||
@@ -4241,13 +4441,22 @@ checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
"serde_spanned",
|
"serde_spanned 1.1.1",
|
||||||
"toml_datetime 1.1.1+spec-1.1.0",
|
"toml_datetime 1.1.1+spec-1.1.0",
|
||||||
"toml_parser",
|
"toml_parser",
|
||||||
"toml_writer",
|
"toml_writer",
|
||||||
"winnow 1.0.3",
|
"winnow 1.0.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_datetime"
|
||||||
|
version = "0.6.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml_datetime"
|
name = "toml_datetime"
|
||||||
version = "0.7.5+spec-1.1.0"
|
version = "0.7.5+spec-1.1.0"
|
||||||
@@ -4266,6 +4475,20 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_edit"
|
||||||
|
version = "0.22.27"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
|
||||||
|
dependencies = [
|
||||||
|
"indexmap",
|
||||||
|
"serde",
|
||||||
|
"serde_spanned 0.6.9",
|
||||||
|
"toml_datetime 0.6.11",
|
||||||
|
"toml_write",
|
||||||
|
"winnow 0.7.15",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml_edit"
|
name = "toml_edit"
|
||||||
version = "0.25.12+spec-1.1.0"
|
version = "0.25.12+spec-1.1.0"
|
||||||
@@ -4287,6 +4510,12 @@ dependencies = [
|
|||||||
"winnow 1.0.3",
|
"winnow 1.0.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_write"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml_writer"
|
name = "toml_writer"
|
||||||
version = "1.1.1+spec-1.1.0"
|
version = "1.1.1+spec-1.1.0"
|
||||||
@@ -5330,6 +5559,9 @@ name = "winnow"
|
|||||||
version = "0.7.15"
|
version = "0.7.15"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
|
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winnow"
|
name = "winnow"
|
||||||
@@ -5389,6 +5621,16 @@ dependencies = [
|
|||||||
"time",
|
"time",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "xattr"
|
||||||
|
version = "1.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"rustix",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "xkbcommon"
|
name = "xkbcommon"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
|
|||||||
+19
-2
@@ -5,19 +5,36 @@ members = [
|
|||||||
"crates/punktfunk-host",
|
"crates/punktfunk-host",
|
||||||
"crates/punktfunk-host/vendor/usbip-sim",
|
"crates/punktfunk-host/vendor/usbip-sim",
|
||||||
"crates/punktfunk-tray",
|
"crates/punktfunk-tray",
|
||||||
|
"crates/pf-client-core",
|
||||||
|
"crates/pf-presenter",
|
||||||
|
"crates/pf-console-ui",
|
||||||
|
"crates/pf-ffvk",
|
||||||
"crates/pf-driver-proto",
|
"crates/pf-driver-proto",
|
||||||
"clients/probe",
|
"clients/probe",
|
||||||
"clients/linux",
|
"clients/linux",
|
||||||
|
"clients/session",
|
||||||
"clients/windows",
|
"clients/windows",
|
||||||
"clients/android/native",
|
"clients/android/native",
|
||||||
"tools/latency-probe",
|
"tools/latency-probe",
|
||||||
"tools/loss-harness",
|
"tools/loss-harness",
|
||||||
]
|
]
|
||||||
# Standalone PoC (built on its own; pulls usbip/tokio/libusb we don't want in the workspace).
|
# Standalone PoC (built on its own; pulls usbip/tokio/libusb we don't want in the workspace).
|
||||||
exclude = ["packaging/linux/steam-deck-gadget/usbip-poc"]
|
# The vendored `ndk` is a [patch.crates-io] source, not a member: it only compiles for the
|
||||||
|
# `*-linux-android` targets, so workspace membership would break host `cargo build --workspace`.
|
||||||
|
exclude = [
|
||||||
|
"packaging/linux/steam-deck-gadget/usbip-poc",
|
||||||
|
"clients/android/native/vendor/ndk",
|
||||||
|
]
|
||||||
|
|
||||||
|
# ndk 0.9.0 verbatim from crates.io plus ONE visibility change: `MediaCodec::as_ptr` made public
|
||||||
|
# (upstream keeps it private and exposes no frame-rendered binding), so the Android client can
|
||||||
|
# call `AMediaCodec_setOnFrameRenderedCallback` via ndk-sys for the HUD's `display` stage
|
||||||
|
# (design/stats-unification.md). Drop the patch when upstream exposes the pointer or the callback.
|
||||||
|
[patch.crates-io]
|
||||||
|
ndk = { path = "clients/android/native/vendor/ndk" }
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.8.2"
|
version = "0.8.4"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.82"
|
rust-version = "1.82"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@
|
|||||||
"name": "MIT OR Apache-2.0",
|
"name": "MIT OR Apache-2.0",
|
||||||
"identifier": "MIT OR Apache-2.0"
|
"identifier": "MIT OR Apache-2.0"
|
||||||
},
|
},
|
||||||
"version": "0.8.2"
|
"version": "0.8.4"
|
||||||
},
|
},
|
||||||
"paths": {
|
"paths": {
|
||||||
"/api/v1/clients": {
|
"/api/v1/clients": {
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ RUN dnf -y install \
|
|||||||
mesa-libGL-devel mesa-libgbm-devel \
|
mesa-libGL-devel mesa-libgbm-devel \
|
||||||
# punktfunk-client link deps (GTK4 shell + SDL3 gamepads)
|
# punktfunk-client link deps (GTK4 shell + SDL3 gamepads)
|
||||||
gtk4-devel libadwaita-devel SDL3-devel \
|
gtk4-devel libadwaita-devel SDL3-devel \
|
||||||
|
# pf-ffvk bindgen over libavutil/hwcontext_vulkan.h needs <vulkan/vulkan.h>
|
||||||
|
vulkan-headers \
|
||||||
&& dnf clean all
|
&& dnf clean all
|
||||||
|
|
||||||
# bun — both the BUILD tool and the RUNTIME for the punktfunk-web console (`bun run build` -> the
|
# bun — both the BUILD tool and the RUNTIME for the punktfunk-web console (`bun run build` -> the
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
libgl-dev libegl-dev libgbm-dev \
|
libgl-dev libegl-dev libgbm-dev \
|
||||||
# punktfunk-client-linux (GTK4/libadwaita shell, SDL3 gamepads)
|
# punktfunk-client-linux (GTK4/libadwaita shell, SDL3 gamepads)
|
||||||
libgtk-4-dev libadwaita-1-dev libsdl3-dev \
|
libgtk-4-dev libadwaita-1-dev libsdl3-dev \
|
||||||
|
# pf-ffvk (bindgen over libavutil/hwcontext_vulkan.h needs <vulkan/vulkan.h>)
|
||||||
|
libvulkan-dev \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# bun — builds the punktfunk-web console in deb.yml (which runs the web build in THIS image).
|
# bun — builds the punktfunk-web console in deb.yml (which runs the web build in THIS image).
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ plugins {
|
|||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "io.unom.punktfunk"
|
namespace = "io.unom.punktfunk"
|
||||||
compileSdk = 37 // Android 17 — required by androidx.core 1.19.0; targetSdk stays 36 for now.
|
compileSdk = 37 // Android 17 — required by androidx.core 1.19.0.
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
// Load from .env if it exists (local dev), otherwise from System.getenv (CI)
|
// Load from .env if it exists (local dev), otherwise from System.getenv (CI)
|
||||||
@@ -26,7 +26,11 @@ android {
|
|||||||
// the handful of API 31+ APIs we use are runtime-gated (Material You → brand palette, rumble
|
// the handful of API 31+ APIs we use are runtime-gated (Material You → brand palette, rumble
|
||||||
// → legacy Vibrator, NEARBY_WIFI/lights/ADPF already gated), so nothing is lost above 28.
|
// → legacy Vibrator, NEARBY_WIFI/lights/ADPF already gated), so nothing is lost above 28.
|
||||||
minSdk = 28
|
minSdk = 28
|
||||||
targetSdk = 36
|
// Android 17: targeting 37 makes Local Network Protection MANDATORY — all LAN traffic (the
|
||||||
|
// QUIC dial, mDNS, WoL, the library fetch) is blocked until the user grants the
|
||||||
|
// ACCESS_LOCAL_NETWORK runtime permission. ConnectScreen owns that request/rationale flow;
|
||||||
|
// don't bump past 37 without re-checking the next release's behavior changes.
|
||||||
|
targetSdk = 37
|
||||||
val vCode = (props.getProperty("VERSION_CODE") ?: System.getenv("VERSION_CODE"))
|
val vCode = (props.getProperty("VERSION_CODE") ?: System.getenv("VERSION_CODE"))
|
||||||
versionCode = vCode?.toInt() ?: 1
|
versionCode = vCode?.toInt() ?: 1
|
||||||
// versionName is the single project version, threaded from CI (a vX.Y.Z release or a
|
// versionName is the single project version, threaded from CI (a vX.Y.Z release or a
|
||||||
|
|||||||
@@ -19,8 +19,9 @@
|
|||||||
Its absence went unnoticed for weeks because the acquire was wrapped in a silent
|
Its absence went unnoticed for weeks because the acquire was wrapped in a silent
|
||||||
runCatching (now logged). -->
|
runCatching (now logged). -->
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
<!-- Enforced from Android 17 (SDK 37) for ALL local-network traffic incl. the QUIC socket.
|
<!-- Enforced from Android 17 (SDK 37, our targetSdk) for ALL local-network traffic incl. the
|
||||||
Harmless to declare on earlier releases. -->
|
QUIC socket — a RUNTIME permission (NEARBY_DEVICES group): ConnectScreen requests it on
|
||||||
|
entry and gates every dial/wake on the grant. Harmless to declare on earlier releases. -->
|
||||||
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
|
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
|
||||||
<!-- Mic uplink to the host's virtual microphone (requested at runtime). -->
|
<!-- Mic uplink to the host's virtual microphone (requested at runtime). -->
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
|||||||
@@ -155,6 +155,35 @@ internal fun TrustNewHostDialog(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Android 17+ Local Network Protection rationale: ACCESS_LOCAL_NETWORK was denied, so discovery and
|
||||||
|
* every connect are dead — offer the system prompt again and a settings deep link (a permanently-
|
||||||
|
* denied request returns instantly without ever showing the prompt, so "Allow" alone isn't enough).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun LocalNetworkDialog(onAllow: () -> Unit, onSettings: () -> Unit, onDismiss: () -> Unit) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("Allow local network access") },
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
"Android blocks punktfunk from talking to devices on your network, so it can't " +
|
||||||
|
"find or reach any host until you allow it. If no prompt appears when you tap " +
|
||||||
|
"Allow, enable “Nearby devices” for punktfunk in system settings.",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onAllow) { Text("Allow") }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
Row {
|
||||||
|
TextButton(onClick = onSettings) { Text("Open settings") }
|
||||||
|
TextButton(onClick = onDismiss) { Text("Not now") }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/** The pinned fingerprint no longer matches — force re-pairing (never a silent re-trust). */
|
/** The pinned fingerprint no longer matches — force re-pairing (never a silent re-trust). */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun FingerprintChangedDialog(
|
internal fun FingerprintChangedDialog(
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package io.unom.punktfunk
|
|||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
@@ -30,6 +32,7 @@ import androidx.compose.material3.Icon
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@@ -37,6 +40,7 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -44,6 +48,9 @@ import androidx.compose.ui.platform.LocalContext
|
|||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
|
import androidx.lifecycle.LifecycleOwner
|
||||||
import io.unom.punktfunk.components.EmptyHostsState
|
import io.unom.punktfunk.components.EmptyHostsState
|
||||||
import io.unom.punktfunk.components.HostCard
|
import io.unom.punktfunk.components.HostCard
|
||||||
import io.unom.punktfunk.components.SectionLabel
|
import io.unom.punktfunk.components.SectionLabel
|
||||||
@@ -60,6 +67,7 @@ import io.unom.punktfunk.models.HostStatus
|
|||||||
import io.unom.punktfunk.models.PendingTrust
|
import io.unom.punktfunk.models.PendingTrust
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
@@ -111,14 +119,57 @@ fun ConnectScreen(
|
|||||||
// denial used to leave discovery dead forever.
|
// denial used to leave discovery dead forever.
|
||||||
val discovery = remember { HostDiscovery(context) }
|
val discovery = remember { HostDiscovery(context) }
|
||||||
var discovered by remember { mutableStateOf<List<DiscoveredHost>>(emptyList()) }
|
var discovered by remember { mutableStateOf<List<DiscoveredHost>>(emptyList()) }
|
||||||
|
// Android 17 Local Network Protection: with targetSdk 37, EVERYTHING this screen does — the mDNS
|
||||||
|
// browse, the QUIC dial (UDP 9777), Wake-on-LAN, the library fetch — is blocked until the user
|
||||||
|
// grants ACCESS_LOCAL_NETWORK (a runtime permission in the NEARBY_DEVICES group). Blocked UDP
|
||||||
|
// fails with EPERM, which quinn experiences as a silent handshake timeout — so without this gate
|
||||||
|
// a denial looks exactly like a dead host. Unlike NEARBY_WIFI_DEVICES below, this one is
|
||||||
|
// load-bearing: request it on entry, and surface a denial as an actionable dialog/banner (with a
|
||||||
|
// system-settings deep link) instead of dead-ending on timeouts.
|
||||||
|
var lnpGranted by remember { mutableStateOf(hasLocalNetworkPermission(context)) }
|
||||||
|
var lnpPrompt by remember { mutableStateOf(false) }
|
||||||
|
val localNetLauncher = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.RequestPermission(),
|
||||||
|
) { granted ->
|
||||||
|
lnpGranted = granted
|
||||||
|
if (granted) {
|
||||||
|
lnpPrompt = false
|
||||||
|
// The browse started while blocked (its sockets failed or received nothing) — restart it
|
||||||
|
// now that the grant makes them work.
|
||||||
|
discovery.stop()
|
||||||
|
discovery.start()
|
||||||
|
} else {
|
||||||
|
lnpPrompt = true // rationale + "Open settings" (a permanently-denied request returns instantly)
|
||||||
|
}
|
||||||
|
}
|
||||||
val nearbyLauncher = rememberLauncherForActivityResult(
|
val nearbyLauncher = rememberLauncherForActivityResult(
|
||||||
ActivityResultContracts.RequestPermission(),
|
ActivityResultContracts.RequestPermission(),
|
||||||
) { _ -> /* best-effort hint; discovery runs regardless of the result */ }
|
) { _ -> /* best-effort hint; discovery runs regardless of the result */ }
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !hasNearbyPermission(context)) {
|
if (!lnpGranted) {
|
||||||
|
localNetLauncher.launch(Manifest.permission.ACCESS_LOCAL_NETWORK)
|
||||||
|
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !hasNearbyPermission(context)) {
|
||||||
|
// The old opportunistic multicast hedge (some OEMs filter multicast without it). On API
|
||||||
|
// 37+ it shares the NEARBY_DEVICES group with ACCESS_LOCAL_NETWORK, so once that is
|
||||||
|
// granted this auto-grants without a second prompt.
|
||||||
nearbyLauncher.launch(Manifest.permission.NEARBY_WIFI_DEVICES)
|
nearbyLauncher.launch(Manifest.permission.NEARBY_WIFI_DEVICES)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Re-check on resume: our dialog deep-links to system settings, and granting there doesn't kill
|
||||||
|
// or otherwise notify the app — this observer is what turns the grant into a live discovery.
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
val lifecycle = (context as? LifecycleOwner)?.lifecycle
|
||||||
|
val obs = LifecycleEventObserver { _, event ->
|
||||||
|
if (event == Lifecycle.Event.ON_RESUME && !lnpGranted && hasLocalNetworkPermission(context)) {
|
||||||
|
lnpGranted = true
|
||||||
|
lnpPrompt = false
|
||||||
|
discovery.stop()
|
||||||
|
discovery.start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lifecycle?.addObserver(obs)
|
||||||
|
onDispose { lifecycle?.removeObserver(obs) }
|
||||||
|
}
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
discovery.onChange = { discovered = it }
|
discovery.onChange = { discovered = it }
|
||||||
discovery.start()
|
discovery.start()
|
||||||
@@ -154,6 +205,29 @@ fun ConnectScreen(
|
|||||||
}
|
}
|
||||||
if (learned) savedHosts = knownHostStore.all()
|
if (learned) savedHosts = knownHostStore.all()
|
||||||
}
|
}
|
||||||
|
// Saved hosts proven reachable by a QUIC probe this cycle, keyed "address:port" — the
|
||||||
|
// routed-network (Tailscale/VPN) counterpart to mDNS presence, since such hosts never
|
||||||
|
// advertise. OR'd into `isOnline` below so their pips light up. Probe only saved hosts NOT
|
||||||
|
// already seen on mDNS, off the main thread, every ~12 s; gated on LNP (blocked UDP would
|
||||||
|
// just time out). `rememberUpdatedState` keeps the 1 Hz mDNS updates from restarting the loop.
|
||||||
|
var reachable by remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||||
|
val discoveredNow by rememberUpdatedState(discovered)
|
||||||
|
LaunchedEffect(savedHosts, lnpGranted) {
|
||||||
|
if (!lnpGranted) {
|
||||||
|
reachable = emptySet()
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
while (true) {
|
||||||
|
val targets = savedHosts.filter { kh -> discoveredNow.none { kh.matches(it) } }
|
||||||
|
reachable = withContext(Dispatchers.IO) {
|
||||||
|
targets
|
||||||
|
.filter { NativeBridge.nativeProbe(it.address, it.port, 3_000) }
|
||||||
|
.map { "${it.address}:${it.port}" }
|
||||||
|
.toSet()
|
||||||
|
}
|
||||||
|
delay(12_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
// Mint-once on genuine first run; an Unrecoverable store (decrypt failure) surfaces here and
|
// Mint-once on genuine first run; an Unrecoverable store (decrypt failure) surfaces here and
|
||||||
// refuses to connect — never silently shadow-minting a new identity (which would force re-pair).
|
// refuses to connect — never silently shadow-minting a new identity (which would force re-pair).
|
||||||
var identity by remember { mutableStateOf<ClientIdentity?>(null) }
|
var identity by remember { mutableStateOf<ClientIdentity?>(null) }
|
||||||
@@ -185,8 +259,10 @@ fun ConnectScreen(
|
|||||||
|
|
||||||
// The actual dial (identity already ready). On a TOFU connect (pinHex null), pin the fingerprint
|
// The actual dial (identity already ready). On a TOFU connect (pinHex null), pin the fingerprint
|
||||||
// the host presented (as an unpaired known host) so the next connect goes straight through and it
|
// the host presented (as an unpaired known host) so the next connect goes straight through and it
|
||||||
// appears in the saved-hosts list.
|
// appears in the saved-hosts list. [onFailure], when set, takes over a failed dial (the wake-wait
|
||||||
fun doConnectDirect(targetHost: String, targetPort: Int, name: String, pinHex: String?) {
|
// fallback) instead of the error status line — discovery is already restarted when it runs, so
|
||||||
|
// the wait can observe the host reappear.
|
||||||
|
fun doConnectDirect(targetHost: String, targetPort: Int, name: String, pinHex: String?, onFailure: (() -> Unit)? = null) {
|
||||||
val id = identity ?: run {
|
val id = identity ?: run {
|
||||||
status = "Identity not ready yet — try again in a moment"
|
status = "Identity not ready yet — try again in a moment"
|
||||||
return
|
return
|
||||||
@@ -206,18 +282,25 @@ fun ConnectScreen(
|
|||||||
}
|
}
|
||||||
onConnected(handle)
|
onConnected(handle)
|
||||||
} else {
|
} else {
|
||||||
status = "Connection failed — check host/port, PIN, and logcat"
|
|
||||||
discovery.start()
|
discovery.start()
|
||||||
|
if (onFailure != null) {
|
||||||
|
status = ""
|
||||||
|
onFailure()
|
||||||
|
} else {
|
||||||
|
status = "Connection failed — check host/port, PIN, and logcat"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wake-aware connect. If auto-wake is on (Settings.autoWakeEnabled) and the target is a saved
|
// Wake-aware connect. If auto-wake is on (Settings.autoWakeEnabled) and the target is a saved
|
||||||
// host with a learned MAC that ISN'T currently advertising (asleep/off, or just missing from
|
// host with a learned MAC that ISN'T currently advertising, fire a wake packet and DIAL
|
||||||
// mDNS), wake it and WAIT for it to reappear on mDNS (WakeController shows the "Waking…" overlay)
|
// IMMEDIATELY — mDNS absence does NOT mean unreachable (a host reached over a routed network —
|
||||||
// before dialing — discovery stays running meanwhile so we can see it come back. A fire-and-forget
|
// Tailscale/VPN/another subnet — is mDNS-blind forever, and gating the dial on presence bricked
|
||||||
// packet + the connect timeout wasn't enough for a cold boot. Otherwise (auto-wake off, no MAC, or
|
// exactly those reconnects). A genuinely-asleep box is already booting while the dial times out;
|
||||||
// already seen live) dial straight through.
|
// only a FAILED dial falls into the wake-and-WAIT-for-mDNS flow (WakeController's "Waking…"
|
||||||
|
// overlay), which redials once the host reappears. Otherwise (auto-wake off, no MAC, or already
|
||||||
|
// seen live) dial straight through.
|
||||||
fun doConnect(targetHost: String, targetPort: Int, name: String, pinHex: String?) {
|
fun doConnect(targetHost: String, targetPort: Int, name: String, pinHex: String?) {
|
||||||
if (identity == null) {
|
if (identity == null) {
|
||||||
status = "Identity not ready yet — try again in a moment"
|
status = "Identity not ready yet — try again in a moment"
|
||||||
@@ -232,6 +315,9 @@ fun ConnectScreen(
|
|||||||
if (kh != null) discovered.firstOrNull { kh.matches(it) }
|
if (kh != null) discovered.firstOrNull { kh.matches(it) }
|
||||||
else discovered.firstOrNull { it.host == targetHost && it.port == targetPort }
|
else discovered.firstOrNull { it.host == targetHost && it.port == targetPort }
|
||||||
if (settings.autoWakeEnabled && macs.isNotEmpty() && liveAdvert() == null) {
|
if (settings.autoWakeEnabled && macs.isNotEmpty() && liveAdvert() == null) {
|
||||||
|
// Fire-and-forget first packet (harmless if it's awake), then dial-first.
|
||||||
|
scope.launch(Dispatchers.IO) { NativeBridge.nativeWakeOnLan(macs.joinToString(","), targetHost) }
|
||||||
|
doConnectDirect(targetHost, targetPort, name, pinHex, onFailure = {
|
||||||
waker.start(
|
waker.start(
|
||||||
hostName = name,
|
hostName = name,
|
||||||
connectsAfter = true,
|
connectsAfter = true,
|
||||||
@@ -240,8 +326,9 @@ fun ConnectScreen(
|
|||||||
isOnline = { liveAdvert() != null },
|
isOnline = { liveAdvert() != null },
|
||||||
onOnline = {
|
onOnline = {
|
||||||
val live = liveAdvert()
|
val live = liveAdvert()
|
||||||
// Woke back on a new address? Re-key the saved record so it (and future connects)
|
// Woke back on a new address? Re-key the saved record so it (and future
|
||||||
// point at the live one, then dial there.
|
// connects) point at the live one, then dial there (no fallback on this
|
||||||
|
// redial — a second failure surfaces as the plain error).
|
||||||
if (live != null && kh != null && (live.host != kh.address || live.port != kh.port)) {
|
if (live != null && kh != null && (live.host != kh.address || live.port != kh.port)) {
|
||||||
knownHostStore.update(kh.address, kh.port, kh.copy(address = live.host, port = live.port))
|
knownHostStore.update(kh.address, kh.port, kh.copy(address = live.host, port = live.port))
|
||||||
savedHosts = knownHostStore.all()
|
savedHosts = knownHostStore.all()
|
||||||
@@ -249,6 +336,7 @@ fun ConnectScreen(
|
|||||||
doConnectDirect(live?.host ?: targetHost, live?.port ?: targetPort, name, pinHex)
|
doConnectDirect(live?.host ?: targetHost, live?.port ?: targetPort, name, pinHex)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
doConnectDirect(targetHost, targetPort, name, pinHex)
|
doConnectDirect(targetHost, targetPort, name, pinHex)
|
||||||
}
|
}
|
||||||
@@ -311,6 +399,12 @@ fun ConnectScreen(
|
|||||||
dh: DiscoveredHost? = null,
|
dh: DiscoveredHost? = null,
|
||||||
manualName: String? = null,
|
manualName: String? = null,
|
||||||
) {
|
) {
|
||||||
|
// Every dial/pair path funnels through here — with local network access denied the connect
|
||||||
|
// can only EPERM its way to a 10 s timeout, so ask instead of pretending to try.
|
||||||
|
if (!lnpGranted) {
|
||||||
|
lnpPrompt = true
|
||||||
|
return
|
||||||
|
}
|
||||||
val known = knownHostStore.get(targetHost, targetPort)
|
val known = knownHostStore.get(targetHost, targetPort)
|
||||||
val adv = dh?.fingerprint?.lowercase()
|
val adv = dh?.fingerprint?.lowercase()
|
||||||
// Label precedence: a saved host keeps its (possibly user-renamed) name; else the discovered
|
// Label precedence: a saved host keeps its (possibly user-renamed) name; else the discovered
|
||||||
@@ -347,7 +441,7 @@ fun ConnectScreen(
|
|||||||
title = kh.name,
|
title = kh.name,
|
||||||
subtitle = "${kh.address}:${kh.port}",
|
subtitle = "${kh.address}:${kh.port}",
|
||||||
filled = true,
|
filled = true,
|
||||||
online = discovered.any { it.host == kh.address && it.port == kh.port },
|
online = kh.isOnline(discovered, reachable),
|
||||||
paired = kh.paired,
|
paired = kh.paired,
|
||||||
knownHost = kh,
|
knownHost = kh,
|
||||||
activate = { connect(kh.address, kh.port) },
|
activate = { connect(kh.address, kh.port) },
|
||||||
@@ -384,7 +478,8 @@ fun ConnectScreen(
|
|||||||
// handle — the touch grid guards the same way with enabled=!connecting), or while the whole
|
// handle — the touch grid guards the same way with enabled=!connecting), or while the whole
|
||||||
// console home is cross-fading out.
|
// console home is cross-fading out.
|
||||||
navActive = navGate && !connecting && !showManualSheet && pendingTrust == null &&
|
navActive = navGate && !connecting && !showManualSheet && pendingTrust == null &&
|
||||||
awaiting == null && editTarget == null && optionsTarget == null && waker.waking == null,
|
awaiting == null && editTarget == null && optionsTarget == null &&
|
||||||
|
waker.waking == null && !lnpPrompt,
|
||||||
onActivate = { it.activate() },
|
onActivate = { it.activate() },
|
||||||
onOpenLibrary = { it.knownHost?.let(onOpenLibrary) },
|
onOpenLibrary = { it.knownHost?.let(onOpenLibrary) },
|
||||||
onOpenSettings = onOpenSettings,
|
onOpenSettings = onOpenSettings,
|
||||||
@@ -451,6 +546,38 @@ fun ConnectScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!lnpGranted) {
|
||||||
|
// Local network access denied: discovery can't ever find anything and every connect
|
||||||
|
// would time out — say so at the top, with the fix one tap away, instead of letting
|
||||||
|
// the screen look idle/broken.
|
||||||
|
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.errorContainer,
|
||||||
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Local network access is off",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Android blocks punktfunk from finding or reaching hosts until you allow it.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
TextButton(onClick = { lnpPrompt = true }) { Text("Allow…") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (savedHosts.isEmpty() && discoveredUnsaved.isEmpty()) {
|
if (savedHosts.isEmpty() && discoveredUnsaved.isEmpty()) {
|
||||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||||
EmptyHostsState()
|
EmptyHostsState()
|
||||||
@@ -466,6 +593,7 @@ fun ConnectScreen(
|
|||||||
name = kh.name,
|
name = kh.name,
|
||||||
address = "${kh.address}:${kh.port}",
|
address = "${kh.address}:${kh.port}",
|
||||||
status = if (kh.paired) HostStatus.PAIRED else HostStatus.TOFU,
|
status = if (kh.paired) HostStatus.PAIRED else HostStatus.TOFU,
|
||||||
|
online = kh.isOnline(discovered, reachable),
|
||||||
enabled = !connecting,
|
enabled = !connecting,
|
||||||
onConnect = { connect(kh.address, kh.port) },
|
onConnect = { connect(kh.address, kh.port) },
|
||||||
onForget = {
|
onForget = {
|
||||||
@@ -477,8 +605,12 @@ fun ConnectScreen(
|
|||||||
// through the WakeController so it shows the "Waking…" overlay and waits for
|
// through the WakeController so it shows the "Waking…" overlay and waits for
|
||||||
// the host to come online (matched by fingerprint, so a new DHCP address on a
|
// the host to come online (matched by fingerprint, so a new DHCP address on a
|
||||||
// cold boot still counts as "up") rather than firing a single silent packet.
|
// cold boot still counts as "up") rather than firing a single silent packet.
|
||||||
onWake = if (kh.mac.isNotEmpty() && discovered.none { kh.matches(it) }) {
|
onWake = if (kh.mac.isNotEmpty() && !kh.isOnline(discovered, reachable)) {
|
||||||
{
|
{
|
||||||
|
// The magic packet is UDP broadcast — LNP-blocked like everything else.
|
||||||
|
if (!lnpGranted) {
|
||||||
|
lnpPrompt = true
|
||||||
|
} else {
|
||||||
waker.start(
|
waker.start(
|
||||||
hostName = kh.name,
|
hostName = kh.name,
|
||||||
connectsAfter = false,
|
connectsAfter = false,
|
||||||
@@ -488,6 +620,7 @@ fun ConnectScreen(
|
|||||||
onOnline = {},
|
onOnline = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
},
|
},
|
||||||
@@ -505,6 +638,7 @@ fun ConnectScreen(
|
|||||||
name = dh.name,
|
name = dh.name,
|
||||||
address = "${dh.host}:${dh.port}",
|
address = "${dh.host}:${dh.port}",
|
||||||
status = if (dh.pairingRequired) HostStatus.PAIRING else HostStatus.TOFU,
|
status = if (dh.pairingRequired) HostStatus.PAIRING else HostStatus.TOFU,
|
||||||
|
online = true, // in the discovered list ⇒ live on mDNS right now
|
||||||
enabled = !connecting,
|
enabled = !connecting,
|
||||||
onConnect = { connect(dh.host, dh.port, dh) },
|
onConnect = { connect(dh.host, dh.port, dh) },
|
||||||
onForget = null,
|
onForget = null,
|
||||||
@@ -514,8 +648,10 @@ fun ConnectScreen(
|
|||||||
|
|
||||||
// Active-discovery hint: discovery runs whenever this screen is up, so while it's
|
// Active-discovery hint: discovery runs whenever this screen is up, so while it's
|
||||||
// scanning but nothing's turned up yet (and we're not mid-connect), show it's working
|
// scanning but nothing's turned up yet (and we're not mid-connect), show it's working
|
||||||
// rather than looking idle/empty.
|
// rather than looking idle/empty. Suppressed while local network access is denied —
|
||||||
if (!connecting && discovered.isEmpty()) {
|
// a spinner would be a lie there (the browse can't receive anything); the banner above
|
||||||
|
// owns that state.
|
||||||
|
if (lnpGranted && !connecting && discovered.isEmpty()) {
|
||||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
|
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
|
||||||
@@ -615,17 +751,22 @@ fun ConnectScreen(
|
|||||||
|
|
||||||
// Console host options (Up on a saved carousel tile): Wake / Edit / Forget.
|
// Console host options (Up on a saved carousel tile): Wake / Edit / Forget.
|
||||||
optionsTarget?.let { kh ->
|
optionsTarget?.let { kh ->
|
||||||
val offline = discovered.none { kh.matches(it) }
|
val offline = !kh.isOnline(discovered, reachable)
|
||||||
GamepadHostOptionsDialog(
|
GamepadHostOptionsDialog(
|
||||||
hostName = kh.name,
|
hostName = kh.name,
|
||||||
canWake = kh.mac.isNotEmpty() && offline,
|
canWake = kh.mac.isNotEmpty() && offline,
|
||||||
onWake = {
|
onWake = {
|
||||||
optionsTarget = null
|
optionsTarget = null
|
||||||
|
// The magic packet is UDP broadcast — LNP-blocked like everything else.
|
||||||
|
if (!lnpGranted) {
|
||||||
|
lnpPrompt = true
|
||||||
|
} else {
|
||||||
waker.start(
|
waker.start(
|
||||||
hostName = kh.name, connectsAfter = false, macs = kh.mac, lastIp = kh.address,
|
hostName = kh.name, connectsAfter = false, macs = kh.mac, lastIp = kh.address,
|
||||||
isOnline = { discovered.any { kh.matches(it) } },
|
isOnline = { discovered.any { kh.matches(it) } },
|
||||||
onOnline = {},
|
onOnline = {},
|
||||||
)
|
)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
// A saved host always has a library (it's a knownHost) → offer it when the setting's on,
|
// A saved host always has a library (it's a knownHost) → offer it when the setting's on,
|
||||||
// so a TV remote reaches the library here instead of via the Y face button.
|
// so a TV remote reaches the library here instead of via the Y face button.
|
||||||
@@ -673,6 +814,29 @@ fun ConnectScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (lnpPrompt) {
|
||||||
|
// Android 17+ local-network-permission rationale: re-request (a permanently-denied request
|
||||||
|
// returns instantly without a system prompt — hence the settings deep link alongside).
|
||||||
|
val onAllow = {
|
||||||
|
lnpPrompt = false
|
||||||
|
localNetLauncher.launch(Manifest.permission.ACCESS_LOCAL_NETWORK)
|
||||||
|
}
|
||||||
|
val onSettings = {
|
||||||
|
lnpPrompt = false
|
||||||
|
context.startActivity(
|
||||||
|
Intent(
|
||||||
|
android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
|
||||||
|
Uri.fromParts("package", context.packageName, null),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (gamepadUi) {
|
||||||
|
GamepadLocalNetworkDialog(onAllow = onAllow, onSettings = onSettings, onDismiss = { lnpPrompt = false })
|
||||||
|
} else {
|
||||||
|
LocalNetworkDialog(onAllow = onAllow, onSettings = onSettings, onDismiss = { lnpPrompt = false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Topmost: the "Waking…" overlay rides over both the touch grid and the console home.
|
// Topmost: the "Waking…" overlay rides over both the touch grid and the console home.
|
||||||
WakeOverlay(waker, gamepadUi)
|
WakeOverlay(waker, gamepadUi)
|
||||||
}
|
}
|
||||||
@@ -687,6 +851,18 @@ fun hasNearbyPermission(context: Context): Boolean =
|
|||||||
ContextCompat.checkSelfPermission(context, Manifest.permission.NEARBY_WIFI_DEVICES) ==
|
ContextCompat.checkSelfPermission(context, Manifest.permission.NEARBY_WIFI_DEVICES) ==
|
||||||
PackageManager.PERMISSION_GRANTED
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether ACCESS_LOCAL_NETWORK is held (API 37+; below, the permission doesn't exist and local
|
||||||
|
* network access is implicit). Android 17's Local Network Protection blocks ALL local-network
|
||||||
|
* traffic for apps targeting SDK 37 without this runtime grant: UDP sends fail with EPERM, so the
|
||||||
|
* QUIC dial surfaces as a silent handshake timeout and the mDNS browse receives nothing. Unlike
|
||||||
|
* [hasNearbyPermission] this is load-bearing — nothing on the connect screen works without it.
|
||||||
|
*/
|
||||||
|
fun hasLocalNetworkPermission(context: Context): Boolean =
|
||||||
|
Build.VERSION.SDK_INT < Build.VERSION_CODES.CINNAMON_BUN ||
|
||||||
|
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_LOCAL_NETWORK) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* True when a saved host and a discovered advert are the same machine — matched by certificate
|
* True when a saved host and a discovered advert are the same machine — matched by certificate
|
||||||
* fingerprint when both carry it (so it survives a DHCP address change), else by address:port.
|
* fingerprint when both carry it (so it survives a DHCP address change), else by address:port.
|
||||||
@@ -697,3 +873,11 @@ private fun KnownHost.matches(dh: DiscoveredHost): Boolean {
|
|||||||
if (!advFp.isNullOrEmpty() && fpHex.isNotEmpty() && fpHex.lowercase() == advFp) return true
|
if (!advFp.isNullOrEmpty() && fpHex.isNotEmpty() && fpHex.lowercase() == advFp) return true
|
||||||
return address == dh.host && port == dh.port
|
return address == dh.host && port == dh.port
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when a saved host is reachable RIGHT NOW: advertising on mDNS OR answering the QUIC probe
|
||||||
|
* (a host reached over a routed network — Tailscale/VPN — never advertises but is reachable). The
|
||||||
|
* display-side companion to dial-first: presence no longer means "on this LAN".
|
||||||
|
*/
|
||||||
|
private fun KnownHost.isOnline(discovered: List<DiscoveredHost>, reachable: Set<String>): Boolean =
|
||||||
|
discovered.any { matches(it) } || reachable.contains("$address:$port")
|
||||||
|
|||||||
@@ -229,6 +229,29 @@ fun GamepadHostOptionsDialog(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Console counterpart of [LocalNetworkDialog] — the Android 17+ ACCESS_LOCAL_NETWORK rationale. */
|
||||||
|
@Composable
|
||||||
|
fun GamepadLocalNetworkDialog(onAllow: () -> Unit, onSettings: () -> Unit, onDismiss: () -> Unit) {
|
||||||
|
GamepadDialog(
|
||||||
|
title = "Allow local network access",
|
||||||
|
onDismiss = onDismiss,
|
||||||
|
actions = listOf(
|
||||||
|
DialogAction("Allow", primary = true, onClick = onAllow),
|
||||||
|
DialogAction("Open settings", onClick = onSettings),
|
||||||
|
DialogAction("Not now", onClick = onDismiss),
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
DialogText(
|
||||||
|
"Android blocks punktfunk from talking to devices on your network, so it can't find " +
|
||||||
|
"or reach any host until you allow it.",
|
||||||
|
)
|
||||||
|
DialogText(
|
||||||
|
"If no prompt appears after Allow, enable “Nearby devices” for punktfunk in " +
|
||||||
|
"system settings.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun GamepadTrustNewDialog(pt: PendingTrust, onTrust: () -> Unit, onPairInstead: () -> Unit, onDismiss: () -> Unit) {
|
fun GamepadTrustNewDialog(pt: PendingTrust, onTrust: () -> Unit, onPairInstead: () -> Unit, onDismiss: () -> Unit) {
|
||||||
GamepadDialog(
|
GamepadDialog(
|
||||||
|
|||||||
@@ -327,7 +327,7 @@ private fun buildSettingsRows(s: Settings, update: (Settings) -> Unit): List<GpR
|
|||||||
) { update(s.copy(hdrEnabled = it)) },
|
) { update(s.copy(hdrEnabled = it)) },
|
||||||
toggle(
|
toggle(
|
||||||
"lowLatency", null, "Low-latency mode",
|
"lowLatency", null, "Low-latency mode",
|
||||||
"Experimental — aggressive decoder and system tuning. Turn off if the stream stutters or glitches.",
|
"The fast pipeline (async decode + system tuning). On by default — turn off to fall back if the stream stutters or glitches.",
|
||||||
s.lowLatencyMode,
|
s.lowLatencyMode,
|
||||||
) { update(s.copy(lowLatencyMode = it)) },
|
) { update(s.copy(lowLatencyMode = it)) },
|
||||||
|
|
||||||
@@ -346,11 +346,12 @@ private fun buildSettingsRows(s: Settings, update: (Settings) -> Unit): List<GpR
|
|||||||
GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl }, s.gamepad,
|
GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl }, s.gamepad,
|
||||||
) { update(s.copy(gamepad = it)) },
|
) { update(s.copy(gamepad = it)) },
|
||||||
|
|
||||||
toggle(
|
choice(
|
||||||
"hud", "Interface", "Statistics overlay",
|
"hud", "Interface", "Statistics overlay",
|
||||||
"Show FPS, throughput and latency while streaming.",
|
"How much the overlay shows: Compact (one line) → Normal → Detailed (full HUD). " +
|
||||||
s.statsHudEnabled,
|
"A 3-finger tap cycles the tiers live.",
|
||||||
) { update(s.copy(statsHudEnabled = it)) },
|
STATS_VERBOSITY_OPTIONS, s.statsVerbosity,
|
||||||
|
) { update(s.copy(statsVerbosity = it)) },
|
||||||
toggle(
|
toggle(
|
||||||
"library", null, "Game library",
|
"library", null, "Game library",
|
||||||
"Browse a paired host's games with Y (experimental).",
|
"Browse a paired host's games with Y (experimental).",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package io.unom.punktfunk
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import io.unom.punktfunk.kit.Gamepad
|
import io.unom.punktfunk.kit.Gamepad
|
||||||
import io.unom.punktfunk.kit.NativeBridge
|
import io.unom.punktfunk.kit.NativeBridge
|
||||||
|
import io.unom.punktfunk.kit.VideoDecoders
|
||||||
import io.unom.punktfunk.kit.security.ClientIdentity
|
import io.unom.punktfunk.kit.security.ClientIdentity
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -41,7 +42,10 @@ suspend fun connectToHost(
|
|||||||
host, port, w, h, hz,
|
host, port, w, h, hz,
|
||||||
identity.certPem, identity.privateKeyPem, pinHex,
|
identity.certPem, identity.privateKeyPem, pinHex,
|
||||||
settings.bitrateKbps, settings.compositor, gamepadPref,
|
settings.bitrateKbps, settings.compositor, gamepadPref,
|
||||||
hdrEnabled, settings.audioChannels, settings.preferredCodec(), timeoutMs,
|
hdrEnabled, settings.audioChannels,
|
||||||
|
// What this device can decode (H.264|HEVC always, AV1 when a real decoder exists) +
|
||||||
|
// the user's soft codec preference — the host resolves the emitted codec from both.
|
||||||
|
VideoDecoders.decodableCodecBits(), settings.preferredCodec(), timeoutMs,
|
||||||
launch,
|
launch,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,12 +26,18 @@ data class Settings(
|
|||||||
/** Requested audio channel count: 2 (stereo), 6 (5.1) or 8 (7.1). The host clamps to what it
|
/** Requested audio channel count: 2 (stereo), 6 (5.1) or 8 (7.1). The host clamps to what it
|
||||||
* can capture; the resolved count drives the decoder + AAudio layout. */
|
* can capture; the resolved count drives the decoder + AAudio layout. */
|
||||||
val audioChannels: Int = 2,
|
val audioChannels: Int = 2,
|
||||||
/** Preferred video codec: `"auto"` (host decides), `"hevc"`, or `"h264"`. A soft preference — the
|
/** Preferred video codec: `"auto"` (host decides), `"hevc"`, `"h264"`, or `"av1"`. A soft
|
||||||
* host emits it when it can, else falls back. AMediaCodec decodes whichever the host resolves. */
|
* preference — the host emits it when it can, else falls back. AMediaCodec decodes whichever
|
||||||
|
* the host resolves (AV1 is only advertised/offered when the device has a real AV1 decoder). */
|
||||||
val codec: String = "auto",
|
val codec: String = "auto",
|
||||||
val micEnabled: Boolean = false,
|
val micEnabled: Boolean = false,
|
||||||
/** Show the live stats overlay (FPS / throughput / latency) during a stream. */
|
/**
|
||||||
val statsHudEnabled: Boolean = true,
|
* How much the in-stream stats overlay shows — see [StatsVerbosity]. Defaults to
|
||||||
|
* [StatsVerbosity.NORMAL] (the res/fps line + latency headline + reliability counters); the full
|
||||||
|
* decoder/feed/equation HUD is [StatsVerbosity.DETAILED], and a single terse line is
|
||||||
|
* [StatsVerbosity.COMPACT]. A 3-finger tap cycles through the tiers live.
|
||||||
|
*/
|
||||||
|
val statsVerbosity: StatsVerbosity = StatsVerbosity.NORMAL,
|
||||||
/**
|
/**
|
||||||
* Touch input model — how touchscreen fingers drive the host. [TouchMode.TRACKPAD] (default):
|
* Touch input model — how touchscreen fingers drive the host. [TouchMode.TRACKPAD] (default):
|
||||||
* the cursor stays put on touch-down and moves by the finger's relative delta (swipe to nudge,
|
* the cursor stays put on touch-down and moves by the finger's relative delta (swipe to nudge,
|
||||||
@@ -55,14 +61,18 @@ data class Settings(
|
|||||||
*/
|
*/
|
||||||
val libraryEnabled: Boolean = true,
|
val libraryEnabled: Boolean = true,
|
||||||
/**
|
/**
|
||||||
* "Low-latency mode (experimental)" — the master switch over the latency overhaul: decoder
|
* "Low-latency mode" — the master switch over the latency pipeline: the async decode loop
|
||||||
* ranking + per-SoC vendor keys + the async decode loop (native), pipeline thread boosts + ADPF
|
* (native; burst-feed + present-newest-per-vsync, the Apple client's discipline), decoder ranking
|
||||||
* max-performance, game-tagged AAudio, DSCP marking on the media sockets, HDMI ALLM, and the
|
* + per-SoC vendor keys, pipeline thread boosts + ADPF max-performance, game-tagged AAudio, DSCP
|
||||||
* forced TV mode switch. (The Wi-Fi locks are NOT part of this — both are always held while
|
* marking on the media sockets, HDMI ALLM, and the forced TV mode switch. (The Wi-Fi locks are NOT
|
||||||
* streaming; see StreamScreen.) Off (default): the original decode pipeline, kept as the
|
* part of this — both are always held while streaming; see StreamScreen.) On (default): the fast
|
||||||
* known-good baseline until the aggressive stack is proven per-device.
|
* pipeline. Off restores the original synchronous decode loop byte-for-byte, kept as a per-device
|
||||||
|
* escape hatch. Promoted to default once the receive-side latency ratchet the overhaul interacted
|
||||||
|
* badly with was fixed in the shared core — the pump now jumps to live on a standing backlog
|
||||||
|
* instead of accumulating it (see `punktfunk-core` `FrameChannel`), so the async loop no longer
|
||||||
|
* feeds a queue that only grows.
|
||||||
*/
|
*/
|
||||||
val lowLatencyMode: Boolean = false,
|
val lowLatencyMode: Boolean = true,
|
||||||
/**
|
/**
|
||||||
* Wake-on-LAN a saved host before connecting when it isn't currently seen on mDNS. On (default):
|
* Wake-on-LAN a saved host before connecting when it isn't currently seen on mDNS. On (default):
|
||||||
* a connect to a host with a learned MAC that isn't advertising sends a magic packet and waits
|
* a connect to a host with a learned MAC that isn't advertising sends a magic packet and waits
|
||||||
@@ -77,6 +87,27 @@ data class Settings(
|
|||||||
/** [Settings.touchMode] values; persisted by name. */
|
/** [Settings.touchMode] values; persisted by name. */
|
||||||
enum class TouchMode { TRACKPAD, POINTER, TOUCH }
|
enum class TouchMode { TRACKPAD, POINTER, TOUCH }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stats-overlay detail tiers, in cycling order (persisted by name). Each tier is a strict superset
|
||||||
|
* of the previous one, so toning down never hides a number a lower tier keeps:
|
||||||
|
* - [OFF] — no overlay (and native sampling is gated off, one atomic load per frame).
|
||||||
|
* - [COMPACT] — one line: `fps · end-to-end ms · Mb/s` (+ a loss flag when frames drop).
|
||||||
|
* - [NORMAL] — adds the resolution/refresh line, the end-to-end p50/p95 headline, and the
|
||||||
|
* reliability counters (lost / skipped / FEC) when nonzero. The default.
|
||||||
|
* - [DETAILED] — the full HUD: also the decoder label, the video-feed descriptor, and the
|
||||||
|
* `host+network + decode` stage equation.
|
||||||
|
* A 3-finger tap in-stream cycles Off → Compact → Normal → Detailed → Off (see [next]).
|
||||||
|
*/
|
||||||
|
enum class StatsVerbosity(val label: String) {
|
||||||
|
OFF("Off"),
|
||||||
|
COMPACT("Compact"),
|
||||||
|
NORMAL("Normal"),
|
||||||
|
DETAILED("Detailed");
|
||||||
|
|
||||||
|
/** The next tier for the live 3-finger-tap cycle (wraps Detailed → Off). */
|
||||||
|
fun next(): StatsVerbosity = entries[(ordinal + 1) % entries.size]
|
||||||
|
}
|
||||||
|
|
||||||
/** Loads/saves [Settings] in the app-private `punktfunk_settings` prefs. */
|
/** Loads/saves [Settings] in the app-private `punktfunk_settings` prefs. */
|
||||||
class SettingsStore(context: Context) {
|
class SettingsStore(context: Context) {
|
||||||
private val prefs =
|
private val prefs =
|
||||||
@@ -93,14 +124,23 @@ class SettingsStore(context: Context) {
|
|||||||
audioChannels = prefs.getInt(K_AUDIO_CH, 2),
|
audioChannels = prefs.getInt(K_AUDIO_CH, 2),
|
||||||
codec = prefs.getString(K_CODEC, "auto") ?: "auto",
|
codec = prefs.getString(K_CODEC, "auto") ?: "auto",
|
||||||
micEnabled = prefs.getBoolean(K_MIC, false),
|
micEnabled = prefs.getBoolean(K_MIC, false),
|
||||||
statsHudEnabled = prefs.getBoolean(K_HUD, true),
|
statsVerbosity = prefs.getString(K_STATS_VERBOSITY, null)
|
||||||
|
?.let { name -> StatsVerbosity.entries.firstOrNull { it.name == name } }
|
||||||
|
// Migration from the pre-tier Boolean "stats_hud_enabled": an explicit OFF stays off;
|
||||||
|
// everyone else (incl. fresh installs) lands on NORMAL — the old always-full HUD toned
|
||||||
|
// down to the new default, which is the whole point of adding tiers.
|
||||||
|
?: if (prefs.contains(K_HUD) && !prefs.getBoolean(K_HUD, true)) {
|
||||||
|
StatsVerbosity.OFF
|
||||||
|
} else {
|
||||||
|
StatsVerbosity.NORMAL
|
||||||
|
},
|
||||||
touchMode = prefs.getString(K_TOUCH_MODE, null)
|
touchMode = prefs.getString(K_TOUCH_MODE, null)
|
||||||
?.let { name -> TouchMode.entries.firstOrNull { it.name == name } }
|
?.let { name -> TouchMode.entries.firstOrNull { it.name == name } }
|
||||||
// Migration: the pre-enum Boolean "trackpad_mode" (true = trackpad, false = direct).
|
// Migration: the pre-enum Boolean "trackpad_mode" (true = trackpad, false = direct).
|
||||||
?: if (prefs.getBoolean(K_TRACKPAD, true)) TouchMode.TRACKPAD else TouchMode.POINTER,
|
?: if (prefs.getBoolean(K_TRACKPAD, true)) TouchMode.TRACKPAD else TouchMode.POINTER,
|
||||||
gamepadUiEnabled = prefs.getBoolean(K_GAMEPAD_UI, true),
|
gamepadUiEnabled = prefs.getBoolean(K_GAMEPAD_UI, true),
|
||||||
libraryEnabled = prefs.getBoolean(K_LIBRARY, true),
|
libraryEnabled = prefs.getBoolean(K_LIBRARY, true),
|
||||||
lowLatencyMode = prefs.getBoolean(K_LOW_LATENCY, false),
|
lowLatencyMode = prefs.getBoolean(K_LOW_LATENCY, true),
|
||||||
autoWakeEnabled = prefs.getBoolean(K_AUTO_WAKE, true),
|
autoWakeEnabled = prefs.getBoolean(K_AUTO_WAKE, true),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -116,7 +156,7 @@ class SettingsStore(context: Context) {
|
|||||||
.putInt(K_AUDIO_CH, s.audioChannels)
|
.putInt(K_AUDIO_CH, s.audioChannels)
|
||||||
.putString(K_CODEC, s.codec)
|
.putString(K_CODEC, s.codec)
|
||||||
.putBoolean(K_MIC, s.micEnabled)
|
.putBoolean(K_MIC, s.micEnabled)
|
||||||
.putBoolean(K_HUD, s.statsHudEnabled)
|
.putString(K_STATS_VERBOSITY, s.statsVerbosity.name)
|
||||||
.putString(K_TOUCH_MODE, s.touchMode.name)
|
.putString(K_TOUCH_MODE, s.touchMode.name)
|
||||||
.putBoolean(K_GAMEPAD_UI, s.gamepadUiEnabled)
|
.putBoolean(K_GAMEPAD_UI, s.gamepadUiEnabled)
|
||||||
.putBoolean(K_LIBRARY, s.libraryEnabled)
|
.putBoolean(K_LIBRARY, s.libraryEnabled)
|
||||||
@@ -136,18 +176,26 @@ class SettingsStore(context: Context) {
|
|||||||
const val K_AUDIO_CH = "audio_channels"
|
const val K_AUDIO_CH = "audio_channels"
|
||||||
const val K_CODEC = "codec"
|
const val K_CODEC = "codec"
|
||||||
const val K_MIC = "mic_enabled"
|
const val K_MIC = "mic_enabled"
|
||||||
|
const val K_STATS_VERBOSITY = "stats_verbosity"
|
||||||
|
|
||||||
|
/** Pre-tier Boolean the [K_STATS_VERBOSITY] enum replaced — read once for migration, never
|
||||||
|
* written. */
|
||||||
const val K_HUD = "stats_hud_enabled"
|
const val K_HUD = "stats_hud_enabled"
|
||||||
const val K_TOUCH_MODE = "touch_mode"
|
const val K_TOUCH_MODE = "touch_mode"
|
||||||
const val K_GAMEPAD_UI = "gamepad_ui_enabled"
|
const val K_GAMEPAD_UI = "gamepad_ui_enabled"
|
||||||
const val K_LIBRARY = "library_enabled"
|
const val K_LIBRARY = "library_enabled"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deliberately NOT the original `"low_latency_mode"` key: that one shipped default-ON, so
|
* Bumped AGAIN to restart every install at the new default (ON). History: the original
|
||||||
* any install that ever saved settings persisted `true` — under the old key, flipping the
|
* `"low_latency_mode"` shipped default-ON; `"low_latency_mode_experimental"` restarted
|
||||||
* default to off would leave exactly the regressed devices stuck on the overhaul. The fresh
|
* everyone at OFF after the overhaul regressed on some phones. That regression was the
|
||||||
* key restarts everyone at the safe default; the stale one is abandoned unread.
|
* receive-side latency ratchet the async loop fed (a standing queue that only grew) — now
|
||||||
|
* fixed in the shared core (`punktfunk-core` `FrameChannel`: the pump jumps to live on a
|
||||||
|
* standing backlog instead of accumulating it), so the fast pipeline is the default again. A
|
||||||
|
* fresh key re-defaults every install — including ones persisted OFF under the old key — to
|
||||||
|
* on; both stale keys are abandoned unread. The toggle stays as a per-device escape hatch.
|
||||||
*/
|
*/
|
||||||
const val K_LOW_LATENCY = "low_latency_mode_experimental"
|
const val K_LOW_LATENCY = "low_latency_mode_v2"
|
||||||
const val K_AUTO_WAKE = "auto_wake_enabled"
|
const val K_AUTO_WAKE = "auto_wake_enabled"
|
||||||
|
|
||||||
/** Legacy Boolean the enum replaced — read once as the migration default, never written. */
|
/** Legacy Boolean the enum replaced — read once as the migration default, never written. */
|
||||||
@@ -224,14 +272,17 @@ val AUDIO_CHANNEL_OPTIONS = listOf(
|
|||||||
8 to "7.1 Surround",
|
8 to "7.1 Surround",
|
||||||
)
|
)
|
||||||
|
|
||||||
/** (stored value, label) for the preferred video codec. `"auto"` = host decides. */
|
/** (stored value, label) for the preferred video codec. `"auto"` = host decides. The `"av1"` row
|
||||||
|
* only makes sense on a device with a real AV1 decoder — SettingsScreen filters it out otherwise. */
|
||||||
val CODEC_OPTIONS = listOf(
|
val CODEC_OPTIONS = listOf(
|
||||||
"auto" to "Automatic",
|
"auto" to "Automatic",
|
||||||
"hevc" to "HEVC (H.265)",
|
"hevc" to "HEVC (H.265)",
|
||||||
"h264" to "H.264 (AVC)",
|
"h264" to "H.264 (AVC)",
|
||||||
|
"av1" to "AV1",
|
||||||
)
|
)
|
||||||
|
|
||||||
/** The [Settings.codec] string as a `quic::CODEC_*` preference byte (`0` = auto). H264=1, HEVC=2. */
|
/** The [Settings.codec] string as a `quic::CODEC_*` preference byte (`0` = auto). H264=1, HEVC=2,
|
||||||
|
* AV1=4. */
|
||||||
fun Settings.preferredCodec(): Int = when (codec) {
|
fun Settings.preferredCodec(): Int = when (codec) {
|
||||||
"h264" -> 1
|
"h264" -> 1
|
||||||
"hevc" -> 2
|
"hevc" -> 2
|
||||||
@@ -261,6 +312,9 @@ val COMPOSITOR_OPTIONS = listOf(
|
|||||||
"gamescope",
|
"gamescope",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** (verbosity, label) for the stats-overlay detail picker. Order = the live 3-finger-tap cycle. */
|
||||||
|
val STATS_VERBOSITY_OPTIONS = StatsVerbosity.entries.map { it to it.label }
|
||||||
|
|
||||||
/** (mode, label) for the touch-input model. */
|
/** (mode, label) for the touch-input model. */
|
||||||
val TOUCH_MODE_OPTIONS = listOf(
|
val TOUCH_MODE_OPTIONS = listOf(
|
||||||
TouchMode.TRACKPAD to "Trackpad",
|
TouchMode.TRACKPAD to "Trackpad",
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
|||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
|
import io.unom.punktfunk.kit.VideoDecoders
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stream settings, organised as an iOS-Settings / Android-system-settings style list of category
|
* Stream settings, organised as an iOS-Settings / Android-system-settings style list of category
|
||||||
@@ -300,7 +301,12 @@ private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: an
|
|||||||
update(s.copy(bitrateKbps = kbps))
|
update(s.copy(bitrateKbps = kbps))
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingDropdown(label = "Video codec", options = CODEC_OPTIONS, selected = s.codec) { c ->
|
// AV1 is only offered when the device has a real AV1 decoder (it's never advertised to the
|
||||||
|
// host otherwise, so preferring it would be a dead setting). A stored "av1" from a capable
|
||||||
|
// device stays visible so the selection is always representable.
|
||||||
|
val av1Capable = remember { VideoDecoders.pickDecoder("video/av01") != null }
|
||||||
|
val codecOptions = CODEC_OPTIONS.filter { (v, _) -> v != "av1" || av1Capable || s.codec == "av1" }
|
||||||
|
SettingDropdown(label = "Video codec", options = codecOptions, selected = s.codec) { c ->
|
||||||
update(s.copy(codec = c))
|
update(s.copy(codec = c))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,10 +332,10 @@ private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: an
|
|||||||
) { c -> update(s.copy(compositor = c)) }
|
) { c -> update(s.copy(compositor = c)) }
|
||||||
|
|
||||||
ToggleRow(
|
ToggleRow(
|
||||||
title = "Low-latency mode (experimental)",
|
title = "Low-latency mode",
|
||||||
subtitle = "Aggressive decoder and system tuning (per-device decoder selection, async " +
|
subtitle = "The fast pipeline (async decode, per-device decoder selection, HDMI game " +
|
||||||
"decode, HDMI game mode). Can lower latency, but may stutter or glitch on " +
|
"mode). On by default — turn off to fall back to the plain decode path if the stream " +
|
||||||
"some devices — turn off if the stream misbehaves.",
|
"stutters or glitches on this device.",
|
||||||
checked = s.lowLatencyMode,
|
checked = s.lowLatencyMode,
|
||||||
onCheckedChange = { on -> update(s.copy(lowLatencyMode = on)) },
|
onCheckedChange = { on -> update(s.copy(lowLatencyMode = on)) },
|
||||||
)
|
)
|
||||||
@@ -403,11 +409,17 @@ private fun InterfaceSettings(s: Settings, update: (Settings) -> Unit) {
|
|||||||
checked = s.autoWakeEnabled,
|
checked = s.autoWakeEnabled,
|
||||||
onCheckedChange = { on -> update(s.copy(autoWakeEnabled = on)) },
|
onCheckedChange = { on -> update(s.copy(autoWakeEnabled = on)) },
|
||||||
)
|
)
|
||||||
ToggleRow(
|
SettingDropdown(
|
||||||
title = "Stats overlay",
|
label = "Stats overlay",
|
||||||
subtitle = "Show FPS, throughput and latency while streaming (3-finger tap toggles it live)",
|
options = STATS_VERBOSITY_OPTIONS,
|
||||||
checked = s.statsHudEnabled,
|
selected = s.statsVerbosity,
|
||||||
onCheckedChange = { on -> update(s.copy(statsHudEnabled = on)) },
|
) { v -> update(s.copy(statsVerbosity = v)) }
|
||||||
|
Text(
|
||||||
|
"How much the in-stream overlay shows: Compact is a single fps · latency · bitrate " +
|
||||||
|
"line; Normal adds the resolution and reliability lines; Detailed adds the decoder, " +
|
||||||
|
"colour and latency-breakdown lines. A 3-finger tap cycles the tiers live.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,87 +15,139 @@ import io.unom.punktfunk.kit.NativeBridge
|
|||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The live stats overlay — the unified HUD (`design/stats-unification.md`, Android v1: headline is
|
* The live stats overlay — the unified HUD (`design/stats-unification.md`): headline is
|
||||||
* `capture→decoded`, tiled by `host+network` + `decode`). Reads the 18-double layout from
|
* `capture→displayed` tiled by `host+network` + `decode` + `display` when the platform delivered
|
||||||
* [NativeBridge.nativeVideoStats]:
|
* OnFrameRendered render callbacks this window (`dispValid`), falling back to the v1
|
||||||
* `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skew, w, h, hz, lost, bitDepth, colorPrimaries,
|
* `capture→decoded` headline without the `display` term when it didn't. Reads the 26-double
|
||||||
* colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms, netP50Ms]`. Indexes 10–13
|
* layout from [NativeBridge.nativeVideoStats]:
|
||||||
* (present on a current native lib) describe the negotiated video feed and render as a
|
* `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skew, w, h, hz, lostTotal, bitDepth, colorPrimaries,
|
||||||
* codec/depth/colour/chroma line; 14/15 render as the stage equation — split into
|
* colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms, netP50Ms, lost, skipped,
|
||||||
* `host + network + decode` when the Phase-2 terms at 16/17 are nonzero (a current host sends
|
* fec, frames, dispValid, displayP50Ms, e2eDispP50Ms, e2eDispP95Ms]`.
|
||||||
* per-AU 0xCF timings; an old host leaves them 0 and the combined `host+network` term stands);
|
*
|
||||||
* older layouts just omit those lines.
|
* [verbosity] selects how many lines render (each tier a superset of the last — see
|
||||||
|
* [StatsVerbosity]):
|
||||||
|
* - [StatsVerbosity.COMPACT] — one line, `fps · end-to-end ms · Mb/s` (+ a loss flag).
|
||||||
|
* - [StatsVerbosity.NORMAL] — the res/fps/Mb·s line, the end-to-end p50/p95 headline, and the
|
||||||
|
* reliability counters (18–21) when nonzero.
|
||||||
|
* - [StatsVerbosity.DETAILED] — also the decoder label, the video-feed descriptor (10–13), and the
|
||||||
|
* stage equation (14/15, split into `host + network` when the Phase-2 terms at 16/17 are nonzero).
|
||||||
|
* [StatsVerbosity.OFF] renders nothing. Older native layouts simply omit the lines they lack (the
|
||||||
|
* counter line falls back to the cumulative `lostTotal` at index 9 on a pre-window lib).
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
internal fun StatsOverlay(s: DoubleArray, decoderLabel: String = "", modifier: Modifier = Modifier) {
|
internal fun StatsOverlay(
|
||||||
if (s.size < 10) return
|
s: DoubleArray,
|
||||||
|
verbosity: StatsVerbosity,
|
||||||
|
decoderLabel: String = "",
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
if (verbosity == StatsVerbosity.OFF || s.size < 10) return
|
||||||
val w = s[6].toInt()
|
val w = s[6].toInt()
|
||||||
val h = s[7].toInt()
|
val h = s[7].toInt()
|
||||||
val hz = s[8].toInt()
|
val hz = s[8].toInt()
|
||||||
val latValid = s[4] != 0.0
|
val latValid = s[4] != 0.0
|
||||||
val skew = s[5] != 0.0
|
val skew = s[5] != 0.0
|
||||||
val lost = s[9].toLong()
|
val lost = s[9].toLong()
|
||||||
|
val detailed = verbosity == StatsVerbosity.DETAILED
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.background(Color.Black.copy(alpha = 0.45f), RoundedCornerShape(6.dp))
|
.background(Color.Black.copy(alpha = 0.45f), RoundedCornerShape(6.dp))
|
||||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
// Compact: everything the glance-value needs on one line, nothing else.
|
||||||
"$w×$h@$hz ${s[0].roundToInt()} fps ${"%.1f".format(s[1])} Mb/s",
|
if (verbosity == StatsVerbosity.COMPACT) {
|
||||||
color = Color.White,
|
statLine(compactLine(s, latValid), Color.White)
|
||||||
fontFamily = FontFamily.Monospace,
|
return@Column
|
||||||
fontSize = 12.sp,
|
|
||||||
)
|
|
||||||
if (decoderLabel.isNotEmpty()) {
|
|
||||||
Text(
|
|
||||||
decoderLabel,
|
|
||||||
color = Color(0xFFB0D0FF),
|
|
||||||
fontFamily = FontFamily.Monospace,
|
|
||||||
fontSize = 12.sp,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
videoFeedLine(s)?.let { feed ->
|
|
||||||
Text(
|
statLine("$w×$h@$hz ${s[0].roundToInt()} fps ${"%.1f".format(s[1])} Mb/s", Color.White)
|
||||||
feed,
|
if (detailed && decoderLabel.isNotEmpty()) {
|
||||||
color = Color.White,
|
statLine(decoderLabel, Color(0xFFB0D0FF))
|
||||||
fontFamily = FontFamily.Monospace,
|
}
|
||||||
fontSize = 12.sp,
|
if (detailed) {
|
||||||
)
|
videoFeedLine(s)?.let { statLine(it, Color.White) }
|
||||||
}
|
}
|
||||||
if (latValid) {
|
if (latValid) {
|
||||||
|
// Display stage (s[22]–s[25], from OnFrameRendered): when a render timestamp landed
|
||||||
|
// this window the headline is the directly-measured capture→displayed pair and the
|
||||||
|
// equation gains its `display` term; otherwise (older lib / no callbacks) the endpoint
|
||||||
|
// honestly stays capture→decoded — the equation always tiles the headline interval.
|
||||||
|
val dispValid = s.size >= 26 && s[22] != 0.0
|
||||||
val tag = if (skew) "" else " (same-host clock)"
|
val tag = if (skew) "" else " (same-host clock)"
|
||||||
Text(
|
val (p50, p95, endpoint) = if (dispValid) {
|
||||||
"end-to-end ${"%.1f".format(s[2])} ms p50 · ${"%.1f".format(s[3])} p95 · capture→decoded$tag",
|
Triple(s[24], s[25], "capture→displayed")
|
||||||
color = Color.White,
|
} else {
|
||||||
fontFamily = FontFamily.Monospace,
|
Triple(s[2], s[3], "capture→decoded")
|
||||||
fontSize = 12.sp,
|
}
|
||||||
|
statLine(
|
||||||
|
"end-to-end ${"%.1f".format(p50)} ms p50 · ${"%.1f".format(p95)} p95 · $endpoint$tag",
|
||||||
|
Color.White,
|
||||||
)
|
)
|
||||||
if (s.size >= 16) {
|
if (detailed && s.size >= 16) {
|
||||||
// Phase-2 split (s[16]/s[17]): render `host + network` separately when the host
|
// Phase-2 split (s[16]/s[17]): render `host + network` separately when the host
|
||||||
// reported its share this window; otherwise the combined term (old host / no
|
// reported its share this window; otherwise the combined term (old host / no
|
||||||
// matched 0xCF timing).
|
// matched 0xCF timing).
|
||||||
val equation = if (s.size >= 18 && s[16] > 0) {
|
val hostTerms = if (s.size >= 18 && s[16] > 0) {
|
||||||
"= host ${"%.1f".format(s[16])} + network ${"%.1f".format(s[17])} + decode ${"%.1f".format(s[15])}"
|
"host ${"%.1f".format(s[16])} + network ${"%.1f".format(s[17])}"
|
||||||
} else {
|
} else {
|
||||||
"= host+network ${"%.1f".format(s[14])} + decode ${"%.1f".format(s[15])}"
|
"host+network ${"%.1f".format(s[14])}"
|
||||||
}
|
}
|
||||||
Text(
|
val displayTerm = if (dispValid) " + display ${"%.1f".format(s[23])}" else ""
|
||||||
equation,
|
statLine("= $hostTerms + decode ${"%.1f".format(s[15])}$displayTerm", Color.White)
|
||||||
color = Color.White,
|
|
||||||
fontFamily = FontFamily.Monospace,
|
|
||||||
fontSize = 12.sp,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
counterLine(s, lost)?.let { statLine(it, Color(0xFFFFB0B0)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One monospace HUD line — the shared type ramp so every tier's rows line up. */
|
||||||
|
@Composable
|
||||||
|
private fun statLine(text: String, color: Color) {
|
||||||
|
Text(text, color = color, fontFamily = FontFamily.Monospace, fontSize = 12.sp)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single [StatsVerbosity.COMPACT] line: `238 fps · 1.3 ms · 921 Mb/s`. The end-to-end p50 term
|
||||||
|
* is dropped when no in-range latency sample landed (`latValid` false), and a loss flag
|
||||||
|
* `· ⚠ lost {n}` is appended when the window (or, on an old lib, the session) dropped frames — the
|
||||||
|
* one reliability signal worth surfacing even at the tersest tier.
|
||||||
|
*/
|
||||||
|
private fun compactLine(s: DoubleArray, latValid: Boolean): String {
|
||||||
|
// Prefer the capture→displayed end-to-end (s[24]) when a render timestamp landed this window.
|
||||||
|
val e2eP50 = if (s.size >= 26 && s[22] != 0.0) s[24] else s[2]
|
||||||
|
val parts = buildList {
|
||||||
|
add("${s[0].roundToInt()} fps")
|
||||||
|
if (latValid) add("${"%.1f".format(e2eP50)} ms")
|
||||||
|
add("${s[1].roundToInt()} Mb/s")
|
||||||
|
}
|
||||||
|
val lostWindow = if (s.size >= 22) s[18].toLong() else s[9].toLong()
|
||||||
|
val suffix = if (lostWindow > 0) " ⚠ lost $lostWindow" else ""
|
||||||
|
return parts.joinToString(" · ") + suffix
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format the spec's line-4 counters from the per-window doubles at 18–21 —
|
||||||
|
* `lost {n} ({pct}%) · skipped {m} · FEC {k}`, each term only when nonzero, the whole line `null`
|
||||||
|
* when all are zero (spec: "only rendered when any value is nonzero"). `pct = lost/(frames+lost)`
|
||||||
|
* (the received count rides at index 21). A pre-window layout (< 22 doubles) falls back to the
|
||||||
|
* session-cumulative `lostTotal` so an older native lib still reports loss.
|
||||||
|
*/
|
||||||
|
private fun counterLine(s: DoubleArray, lostTotal: Long): String? {
|
||||||
|
if (s.size < 22) return if (lostTotal > 0) "lost $lostTotal" else null
|
||||||
|
val lost = s[18].toLong()
|
||||||
|
val skipped = s[19].toLong()
|
||||||
|
val fec = s[20].toLong()
|
||||||
|
val frames = s[21].toLong()
|
||||||
|
if (lost == 0L && skipped == 0L && fec == 0L) return null
|
||||||
|
return buildList {
|
||||||
if (lost > 0) {
|
if (lost > 0) {
|
||||||
Text(
|
val pct = 100.0 * lost / (frames + lost).coerceAtLeast(1)
|
||||||
"lost $lost",
|
add("lost $lost (${"%.1f".format(pct)}%)")
|
||||||
color = Color(0xFFFFB0B0),
|
|
||||||
fontFamily = FontFamily.Monospace,
|
|
||||||
fontSize = 12.sp,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if (skipped > 0) add("skipped $skipped")
|
||||||
|
if (fec > 0) add("FEC $fec")
|
||||||
|
}.joinToString(" · ")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -54,28 +54,32 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
Manifest.permission.RECORD_AUDIO,
|
Manifest.permission.RECORD_AUDIO,
|
||||||
) == PackageManager.PERMISSION_GRANTED
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
|
||||||
// Live decode stats for the HUD. `showStats` gates the whole pipeline: the native per-frame
|
// Live decode stats for the HUD. `statsOn` (verbosity != OFF) gates the whole native pipeline:
|
||||||
// sampling (nativeSetVideoStatsEnabled — hidden HUD costs one atomic load per frame) AND the
|
// the per-frame sampling (nativeSetVideoStatsEnabled — a hidden HUD costs one atomic load per
|
||||||
// 1 s poll loop, which only runs while the overlay is visible. Enabling resets the native
|
// frame) AND the 1 s poll loop, which only runs while the overlay is visible. Enabling resets
|
||||||
// window, so re-showing never renders stale data. A 3-finger tap toggles it live; the default
|
// the native window, so re-showing never renders stale data. A 3-finger tap cycles the
|
||||||
// comes from Settings.
|
// verbosity tier live (Off → Compact → Normal → Detailed → Off); the default comes from
|
||||||
|
// Settings. The tier only changes how many lines `StatsOverlay` draws — switching between the
|
||||||
|
// visible tiers keeps sampling running (the effect keys on `statsOn`, not the tier) so it never
|
||||||
|
// blanks the numbers for a poll interval.
|
||||||
val initialSettings = remember { SettingsStore(context).load() }
|
val initialSettings = remember { SettingsStore(context).load() }
|
||||||
var stats by remember { mutableStateOf<DoubleArray?>(null) }
|
var stats by remember { mutableStateOf<DoubleArray?>(null) }
|
||||||
var decoderLabel by remember { mutableStateOf("") }
|
var decoderLabel by remember { mutableStateOf("") }
|
||||||
var showStats by remember { mutableStateOf(initialSettings.statsHudEnabled) }
|
var statsVerbosity by remember { mutableStateOf(initialSettings.statsVerbosity) }
|
||||||
|
val statsOn = statsVerbosity != StatsVerbosity.OFF
|
||||||
// Touch model is fixed per session (re-keys the gesture handler below if it ever changes).
|
// Touch model is fixed per session (re-keys the gesture handler below if it ever changes).
|
||||||
val touchMode = initialSettings.touchMode
|
val touchMode = initialSettings.touchMode
|
||||||
// "Low-latency mode (experimental)" master toggle, resolved once for the session. Off (the
|
// "Low-latency mode" master toggle, resolved once for the session. On (the default) enables the
|
||||||
// default) runs the original decode pipeline; on enables the aggressive stack — decoder
|
// fast pipeline — decoder ranking + vendor keys + async loop (native side), HDMI ALLM below,
|
||||||
// ranking + vendor keys + async loop (native side), HDMI ALLM below, game-tagged audio, and
|
// game-tagged audio, and DSCP marking (applied earlier, at connect); off falls back to the
|
||||||
// DSCP marking (applied earlier, at connect).
|
// original synchronous decode pipeline as a per-device escape hatch.
|
||||||
val lowLatencyMode = initialSettings.lowLatencyMode
|
val lowLatencyMode = initialSettings.lowLatencyMode
|
||||||
// TV form factor (leanback): the decoder actively switches the HDMI output mode to the stream
|
// TV form factor (leanback): the decoder actively switches the HDMI output mode to the stream
|
||||||
// refresh; a phone/tablet gets the softer seamless frame-rate hint instead.
|
// refresh; a phone/tablet gets the softer seamless frame-rate hint instead.
|
||||||
val isTv = remember { context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK) }
|
val isTv = remember { context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK) }
|
||||||
LaunchedEffect(handle, showStats) {
|
LaunchedEffect(handle, statsOn) {
|
||||||
NativeBridge.nativeSetVideoStatsEnabled(handle, showStats)
|
NativeBridge.nativeSetVideoStatsEnabled(handle, statsOn)
|
||||||
if (showStats) {
|
if (statsOn) {
|
||||||
while (true) {
|
while (true) {
|
||||||
delay(1000)
|
delay(1000)
|
||||||
stats = NativeBridge.nativeVideoStats(handle)
|
stats = NativeBridge.nativeVideoStats(handle)
|
||||||
@@ -250,8 +254,10 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
)
|
)
|
||||||
// Live stats HUD (FPS / throughput / capture→client latency), drawn over the video but
|
// Live stats HUD (FPS / throughput / capture→client latency), drawn over the video but
|
||||||
// BEFORE the transparent gesture layer below, so it shows through and never eats touches.
|
// BEFORE the transparent gesture layer below, so it shows through and never eats touches.
|
||||||
if (showStats) {
|
if (statsOn) {
|
||||||
stats?.let { StatsOverlay(it, decoderLabel, Modifier.align(Alignment.TopStart).padding(12.dp)) }
|
stats?.let {
|
||||||
|
StatsOverlay(it, statsVerbosity, decoderLabel, Modifier.align(Alignment.TopStart).padding(12.dp))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Touch input per the Settings model: trackpad/direct-pointer mouse (the shared gesture
|
// Touch input per the Settings model: trackpad/direct-pointer mouse (the shared gesture
|
||||||
// vocabulary) or real multi-touch passthrough — see TouchInput.kt.
|
// vocabulary) or real multi-touch passthrough — see TouchInput.kt.
|
||||||
@@ -262,7 +268,7 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
else -> streamTouchInput(
|
else -> streamTouchInput(
|
||||||
handle,
|
handle,
|
||||||
trackpad = touchMode == TouchMode.TRACKPAD,
|
trackpad = touchMode == TouchMode.TRACKPAD,
|
||||||
onToggleStats = { showStats = !showStats },
|
onCycleStats = { statsVerbosity = statsVerbosity.next() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ private const val ACCEL_MAX = 3.0f
|
|||||||
*
|
*
|
||||||
* Both share the same gesture vocabulary: tap = left click; two-finger tap = right click;
|
* Both share the same gesture vocabulary: tap = left click; two-finger tap = right click;
|
||||||
* two-finger drag = scroll; tap-then-press-and-drag = left-drag (text selection / moving
|
* two-finger drag = scroll; tap-then-press-and-drag = left-drag (text selection / moving
|
||||||
* windows); three-finger tap = [onToggleStats] (the stats HUD).
|
* windows); three-finger tap = [onCycleStats] (cycle the stats-HUD verbosity tier).
|
||||||
*/
|
*/
|
||||||
/**
|
/**
|
||||||
* Real multi-touch passthrough ([TouchMode.TOUCH]): every finger forwards as a host touchscreen
|
* Real multi-touch passthrough ([TouchMode.TOUCH]): every finger forwards as a host touchscreen
|
||||||
@@ -93,7 +93,7 @@ internal suspend fun PointerInputScope.streamTouchPassthrough(handle: Long) {
|
|||||||
internal suspend fun PointerInputScope.streamTouchInput(
|
internal suspend fun PointerInputScope.streamTouchInput(
|
||||||
handle: Long,
|
handle: Long,
|
||||||
trackpad: Boolean,
|
trackpad: Boolean,
|
||||||
onToggleStats: () -> Unit,
|
onCycleStats: () -> Unit,
|
||||||
) {
|
) {
|
||||||
var lastTapUp = 0L
|
var lastTapUp = 0L
|
||||||
var lastTapX = 0f
|
var lastTapX = 0f
|
||||||
@@ -218,7 +218,7 @@ internal suspend fun PointerInputScope.streamTouchInput(
|
|||||||
NativeBridge.nativeSendPointerButton(handle, 1, false) // end the drag
|
NativeBridge.nativeSendPointerButton(handle, 1, false) // end the drag
|
||||||
} else if (!moved) {
|
} else if (!moved) {
|
||||||
when {
|
when {
|
||||||
maxFingers >= 3 -> onToggleStats() // in-stream HUD toggle
|
maxFingers >= 3 -> onCycleStats() // in-stream HUD verbosity cycle
|
||||||
maxFingers == 2 -> { // two-finger tap → right click
|
maxFingers == 2 -> { // two-finger tap → right click
|
||||||
NativeBridge.nativeSendPointerButton(handle, 3, true)
|
NativeBridge.nativeSendPointerButton(handle, 3, true)
|
||||||
NativeBridge.nativeSendPointerButton(handle, 3, false)
|
NativeBridge.nativeSendPointerButton(handle, 3, false)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package io.unom.punktfunk.components
|
|||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
@@ -56,6 +57,7 @@ fun HostCard(
|
|||||||
name: String,
|
name: String,
|
||||||
address: String,
|
address: String,
|
||||||
status: HostStatus,
|
status: HostStatus,
|
||||||
|
online: Boolean = false,
|
||||||
enabled: Boolean,
|
enabled: Boolean,
|
||||||
onConnect: () -> Unit,
|
onConnect: () -> Unit,
|
||||||
onForget: (() -> Unit)?,
|
onForget: (() -> Unit)?,
|
||||||
@@ -105,8 +107,14 @@ fun HostCard(
|
|||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
PresencePill(online)
|
||||||
StatusPill(status)
|
StatusPill(status)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (onForget != null || onEdit != null || onWake != null) {
|
if (onForget != null || onEdit != null || onWake != null) {
|
||||||
var menu by remember { mutableStateOf(false) }
|
var menu by remember { mutableStateOf(false) }
|
||||||
@@ -173,6 +181,27 @@ fun HostAvatar(name: String) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A small dot + label for live presence: green Online when the host advertises on mDNS OR answers
|
||||||
|
* the reachability probe (so a routed/VPN host that never advertises still reads Online), dimmed
|
||||||
|
* Offline otherwise.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun PresencePill(online: Boolean) {
|
||||||
|
val color =
|
||||||
|
if (online) MaterialTheme.colorScheme.primary
|
||||||
|
else MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Box(Modifier.size(8.dp).clip(CircleShape).background(color))
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text(
|
||||||
|
if (online) "Online" else "Offline",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = color,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** A small colored dot + label for the host's trust state. */
|
/** A small colored dot + label for the host's trust state. */
|
||||||
@Composable
|
@Composable
|
||||||
fun StatusPill(status: HostStatus) {
|
fun StatusPill(status: HostStatus) {
|
||||||
|
|||||||
@@ -58,7 +58,15 @@ class ScreenshotTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi") // landscape — the stream is immersive
|
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi") // landscape — the stream is immersive
|
||||||
fun stream() = shootRoot("stream") { StreamScene() }
|
fun stream() = shootRoot("stream") { StreamScene(io.unom.punktfunk.StatsVerbosity.DETAILED) }
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||||
|
fun streamCompact() = shootRoot("stream-compact") { StreamScene(io.unom.punktfunk.StatsVerbosity.COMPACT) }
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||||
|
fun streamNormal() = shootRoot("stream-normal") { StreamScene(io.unom.punktfunk.StatsVerbosity.NORMAL) }
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun trust() = shootScreen("trust") {
|
fun trust() = shootScreen("trust") {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import io.unom.punktfunk.Settings
|
|||||||
import io.unom.punktfunk.TouchMode
|
import io.unom.punktfunk.TouchMode
|
||||||
import io.unom.punktfunk.SettingsScreen
|
import io.unom.punktfunk.SettingsScreen
|
||||||
import io.unom.punktfunk.StatsOverlay
|
import io.unom.punktfunk.StatsOverlay
|
||||||
|
import io.unom.punktfunk.StatsVerbosity
|
||||||
import io.unom.punktfunk.components.HostCard
|
import io.unom.punktfunk.components.HostCard
|
||||||
import io.unom.punktfunk.components.SectionLabel
|
import io.unom.punktfunk.components.SectionLabel
|
||||||
import io.unom.punktfunk.models.HostStatus
|
import io.unom.punktfunk.models.HostStatus
|
||||||
@@ -109,7 +110,7 @@ internal fun SettingsScene() {
|
|||||||
compositor = 1,
|
compositor = 1,
|
||||||
gamepad = 2,
|
gamepad = 2,
|
||||||
micEnabled = true,
|
micEnabled = true,
|
||||||
statsHudEnabled = true,
|
statsVerbosity = StatsVerbosity.DETAILED,
|
||||||
touchMode = TouchMode.TRACKPAD,
|
touchMode = TouchMode.TRACKPAD,
|
||||||
),
|
),
|
||||||
onChange = {},
|
onChange = {},
|
||||||
@@ -177,9 +178,12 @@ internal fun PairDialog() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The live stats HUD (the real StatsOverlay) over a synthetic "streamed frame" gradient. */
|
/**
|
||||||
|
* The live stats HUD (the real StatsOverlay) over a synthetic "streamed frame" gradient, at the
|
||||||
|
* given [verbosity] tier — one scene per tier documents how far each tones the overlay down.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
internal fun StreamScene() {
|
internal fun StreamScene(verbosity: StatsVerbosity = StatsVerbosity.DETAILED) {
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -187,17 +191,25 @@ internal fun StreamScene() {
|
|||||||
Brush.linearGradient(listOf(Color(0xFF2A1E5C), Color(0xFF0E1B3D), Color(0xFF06122B))),
|
Brush.linearGradient(listOf(Color(0xFF2A1E5C), Color(0xFF0E1B3D), Color(0xFF06122B))),
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
// The full 18-double unified layout (design/stats-unification.md): [fps, mbps, e2eP50,
|
// The full 26-double unified layout (design/stats-unification.md): [fps, mbps, e2eP50,
|
||||||
// e2eP95, latValid, skew, w, h, hz, lost, bitDepth, colorPrimaries, colorTransfer,
|
// e2eP95, latValid, skew, w, h, hz, lostTotal, bitDepth, colorPrimaries, colorTransfer,
|
||||||
// chromaFormatIdc, hostNetP50, decodeP50, hostP50, netP50]. 10/9/16/1 = a 10-bit BT.2020
|
// chromaFormatIdc, hostNetP50, decodeP50, hostP50, netP50, lost, skipped, fec, frames,
|
||||||
// PQ (HDR) 4:2:0 feed so the HUD renders its video-feed line; the Phase-2 stage terms
|
// dispValid, displayP50, e2eDispP50, e2eDispP95].
|
||||||
// (host 0.6 + network 0.3 + decode 0.4) tile the 1.3 ms headline so it renders the full
|
// 10/9/16/1 = a 10-bit BT.2020 PQ (HDR) 4:2:0 feed so the DETAILED HUD renders its
|
||||||
// split equation, and the decoder label line shows the ranked low-latency decoder.
|
// video-feed line; the display stage is valid (dispValid 1) so the headline is the
|
||||||
|
// directly-measured capture→displayed pair (1.8/2.6) and the Phase-2 stage terms
|
||||||
|
// (host 0.6 + network 0.3 + decode 0.4 + display 0.5) tile it, rendering the full split
|
||||||
|
// equation; the decoder label shows the ranked low-latency decoder. Light per-window loss
|
||||||
|
// (lost 2 · skipped 1 · FEC 5 of 238) so the reliability line (NORMAL/DETAILED) and the
|
||||||
|
// compact loss flag both render.
|
||||||
StatsOverlay(
|
StatsOverlay(
|
||||||
doubleArrayOf(
|
doubleArrayOf(
|
||||||
238.0, 921.4, 1.3, 2.1, 1.0, 1.0, 5120.0, 1440.0, 240.0, 0.0,
|
238.0, 921.4, 1.3, 2.1, 1.0, 1.0, 5120.0, 1440.0, 240.0, 2.0,
|
||||||
10.0, 9.0, 16.0, 1.0, 0.9, 0.4, 0.6, 0.3,
|
10.0, 9.0, 16.0, 1.0, 0.9, 0.4, 0.6, 0.3,
|
||||||
|
2.0, 1.0, 5.0, 238.0,
|
||||||
|
1.0, 0.5, 1.8, 2.6,
|
||||||
),
|
),
|
||||||
|
verbosity = verbosity,
|
||||||
decoderLabel = "c2.qti.hevc.decoder · low-latency",
|
decoderLabel = "c2.qti.hevc.decoder · low-latency",
|
||||||
modifier = Modifier.align(Alignment.TopStart).padding(12.dp),
|
modifier = Modifier.align(Alignment.TopStart).padding(12.dp),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// here (version + apply false) so modules can apply it version-less; its version pins the build's
|
// here (version + apply false) so modules can apply it version-less; its version pins the build's
|
||||||
// Kotlin (compose-compiler and Kotlin release in lockstep), keeping them matched.
|
// Kotlin (compose-compiler and Kotlin release in lockstep), keeping them matched.
|
||||||
// Toolchain: AGP 9.2.0 · Gradle 9.4.1 · Kotlin/Compose-compiler 2.3.21 · JDK 21 · Compose BOM
|
// Toolchain: AGP 9.2.0 · Gradle 9.4.1 · Kotlin/Compose-compiler 2.3.21 · JDK 21 · Compose BOM
|
||||||
// 2026.05.01 · compileSdk 37 · targetSdk 36 · minSdk 31.
|
// 2026.05.01 · compileSdk 37 · targetSdk 37 · minSdk 28.
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application") version "9.2.1" apply false
|
id("com.android.application") version "9.2.1" apply false
|
||||||
id("com.android.library") version "9.2.1" apply false
|
id("com.android.library") version "9.2.1" apply false
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ object NativeBridge {
|
|||||||
gamepadPref: Int,
|
gamepadPref: Int,
|
||||||
hdrEnabled: Boolean,
|
hdrEnabled: Boolean,
|
||||||
audioChannels: Int,
|
audioChannels: Int,
|
||||||
|
/** `quic::CODEC_*` bitfield of codecs this device decodes ([VideoDecoders.decodableCodecBits]);
|
||||||
|
* `0` falls back to H.264|HEVC. The host resolves the emitted codec from this ∩ its GPU. */
|
||||||
|
videoCodecs: Int,
|
||||||
/** Preferred video codec as a `quic::CODEC_*` bit (`0` = auto). Soft — the host falls back. */
|
/** Preferred video codec as a `quic::CODEC_*` bit (`0` = auto). Soft — the host falls back. */
|
||||||
preferredCodec: Int,
|
preferredCodec: Int,
|
||||||
timeoutMs: Int,
|
timeoutMs: Int,
|
||||||
@@ -123,6 +126,15 @@ object NativeBridge {
|
|||||||
*/
|
*/
|
||||||
external fun nativeWakeOnLan(macsCsv: String, lastIp: String): Boolean
|
external fun nativeWakeOnLan(macsCsv: String, lastIp: String): Boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bounded, trust-agnostic QUIC reachability probe to [host]:[port] (mDNS-independent): true if
|
||||||
|
* the host completed the handshake within [timeoutMs]. No pin/identity presented. Lets a saved
|
||||||
|
* host reached over a routed network (Tailscale/VPN/another subnet) — which never advertises on
|
||||||
|
* mDNS — still show as online. Blocking (builds its own runtime) — run on a background
|
||||||
|
* dispatcher, never the main thread.
|
||||||
|
*/
|
||||||
|
external fun nativeProbe(host: String, port: Int, timeoutMs: Int): Boolean
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply the user's "Low-latency mode (experimental)" toggle to the process-wide transport
|
* Apply the user's "Low-latency mode (experimental)" toggle to the process-wide transport
|
||||||
* defaults — today just DSCP/QoS marking on the media sockets. Must be called BEFORE
|
* defaults — today just DSCP/QoS marking on the media sockets. Must be called BEFORE
|
||||||
@@ -171,16 +183,22 @@ object NativeBridge {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Drain ~1 s of live decode stats for the on-stream HUD, or `null` when no decode thread runs.
|
* Drain ~1 s of live decode stats for the on-stream HUD, or `null` when no decode thread runs.
|
||||||
* Returns 18 doubles (unified stats spec, `design/stats-unification.md`):
|
* Returns 26 doubles (unified stats spec, `design/stats-unification.md`):
|
||||||
* `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skewCorrected, width, height, refreshHz, framesLost,
|
* `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skewCorrected, width, height, refreshHz, framesLost,
|
||||||
* bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms,
|
* bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms,
|
||||||
* netP50Ms]`
|
* netP50Ms, lostWindow, skippedWindow, fecWindow, framesWindow, dispValid, displayP50Ms,
|
||||||
* (the two flags are 1.0/0.0; indexes 2/3 are the end-to-end capture→decoded headline; 10–13
|
* e2eDispP50Ms, e2eDispP95Ms]`
|
||||||
|
* (the flags are 1.0/0.0; indexes 2/3 are the end-to-end capture→decoded headline; 10–13
|
||||||
* describe the negotiated video feed — bit depth 8/10, CICP primaries/transfer, and the HEVC
|
* describe the negotiated video feed — bit depth 8/10, CICP primaries/transfer, and the HEVC
|
||||||
* chroma_format_idc 1=4:2:0 / 3=4:4:4; 14/15 are the stage p50s tiling the headline —
|
* chroma_format_idc 1=4:2:0 / 3=4:4:4; 14/15 are the stage p50s tiling the headline —
|
||||||
* `host+network` = capture→received, `decode` = received→decoded; 16/17 split the
|
* `host+network` = capture→received, `decode` = received→decoded; 16/17 split the
|
||||||
* `host+network` term via the host's per-AU 0xCF timings — `host` = the host's capture→sent,
|
* `host+network` term via the host's per-AU 0xCF timings — `host` = the host's capture→sent,
|
||||||
* `network` = the remainder — both 0.0 when no timing matched this window, i.e. an old host).
|
* `network` = the remainder — both 0.0 when no timing matched this window, i.e. an old host;
|
||||||
|
* 18–21 are the per-window reliability counters — lost/skipped/FEC/received; 22–25 are the
|
||||||
|
* `display` stage from the OnFrameRendered render timestamps — when `dispValid` is 1.0 the
|
||||||
|
* headline becomes the directly-measured capture→displayed pair at 24/25, tiled by
|
||||||
|
* `host+network` + `decode` + `display` (23), and when 0.0 the HUD falls back to the
|
||||||
|
* capture→decoded headline at 2/3 without the `display` term).
|
||||||
* Poll ~1 Hz; each call resets the measurement window.
|
* Poll ~1 Hz; each call resets the measurement window.
|
||||||
*/
|
*/
|
||||||
external fun nativeVideoStats(handle: Long): DoubleArray?
|
external fun nativeVideoStats(handle: Long): DoubleArray?
|
||||||
|
|||||||
@@ -44,6 +44,15 @@ object VideoDecoders {
|
|||||||
* Pick the best decoder for [mime] (`"video/hevc"` / `"video/avc"` / `"video/av01"`), or `null`
|
* Pick the best decoder for [mime] (`"video/hevc"` / `"video/avc"` / `"video/av01"`), or `null`
|
||||||
* to let the platform resolve its default. Enumerates once — call at stream start.
|
* to let the platform resolve its default. Enumerates once — call at stream start.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* The `quic::CODEC_*` bitfield of codecs this device can decode, advertised in the Hello so the
|
||||||
|
* host never emits a codec the decode loop can't open: H.264 (1) and HEVC (2) always (universal
|
||||||
|
* on Android hardware), plus AV1 (4) only when [pickDecoder] finds a real (hardware, non-blocked)
|
||||||
|
* `video/av01` decoder. Enumerates `MediaCodecList` — call at connect time, not per frame.
|
||||||
|
*/
|
||||||
|
fun decodableCodecBits(): Int =
|
||||||
|
1 or 2 or (if (pickDecoder("video/av01") != null) 4 else 0)
|
||||||
|
|
||||||
fun pickDecoder(mime: String): DecoderChoice? {
|
fun pickDecoder(mime: String): DecoderChoice? {
|
||||||
if (mime.isEmpty()) return null
|
if (mime.isEmpty()) return null
|
||||||
val infos = runCatching { MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos }
|
val infos = runCatching { MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos }
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ punktfunk-core = { path = "../../../crates/punktfunk-core", features = ["quic"]
|
|||||||
jni = "0.21"
|
jni = "0.21"
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
# LAN host discovery: browse the host's `_punktfunk._udp` mDNS advert — the SAME crate + service the
|
# LAN host discovery: browse the host's `_punktfunk._udp` mDNS advert — the SAME crate + service the
|
||||||
# Linux/Windows clients use (`clients/linux/src/discovery.rs`), replacing Android's per-OEM
|
# Linux/Windows clients use (`crates/pf-client-core/src/discovery.rs`), replacing Android's per-OEM
|
||||||
# `NsdManager` system daemon with one tested browse path. Pure Rust (socket2/if-addrs/mio), so it
|
# `NsdManager` system daemon with one tested browse path. Pure Rust (socket2/if-addrs/mio), so it
|
||||||
# cross-compiles to the Android targets AND builds on the host (the JNI seam links into
|
# cross-compiles to the Android targets AND builds on the host (the JNI seam links into
|
||||||
# `cargo build --workspace`). Kotlin keeps only the Wi-Fi `MulticastLock` + permission UX.
|
# `cargo build --workspace`). Kotlin keeps only the Wi-Fi `MulticastLock` + permission UX.
|
||||||
@@ -47,6 +47,12 @@ tracing = { version = "0.1", default-features = false, features = ["std", "log"]
|
|||||||
# (ANativeWindow_setFrameRate) is dlsym-resolved at runtime (see decode::try_set_frame_rate), not
|
# (ANativeWindow_setFrameRate) is dlsym-resolved at runtime (see decode::try_set_frame_rate), not
|
||||||
# linked, so the .so still loads on API 28/29.
|
# linked, so the .so still loads on API 28/29.
|
||||||
ndk = { version = "0.9", features = ["media", "audio", "nativewindow", "api-level-28"] }
|
ndk = { version = "0.9", features = ["media", "audio", "nativewindow", "api-level-28"] }
|
||||||
|
# Raw FFI for the one AMediaCodec entry point the `ndk` wrapper lacks:
|
||||||
|
# `AMediaCodec_setOnFrameRenderedCallback` (API 26, under the minSdk-28 floor ⇒ hard-linked) — the
|
||||||
|
# per-frame render-timestamp callback behind the HUD's `display` stage (see decode::DisplayTracker).
|
||||||
|
# Reaching it needs the codec's raw pointer, which is why the workspace pins `ndk` to the vendored
|
||||||
|
# copy whose only patch makes `MediaCodec::as_ptr` public (vendor/ndk, wired in the root Cargo.toml).
|
||||||
|
ndk-sys = { version = "0.6", features = ["media"] }
|
||||||
# setpriority/gettid to raise the decode thread toward URGENT_DISPLAY (see decode::boost_thread_priority).
|
# setpriority/gettid to raise the decode thread toward URGENT_DISPLAY (see decode::boost_thread_priority).
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
# Opus decode for the host→client audio plane (0xC9: 48 kHz stereo, 5 ms frames). Same crate the
|
# Opus decode for the host→client audio plane (0xC9: 48 kHz stereo, 5 ms frames). Same crate the
|
||||||
|
|||||||
@@ -36,11 +36,17 @@ const IN_FLIGHT_CAP: usize = 64;
|
|||||||
/// this deep is a lost datagram (or an old host that never sends any) and gets evicted.
|
/// this deep is a lost datagram (or an old host that never sends any) and gets evicted.
|
||||||
const PENDING_SPLIT_CAP: usize = 256;
|
const PENDING_SPLIT_CAP: usize = 256;
|
||||||
|
|
||||||
|
/// Cap on rendered frames parked in [`DisplayTracker`] awaiting their `OnFrameRendered` render
|
||||||
|
/// timestamp: the callback trails its release by at most a vsync or two, so anything this deep
|
||||||
|
/// means the platform stopped delivering render callbacks (allowed under load, per the docs) and
|
||||||
|
/// gets evicted.
|
||||||
|
const RENDERED_CAP: usize = 64;
|
||||||
|
|
||||||
/// Whether low-latency mode uses the event-driven async decode loop (default) or the synchronous
|
/// Whether low-latency mode uses the event-driven async decode loop (default) or the synchronous
|
||||||
/// poll loop. Flip to `false` to A/B the two on the HUD (`design/…`); the async loop presents a
|
/// poll loop. Flip to `false` to A/B the two on the HUD (`design/…`); the async loop presents a
|
||||||
/// decoded frame the instant it's ready instead of waiting out a poll interval. Only consulted when
|
/// decoded frame the instant it's ready instead of waiting out a poll interval. Only consulted when
|
||||||
/// the user's "Low-latency mode (experimental)" toggle is ON — off, the sync loop always runs (the
|
/// the user's "Low-latency mode" toggle is ON (now the default) — off, the sync loop always runs (the
|
||||||
/// original pipeline).
|
/// original pipeline, kept as the per-device escape hatch).
|
||||||
const USE_ASYNC_DECODE: bool = true;
|
const USE_ASYNC_DECODE: bool = true;
|
||||||
|
|
||||||
/// Per-session decode configuration, resolved by the JNI layer (`nativeStartVideo`) and passed to
|
/// Per-session decode configuration, resolved by the JNI layer (`nativeStartVideo`) and passed to
|
||||||
@@ -52,10 +58,10 @@ pub(crate) struct DecodeOptions {
|
|||||||
/// Whether Kotlin found the chosen decoder advertises `FEATURE_LowLatency` (queryable only via
|
/// Whether Kotlin found the chosen decoder advertises `FEATURE_LowLatency` (queryable only via
|
||||||
/// the Java `CodecCapabilities` API) — surfaced on the HUD next to the decoder name.
|
/// the Java `CodecCapabilities` API) — surfaced on the HUD next to the decoder name.
|
||||||
pub ll_feature: bool,
|
pub ll_feature: bool,
|
||||||
/// The user's "Low-latency mode (experimental)" master toggle. On ⇒ the full overhaul: async
|
/// The user's "Low-latency mode" master toggle. On (default) ⇒ the full fast pipeline: async
|
||||||
/// decode loop, per-SoC vendor keys, pipeline thread boosts, ADPF max-performance, forced TV
|
/// decode loop, per-SoC vendor keys, pipeline thread boosts, ADPF max-performance, forced TV
|
||||||
/// mode switch. Off (default) ⇒ the original pre-overhaul pipeline, kept as the known-good
|
/// mode switch. Off ⇒ the original synchronous pre-overhaul pipeline, kept as the per-device
|
||||||
/// baseline while the overhaul is experimental.
|
/// escape hatch.
|
||||||
pub low_latency_mode: bool,
|
pub low_latency_mode: bool,
|
||||||
/// TV form factor (Kotlin's `UiModeManager`): actively drive the HDMI output into the stream's
|
/// TV form factor (Kotlin's `UiModeManager`): actively drive the HDMI output into the stream's
|
||||||
/// refresh mode, vs. the softer seamless hint on a phone/tablet.
|
/// refresh mode, vs. the softer seamless hint on a phone/tablet.
|
||||||
@@ -208,6 +214,12 @@ fn run_sync(
|
|||||||
// host-minus-client clock offset (0 if the host didn't answer the skew handshake — then the
|
// host-minus-client clock offset (0 if the host didn't answer the skew handshake — then the
|
||||||
// HUD flags it "(same-host clock)").
|
// HUD flags it "(same-host clock)").
|
||||||
let clock_offset = client.clock_offset_ns;
|
let clock_offset = client.clock_offset_ns;
|
||||||
|
// Display stage (spec `display` + the capture→displayed headline): frames released with
|
||||||
|
// render = true are parked in the tracker; the OnFrameRendered callback pairs them with
|
||||||
|
// SurfaceFlinger's render timestamp. `render_cb` is the callback's leaked Arc refcount,
|
||||||
|
// reclaimed after the codec is dropped below.
|
||||||
|
let tracker = DisplayTracker::new(stats.clone(), clock_offset);
|
||||||
|
let render_cb = install_render_callback(&codec, &tracker);
|
||||||
// HUD stage split: receipt timestamps keyed by the pts we queue into the codec, so the decoded
|
// HUD stage split: receipt timestamps keyed by the pts we queue into the codec, so the decoded
|
||||||
// point (output-buffer dequeue — MediaCodec round-trips presentationTimeUs) can be paired back
|
// point (output-buffer dequeue — MediaCodec round-trips presentationTimeUs) can be paired back
|
||||||
// to its receipt for the `decode` stage. Only fed while the HUD is visible.
|
// to its receipt for the `decode` stage. Only fed while the HUD is visible.
|
||||||
@@ -309,6 +321,7 @@ fn run_sync(
|
|||||||
&stats,
|
&stats,
|
||||||
&mut in_flight,
|
&mut in_flight,
|
||||||
clock_offset,
|
clock_offset,
|
||||||
|
&tracker,
|
||||||
);
|
);
|
||||||
rendered += r;
|
rendered += r;
|
||||||
discarded += d;
|
discarded += d;
|
||||||
@@ -367,6 +380,11 @@ fn run_sync(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let _ = codec.stop();
|
let _ = codec.stop();
|
||||||
|
drop(codec); // AMediaCodec_delete — after this no render callback can fire
|
||||||
|
if let Some(ud) = render_cb {
|
||||||
|
// SAFETY: the codec was dropped above; this registration's single reclaim.
|
||||||
|
unsafe { release_render_callback(ud) };
|
||||||
|
}
|
||||||
log::info!("decode: stopped (fed={fed} rendered={rendered} discarded={discarded})");
|
log::info!("decode: stopped (fed={fed} rendered={rendered} discarded={discarded})");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -380,6 +398,145 @@ fn now_realtime_ns() -> i128 {
|
|||||||
.unwrap_or(0)
|
.unwrap_or(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `CLOCK_MONOTONIC` now in nanoseconds — the base of the `systemNano` render timestamp the
|
||||||
|
/// `OnFrameRendered` callback reports (Android's `System.nanoTime`), read only to re-base that
|
||||||
|
/// stamp onto `CLOCK_REALTIME` (see [`on_frame_rendered`]).
|
||||||
|
fn now_monotonic_ns() -> i128 {
|
||||||
|
let mut ts = libc::timespec {
|
||||||
|
tv_sec: 0,
|
||||||
|
tv_nsec: 0,
|
||||||
|
};
|
||||||
|
// SAFETY: `clock_gettime` with a valid out-pointer is an always-safe syscall.
|
||||||
|
unsafe { libc::clock_gettime(libc::CLOCK_MONOTONIC, &mut ts) };
|
||||||
|
ts.tv_sec as i128 * 1_000_000_000 + ts.tv_nsec as i128
|
||||||
|
}
|
||||||
|
|
||||||
|
/// State shared between the decode loop and the `AMediaCodec` `OnFrameRendered` callback (which
|
||||||
|
/// fires on a codec-internal thread): rendered frames awaiting their render timestamp, so the HUD
|
||||||
|
/// gets the spec's `display` stage (decoded→displayed) and the `capture→displayed` end-to-end
|
||||||
|
/// headline (`design/stats-unification.md` — this replaces Android's v1 `capture→decoded`
|
||||||
|
/// endpoint whenever the platform delivers render callbacks).
|
||||||
|
struct DisplayTracker {
|
||||||
|
stats: Arc<crate::stats::VideoStats>,
|
||||||
|
/// Host-minus-client clock offset (ns) for the skew-corrected end-to-end sample.
|
||||||
|
clock_offset: i64,
|
||||||
|
/// `(pts_us, decoded_real_ns)` of frames released with `render = true`, in release order,
|
||||||
|
/// awaiting their callback. Pushes are HUD-gated by the caller, so this stays empty (and the
|
||||||
|
/// callback early-outs) while the overlay is hidden.
|
||||||
|
rendered: Mutex<VecDeque<(u64, i128)>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DisplayTracker {
|
||||||
|
fn new(stats: Arc<crate::stats::VideoStats>, clock_offset: i64) -> Arc<DisplayTracker> {
|
||||||
|
Arc::new(DisplayTracker {
|
||||||
|
stats,
|
||||||
|
clock_offset,
|
||||||
|
rendered: Mutex::new(VecDeque::new()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Park one just-rendered frame's `(pts, decoded stamp)` for the render callback to pair.
|
||||||
|
/// Caller gates on the HUD being visible.
|
||||||
|
fn note_rendered(&self, pts_us: u64, decoded_ns: i128) {
|
||||||
|
let mut g = self
|
||||||
|
.rendered
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
g.push_back((pts_us, decoded_ns));
|
||||||
|
if g.len() > RENDERED_CAP {
|
||||||
|
g.pop_front(); // render callbacks stopped coming (allowed under load) — evict
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register [`on_frame_rendered`] on the codec (`AMediaCodec_setOnFrameRenderedCallback`, API 26 —
|
||||||
|
/// under the minSdk-28 floor, so hard-linked via `ndk-sys`; the `ndk` wrapper has no binding, which
|
||||||
|
/// is what the vendored crate's public `as_ptr` patch is for). Returns the userdata pointer holding
|
||||||
|
/// a leaked `Arc<DisplayTracker>` refcount; the caller MUST reclaim it with
|
||||||
|
/// [`release_render_callback`] AFTER dropping the codec (`AMediaCodec_delete` is what guarantees no
|
||||||
|
/// further callback can fire). `None` (nothing to reclaim) if the platform refused — the HUD then
|
||||||
|
/// simply has no `display` stage, exactly the pre-callback behaviour.
|
||||||
|
fn install_render_callback(
|
||||||
|
codec: &MediaCodec,
|
||||||
|
tracker: &Arc<DisplayTracker>,
|
||||||
|
) -> Option<*const DisplayTracker> {
|
||||||
|
let ud = Arc::into_raw(tracker.clone());
|
||||||
|
// SAFETY: `codec.as_ptr()` is the live codec this thread owns; `ud` outlives the registration
|
||||||
|
// (reclaimed only after the codec is deleted, per this function's contract).
|
||||||
|
let status = unsafe {
|
||||||
|
ndk_sys::AMediaCodec_setOnFrameRenderedCallback(
|
||||||
|
codec.as_ptr(),
|
||||||
|
Some(on_frame_rendered),
|
||||||
|
ud as *mut c_void,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if status == ndk_sys::media_status_t::AMEDIA_OK {
|
||||||
|
Some(ud)
|
||||||
|
} else {
|
||||||
|
log::warn!("decode: setOnFrameRenderedCallback failed ({status:?}) — no display stage");
|
||||||
|
// SAFETY: registration failed, so the codec never took the reference — reclaim it now.
|
||||||
|
unsafe { drop(Arc::from_raw(ud)) };
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reclaim [`install_render_callback`]'s leaked `Arc` refcount.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// Call exactly once, and only after the codec the callback was registered on has been dropped —
|
||||||
|
/// deleting the codec stops its internal threads, so no callback can still be running (or run
|
||||||
|
/// later) against this pointer.
|
||||||
|
unsafe fn release_render_callback(ud: *const DisplayTracker) {
|
||||||
|
drop(Arc::from_raw(ud));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `AMediaCodecOnFrameRendered` trampoline: fires (possibly batched) on a codec-internal
|
||||||
|
/// thread once per output frame actually placed on the output surface, with SurfaceFlinger's
|
||||||
|
/// render timestamp. That timestamp (`system_nano`) is on `CLOCK_MONOTONIC`, so it is re-based
|
||||||
|
/// onto `CLOCK_REALTIME` here — against monotonic-now at callback time, which also cancels any lag
|
||||||
|
/// between the frame rendering and the (batchable) callback delivery — to subtract against the
|
||||||
|
/// receipt/decode stamps and the host capture pts. Records the HUD's `displayed` point:
|
||||||
|
/// `end-to-end` = capture→displayed (skew-corrected) and `display` = decoded→displayed
|
||||||
|
/// (single-clock local). Panic-free by construction (poison-proof lock, saturating math) — an
|
||||||
|
/// unwind out of an `extern "C"` fn would abort the process.
|
||||||
|
unsafe extern "C" fn on_frame_rendered(
|
||||||
|
_codec: *mut ndk_sys::AMediaCodec,
|
||||||
|
userdata: *mut c_void,
|
||||||
|
media_time_us: i64,
|
||||||
|
system_nano: i64,
|
||||||
|
) {
|
||||||
|
let t = &*(userdata as *const DisplayTracker);
|
||||||
|
if !t.stats.enabled() {
|
||||||
|
return; // HUD hidden — the ring is empty too (pushes are caller-gated)
|
||||||
|
}
|
||||||
|
let displayed_ns = now_realtime_ns() - (now_monotonic_ns() - system_nano as i128);
|
||||||
|
let pts_us = media_time_us.max(0) as u64;
|
||||||
|
// Pair the frame back to its release record, evicting older entries (their callbacks were
|
||||||
|
// dropped by the platform, or the entry predates a HUD toggle) — same monotonic-eviction
|
||||||
|
// discipline as `note_decoded_pts`.
|
||||||
|
let mut decoded_ns = None;
|
||||||
|
{
|
||||||
|
let mut g = t
|
||||||
|
.rendered
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
while let Some(&(p, d)) = g.front() {
|
||||||
|
if p > pts_us {
|
||||||
|
break; // future frame — leave it for its own callback
|
||||||
|
}
|
||||||
|
g.pop_front();
|
||||||
|
if p == pts_us {
|
||||||
|
decoded_ns = Some(d);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let e2e_ns = displayed_ns + t.clock_offset as i128 - pts_us as i128 * 1000;
|
||||||
|
let e2e_us = (e2e_ns > 0 && e2e_ns < 10_000_000_000).then_some((e2e_ns / 1000) as u64);
|
||||||
|
let display_us = decoded_ns.map(|d| ((displayed_ns - d).max(0) / 1000) as u64);
|
||||||
|
t.stats.note_displayed(e2e_us, display_us);
|
||||||
|
}
|
||||||
|
|
||||||
/// The MediaCodec MIME for the codec the host resolved (`Welcome.codec`). Shared by the decode
|
/// The MediaCodec MIME for the codec the host resolved (`Welcome.codec`). Shared by the decode
|
||||||
/// thread and `nativeVideoMime` (which tells Kotlin what to rank decoders for). AV1 uses the
|
/// thread and `nativeVideoMime` (which tells Kotlin what to rank decoders for). AV1 uses the
|
||||||
/// AOSP `video/av01` type; anything not H.264/AV1 is treated as HEVC (every pre-negotiation host
|
/// AOSP `video/av01` type; anything not H.264/AV1 is treated as HEVC (every pre-negotiation host
|
||||||
@@ -409,10 +566,10 @@ fn create_codec(mime: &str, preferred: Option<&str>) -> Option<MediaCodec> {
|
|||||||
|
|
||||||
/// Apply the low-latency MediaFormat keys for `codec_name`.
|
/// Apply the low-latency MediaFormat keys for `codec_name`.
|
||||||
///
|
///
|
||||||
/// `aggressive` = the "Low-latency mode (experimental)" master toggle. **Off** (default) ⇒ the
|
/// `aggressive` = the "Low-latency mode" master toggle. **Off** ⇒ the pre-overhaul key set,
|
||||||
/// pre-overhaul key set, byte-for-byte — the standard `low-latency` key, the blind Qualcomm vendor
|
/// byte-for-byte — the standard `low-latency` key, the blind Qualcomm vendor twin, `priority = 0` AND
|
||||||
/// twin, `priority = 0` AND `operating-rate = MAX` set together — kept as the known-good baseline
|
/// `operating-rate = MAX` set together — kept as the per-device escape hatch (the profile every device
|
||||||
/// (the profile every device streamed with before the overhaul). **On** ⇒ the Moonlight-parity
|
/// streamed with before the overhaul). **On** (default) ⇒ the Moonlight-parity
|
||||||
/// profile: MediaTek's `vdec-lowlatency` (unconditionally — ignored off MediaTek), the per-SoC
|
/// profile: MediaTek's `vdec-lowlatency` (unconditionally — ignored off MediaTek), the per-SoC
|
||||||
/// vendor extension keys (gated on the decoder-name prefix the way Moonlight-Android does, since a
|
/// vendor extension keys (gated on the decoder-name prefix the way Moonlight-Android does, since a
|
||||||
/// key one vendor honours is meaningless on another), and one *mutually exclusive* clock hint.
|
/// key one vendor honours is meaningless on another), and one *mutually exclusive* clock hint.
|
||||||
@@ -493,10 +650,14 @@ fn decoder_supports_max_operating_rate(name_lower: &str) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// One decoded output buffer ready to release: its codec buffer index + the pts the codec echoed
|
/// One decoded output buffer ready to release: its codec buffer index + the pts the codec echoed
|
||||||
/// (from the output callback's `BufferInfo`), used to pair the `decode` HUD stat.
|
/// (from the output callback's `BufferInfo`), used to pair the `decode` HUD stat, and the
|
||||||
|
/// wall-clock instant the output callback fired — the spec's `decoded` point ("decoder output
|
||||||
|
/// frame available"), stamped at the callback so the event-channel hop + coalescing wait in the
|
||||||
|
/// loop never inflates the decode stage.
|
||||||
struct OutputReady {
|
struct OutputReady {
|
||||||
index: usize,
|
index: usize,
|
||||||
pts_us: u64,
|
pts_us: u64,
|
||||||
|
decoded_ns: i128,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Events the async decode loop reacts to. The codec's async-notify callbacks (which run on its
|
/// Events the async decode loop reacts to. The codec's async-notify callbacks (which run on its
|
||||||
@@ -507,8 +668,12 @@ enum DecodeEvent {
|
|||||||
Au(Frame),
|
Au(Frame),
|
||||||
/// An input buffer slot freed (index) — we can queue an AU into it.
|
/// An input buffer slot freed (index) — we can queue an AU into it.
|
||||||
InputAvailable(usize),
|
InputAvailable(usize),
|
||||||
/// A decoded frame is ready (buffer index + echoed pts).
|
/// A decoded frame is ready (buffer index + echoed pts + the callback-time `decoded` stamp).
|
||||||
OutputAvailable { index: usize, pts_us: u64 },
|
OutputAvailable {
|
||||||
|
index: usize,
|
||||||
|
pts_us: u64,
|
||||||
|
decoded_ns: i128,
|
||||||
|
},
|
||||||
/// The output format changed — re-check the stream's colour signalling (HDR DataSpace).
|
/// The output format changed — re-check the stream's colour signalling (HDR DataSpace).
|
||||||
FormatChanged,
|
FormatChanged,
|
||||||
/// The codec reported an error; `fatal` when neither recoverable nor transient.
|
/// The codec reported an error; `fatal` when neither recoverable nor transient.
|
||||||
@@ -569,6 +734,10 @@ fn run_async(
|
|||||||
let _ = out_tx.send(DecodeEvent::OutputAvailable {
|
let _ = out_tx.send(DecodeEvent::OutputAvailable {
|
||||||
index: idx,
|
index: idx,
|
||||||
pts_us: info.presentation_time_us().max(0) as u64,
|
pts_us: info.presentation_time_us().max(0) as u64,
|
||||||
|
// The `decoded` HUD point: stamp HERE, on the codec's looper thread, so the
|
||||||
|
// decode stage ends when the frame actually became available — not after the
|
||||||
|
// channel hop + whatever work the loop coalesces in front of presenting it.
|
||||||
|
decoded_ns: now_realtime_ns(),
|
||||||
});
|
});
|
||||||
})),
|
})),
|
||||||
on_format_changed: Some(Box::new(move |_fmt| {
|
on_format_changed: Some(Box::new(move |_fmt| {
|
||||||
@@ -637,6 +806,12 @@ fn run_async(
|
|||||||
// HUD is visible.
|
// HUD is visible.
|
||||||
let clock_offset = client.clock_offset_ns;
|
let clock_offset = client.clock_offset_ns;
|
||||||
let in_flight = Arc::new(Mutex::new(VecDeque::<(u64, i128)>::new()));
|
let in_flight = Arc::new(Mutex::new(VecDeque::<(u64, i128)>::new()));
|
||||||
|
// Display stage (spec `display` + the capture→displayed headline): the rendered frame is
|
||||||
|
// parked in the tracker at release; the OnFrameRendered callback pairs it with
|
||||||
|
// SurfaceFlinger's render timestamp. `render_cb` is the callback's leaked Arc refcount,
|
||||||
|
// reclaimed after the codec is dropped below.
|
||||||
|
let tracker = DisplayTracker::new(stats.clone(), clock_offset);
|
||||||
|
let render_cb = install_render_callback(&codec, &tracker);
|
||||||
|
|
||||||
// Feeder thread: block on the network so this loop doesn't (an AU's arrival becomes an event that
|
// Feeder thread: block on the network so this loop doesn't (an AU's arrival becomes an event that
|
||||||
// wakes us immediately, with no input-side poll latency). It also records the `received` HUD stat.
|
// wakes us immediately, with no input-side poll latency). It also records the `received` HUD stat.
|
||||||
@@ -697,29 +872,30 @@ fn run_async(
|
|||||||
};
|
};
|
||||||
let work_t0 = Instant::now();
|
let work_t0 = Instant::now();
|
||||||
let mut fmt_dirty = false;
|
let mut fmt_dirty = false;
|
||||||
let mut au_dropped = false;
|
let mut aus_dropped: u64 = 0;
|
||||||
if let Some(ev) = ev0 {
|
if let Some(ev) = ev0 {
|
||||||
au_dropped |= dispatch_event(
|
aus_dropped += u64::from(dispatch_event(
|
||||||
ev,
|
ev,
|
||||||
&mut pending_aus,
|
&mut pending_aus,
|
||||||
&mut free_inputs,
|
&mut free_inputs,
|
||||||
&mut ready,
|
&mut ready,
|
||||||
&mut fmt_dirty,
|
&mut fmt_dirty,
|
||||||
&mut fatal,
|
&mut fatal,
|
||||||
);
|
));
|
||||||
}
|
}
|
||||||
// Coalesce every other event already queued into this one work pass — correct newest-only
|
// Coalesce every other event already queued into this one work pass — correct newest-only
|
||||||
// presentation across a decode burst, and batched feeding.
|
// presentation across a decode burst, and batched feeding.
|
||||||
while let Ok(ev) = ev_rx.try_recv() {
|
while let Ok(ev) = ev_rx.try_recv() {
|
||||||
au_dropped |= dispatch_event(
|
aus_dropped += u64::from(dispatch_event(
|
||||||
ev,
|
ev,
|
||||||
&mut pending_aus,
|
&mut pending_aus,
|
||||||
&mut free_inputs,
|
&mut free_inputs,
|
||||||
&mut ready,
|
&mut ready,
|
||||||
&mut fmt_dirty,
|
&mut fmt_dirty,
|
||||||
&mut fatal,
|
&mut fatal,
|
||||||
);
|
));
|
||||||
}
|
}
|
||||||
|
stats.note_skipped(aus_dropped); // parked-AU overflow drops are client-side skips too
|
||||||
if fmt_dirty {
|
if fmt_dirty {
|
||||||
apply_hdr_dataspace(&codec, &window, &mut applied_ds);
|
apply_hdr_dataspace(&codec, &window, &mut applied_ds);
|
||||||
}
|
}
|
||||||
@@ -731,6 +907,7 @@ fn run_async(
|
|||||||
&stats,
|
&stats,
|
||||||
&in_flight,
|
&in_flight,
|
||||||
clock_offset,
|
clock_offset,
|
||||||
|
&tracker,
|
||||||
&mut rendered,
|
&mut rendered,
|
||||||
&mut discarded,
|
&mut discarded,
|
||||||
);
|
);
|
||||||
@@ -768,7 +945,7 @@ fn run_async(
|
|||||||
// dropped a parked AU on overflow), throttled so a multi-frame recovery gap doesn't flood the
|
// dropped a parked AU on overflow), throttled so a multi-frame recovery gap doesn't flood the
|
||||||
// control stream.
|
// control stream.
|
||||||
let dropped = client.frames_dropped();
|
let dropped = client.frames_dropped();
|
||||||
if dropped > last_dropped || au_dropped {
|
if dropped > last_dropped || aus_dropped > 0 {
|
||||||
last_dropped = dropped;
|
last_dropped = dropped;
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
if last_kf_req.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100)) {
|
if last_kf_req.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100)) {
|
||||||
@@ -783,6 +960,11 @@ fn run_async(
|
|||||||
if let Some(j) = feeder {
|
if let Some(j) = feeder {
|
||||||
let _ = j.join();
|
let _ = j.join();
|
||||||
}
|
}
|
||||||
|
drop(codec); // AMediaCodec_delete — after this no render callback can fire
|
||||||
|
if let Some(ud) = render_cb {
|
||||||
|
// SAFETY: the codec was dropped above; this registration's single reclaim.
|
||||||
|
unsafe { release_render_callback(ud) };
|
||||||
|
}
|
||||||
log::info!("decode: stopped (async, fed={fed} rendered={rendered} discarded={discarded})");
|
log::info!("decode: stopped (async, fed={fed} rendered={rendered} discarded={discarded})");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -863,7 +1045,15 @@ fn dispatch_event(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
DecodeEvent::InputAvailable(i) => free_inputs.push_back(i),
|
DecodeEvent::InputAvailable(i) => free_inputs.push_back(i),
|
||||||
DecodeEvent::OutputAvailable { index, pts_us } => ready.push(OutputReady { index, pts_us }),
|
DecodeEvent::OutputAvailable {
|
||||||
|
index,
|
||||||
|
pts_us,
|
||||||
|
decoded_ns,
|
||||||
|
} => ready.push(OutputReady {
|
||||||
|
index,
|
||||||
|
pts_us,
|
||||||
|
decoded_ns,
|
||||||
|
}),
|
||||||
DecodeEvent::FormatChanged => *fmt_dirty = true,
|
DecodeEvent::FormatChanged => *fmt_dirty = true,
|
||||||
DecodeEvent::Error { fatal: f } => {
|
DecodeEvent::Error { fatal: f } => {
|
||||||
if f {
|
if f {
|
||||||
@@ -917,13 +1107,17 @@ fn feed_ready(
|
|||||||
/// burst of stale frames on glass is worse than skipping to the freshest (the sync loop's newest-ready
|
/// burst of stale frames on glass is worse than skipping to the freshest (the sync loop's newest-ready
|
||||||
/// policy, callback-driven). Every dequeued buffer, rendered or not, is the HUD's `decoded`
|
/// policy, callback-driven). Every dequeued buffer, rendered or not, is the HUD's `decoded`
|
||||||
/// measurement point (it finished decoding either way); samples are recorded in pts order so the
|
/// measurement point (it finished decoding either way); samples are recorded in pts order so the
|
||||||
/// receipt-map eviction stays monotonic. `ready` is drained.
|
/// receipt-map eviction stays monotonic. The presented frame's `(pts, decoded stamp)` is parked in
|
||||||
|
/// `tracker` for the OnFrameRendered callback — the `display` stage's other endpoint. `ready` is
|
||||||
|
/// drained.
|
||||||
|
#[allow(clippy::too_many_arguments)] // one call site; mirrors the sync loop's drain
|
||||||
fn present_ready(
|
fn present_ready(
|
||||||
codec: &MediaCodec,
|
codec: &MediaCodec,
|
||||||
ready: &mut Vec<OutputReady>,
|
ready: &mut Vec<OutputReady>,
|
||||||
stats: &crate::stats::VideoStats,
|
stats: &crate::stats::VideoStats,
|
||||||
in_flight: &Mutex<VecDeque<(u64, i128)>>,
|
in_flight: &Mutex<VecDeque<(u64, i128)>>,
|
||||||
clock_offset: i64,
|
clock_offset: i64,
|
||||||
|
tracker: &DisplayTracker,
|
||||||
rendered: &mut u64,
|
rendered: &mut u64,
|
||||||
discarded: &mut u64,
|
discarded: &mut u64,
|
||||||
) {
|
) {
|
||||||
@@ -935,15 +1129,24 @@ fn present_ready(
|
|||||||
.lock()
|
.lock()
|
||||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
for o in ready.iter() {
|
for o in ready.iter() {
|
||||||
note_decoded_pts(stats, &mut g, clock_offset, o.pts_us);
|
note_decoded_pts(stats, &mut g, clock_offset, o.pts_us, o.decoded_ns);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let last = ready.len() - 1;
|
let last = ready.len() - 1;
|
||||||
|
let mut skipped: u64 = 0;
|
||||||
for (i, o) in ready.drain(..).enumerate() {
|
for (i, o) in ready.drain(..).enumerate() {
|
||||||
let render = i == last;
|
let render = i == last;
|
||||||
match codec.release_output_buffer_by_index(o.index, render) {
|
match codec.release_output_buffer_by_index(o.index, render) {
|
||||||
Ok(()) if render => *rendered += 1,
|
Ok(()) if render => {
|
||||||
Ok(()) => *discarded += 1,
|
*rendered += 1;
|
||||||
|
if stats.enabled() {
|
||||||
|
tracker.note_rendered(o.pts_us, o.decoded_ns);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(()) => {
|
||||||
|
*discarded += 1;
|
||||||
|
skipped += 1;
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::warn!(
|
log::warn!(
|
||||||
"decode: release_output_buffer_by_index({}, {render}): {e}",
|
"decode: release_output_buffer_by_index({}, {render}): {e}",
|
||||||
@@ -952,6 +1155,7 @@ fn present_ready(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
stats.note_skipped(skipped); // HUD `skipped` counter (newest-wins drops); no-op while hidden
|
||||||
}
|
}
|
||||||
|
|
||||||
/// React to an output-format change by signalling the stream's HDR dataspace on the Surface (SDR
|
/// React to an output-format change by signalling the stream's HDR dataspace on the Surface (SDR
|
||||||
@@ -1117,7 +1321,10 @@ fn feed(codec: &MediaCodec, au: &[u8], pts_us: u64) -> bool {
|
|||||||
/// Each dequeued buffer is also the HUD's `decoded` measurement point (rendered or not — the frame
|
/// Each dequeued buffer is also the HUD's `decoded` measurement point (rendered or not — the frame
|
||||||
/// finished decoding either way): end-to-end = decoded + clock_offset − capture pts, and the
|
/// finished decoding either way): end-to-end = decoded + clock_offset − capture pts, and the
|
||||||
/// `decode` stage pairs the buffer's echoed presentationTimeUs back to the receipt stamp in
|
/// `decode` stage pairs the buffer's echoed presentationTimeUs back to the receipt stamp in
|
||||||
/// `in_flight` (single-clock local difference, no skew involved).
|
/// `in_flight` (single-clock local difference, no skew involved). The presented frame's
|
||||||
|
/// `(pts, decoded stamp)` is additionally parked in `tracker` for the OnFrameRendered callback —
|
||||||
|
/// the `display` stage's other endpoint.
|
||||||
|
#[allow(clippy::too_many_arguments)] // one call site; mirrors the async loop's present_ready
|
||||||
fn drain(
|
fn drain(
|
||||||
codec: &MediaCodec,
|
codec: &MediaCodec,
|
||||||
window: &NativeWindow,
|
window: &NativeWindow,
|
||||||
@@ -1126,23 +1333,33 @@ fn drain(
|
|||||||
stats: &crate::stats::VideoStats,
|
stats: &crate::stats::VideoStats,
|
||||||
in_flight: &mut VecDeque<(u64, i128)>,
|
in_flight: &mut VecDeque<(u64, i128)>,
|
||||||
clock_offset: i64,
|
clock_offset: i64,
|
||||||
|
tracker: &DisplayTracker,
|
||||||
) -> (u64, u64) {
|
) -> (u64, u64) {
|
||||||
let mut held = None; // newest ready buffer so far, presented after the loop
|
// Newest ready buffer so far (presented after the loop) with its HUD metadata —
|
||||||
|
// `Some((pts_us, decoded_ns))` only while the HUD is visible (the stamp read is gated).
|
||||||
|
let mut held: Option<(OutputBuffer<'_>, Option<(u64, i128)>)> = None;
|
||||||
let mut discarded: u64 = 0;
|
let mut discarded: u64 = 0;
|
||||||
let mut wait = first_wait;
|
let mut wait = first_wait;
|
||||||
loop {
|
loop {
|
||||||
match codec.dequeue_output_buffer(wait) {
|
match codec.dequeue_output_buffer(wait) {
|
||||||
Ok(DequeuedOutputBufferInfoResult::Buffer(buf)) => {
|
Ok(DequeuedOutputBufferInfoResult::Buffer(buf)) => {
|
||||||
wait = Duration::ZERO; // only the first dequeue may block
|
wait = Duration::ZERO; // only the first dequeue may block
|
||||||
if stats.enabled() {
|
let meta = if stats.enabled() {
|
||||||
note_decoded(stats, in_flight, clock_offset, &buf);
|
// The dequeue IS the sync loop's decoded-availability instant.
|
||||||
}
|
let pts_us = buf.info().presentation_time_us().max(0) as u64;
|
||||||
if let Some(stale) = held.replace(buf) {
|
let decoded_ns = now_realtime_ns();
|
||||||
|
note_decoded_pts(stats, in_flight, clock_offset, pts_us, decoded_ns);
|
||||||
|
Some((pts_us, decoded_ns))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
if let Some((stale, _)) = held.replace((buf, meta)) {
|
||||||
// A newer frame is ready — drop the held one without rendering.
|
// A newer frame is ready — drop the held one without rendering.
|
||||||
if let Err(e) = codec.release_output_buffer(stale, false) {
|
if let Err(e) = codec.release_output_buffer(stale, false) {
|
||||||
log::warn!("decode: release_output_buffer(discard): {e}");
|
log::warn!("decode: release_output_buffer(discard): {e}");
|
||||||
}
|
}
|
||||||
discarded += 1;
|
discarded += 1;
|
||||||
|
stats.note_skipped(1); // HUD `skipped` counter; no-op while hidden
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(DequeuedOutputBufferInfoResult::OutputFormatChanged) => {
|
Ok(DequeuedOutputBufferInfoResult::OutputFormatChanged) => {
|
||||||
@@ -1175,46 +1392,37 @@ fn drain(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Present the newest ready frame, if any.
|
// Present the newest ready frame, if any, and park its metadata for the render callback.
|
||||||
let mut rendered = 0;
|
let mut rendered = 0;
|
||||||
if let Some(buf) = held {
|
if let Some((buf, meta)) = held {
|
||||||
match codec.release_output_buffer(buf, true) {
|
match codec.release_output_buffer(buf, true) {
|
||||||
Ok(()) => rendered = 1,
|
Ok(()) => {
|
||||||
|
rendered = 1;
|
||||||
|
if let Some((pts_us, decoded_ns)) = meta {
|
||||||
|
tracker.note_rendered(pts_us, decoded_ns);
|
||||||
|
}
|
||||||
|
}
|
||||||
Err(e) => log::warn!("decode: release_output_buffer: {e}"),
|
Err(e) => log::warn!("decode: release_output_buffer: {e}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
(rendered, discarded)
|
(rendered, discarded)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// HUD `decoded` point for one dequeued output buffer: build the end-to-end (capture→decoded,
|
/// HUD `decoded` point for one dequeued output frame, keyed by the echoed `presentationTimeUs`:
|
||||||
/// skew-corrected, clamped to (0, 10 s)) and `decode` (received→decoded, single-clock local, ≥ 0)
|
/// build the end-to-end (capture→decoded, skew-corrected, clamped to (0, 10 s)) and `decode`
|
||||||
/// samples and hand them to [`crate::stats::VideoStats::note_decoded`]. The codec echoes the input
|
/// (received→decoded, single-clock local, ≥ 0) samples and hand them to
|
||||||
/// `presentationTimeUs` on the output buffer, which keys the receipt stamp in `in_flight`; entries
|
/// [`crate::stats::VideoStats::note_decoded`]. The pts keys the receipt stamp in `in_flight`;
|
||||||
/// older than the echoed pts are evicted (decode order == input order here — low-latency, no
|
/// entries older than it are evicted (decode order == input order here — low-latency, no
|
||||||
/// B-frames — so anything before it was dropped inside the codec or stamped before a flush).
|
/// B-frames — so anything before it was dropped inside the codec or stamped before a flush).
|
||||||
fn note_decoded(
|
/// `decoded_ns` is the availability instant: the dequeue (sync loop) or the output callback's
|
||||||
stats: &crate::stats::VideoStats,
|
/// stamp (async loop).
|
||||||
in_flight: &mut VecDeque<(u64, i128)>,
|
|
||||||
clock_offset: i64,
|
|
||||||
buf: &OutputBuffer<'_>,
|
|
||||||
) {
|
|
||||||
note_decoded_pts(
|
|
||||||
stats,
|
|
||||||
in_flight,
|
|
||||||
clock_offset,
|
|
||||||
buf.info().presentation_time_us().max(0) as u64,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The [`note_decoded`] body keyed by the echoed `presentationTimeUs` directly — the async loop has
|
|
||||||
/// the pts (from the output callback's `BufferInfo`) but no borrowed `OutputBuffer`, so it calls this.
|
|
||||||
fn note_decoded_pts(
|
fn note_decoded_pts(
|
||||||
stats: &crate::stats::VideoStats,
|
stats: &crate::stats::VideoStats,
|
||||||
in_flight: &mut VecDeque<(u64, i128)>,
|
in_flight: &mut VecDeque<(u64, i128)>,
|
||||||
clock_offset: i64,
|
clock_offset: i64,
|
||||||
pts_us: u64,
|
pts_us: u64,
|
||||||
|
decoded_ns: i128,
|
||||||
) {
|
) {
|
||||||
let decoded_ns = now_realtime_ns();
|
|
||||||
// Pair the echoed pts back to its receipt stamp, evicting stale (older) entries as we go.
|
// Pair the echoed pts back to its receipt stamp, evicting stale (older) entries as we go.
|
||||||
let mut received_ns = None;
|
let mut received_ns = None;
|
||||||
while let Some(&(p, r)) = in_flight.front() {
|
while let Some(&(p, r)) = in_flight.front() {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
//! LAN host discovery over mDNS, in Rust via `mdns-sd` — the same crate + service type the
|
//! LAN host discovery over mDNS, in Rust via `mdns-sd` — the same crate + service type the
|
||||||
//! Linux/Windows clients use (`clients/linux/src/discovery.rs`), exposed to Kotlin over JNI.
|
//! Linux/Windows clients use (`crates/pf-client-core/src/discovery.rs`), exposed to Kotlin over JNI.
|
||||||
//!
|
//!
|
||||||
//! Why not `NsdManager`: that API delegates to a per-OEM system mDNS daemon whose reliability
|
//! Why not `NsdManager`: that API delegates to a per-OEM system mDNS daemon whose reliability
|
||||||
//! varies wildly (the Android client's discovery was "mostly broken"). Browsing in our own Rust
|
//! varies wildly (the Android client's discovery was "mostly broken"). Browsing in our own Rust
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ mod stats;
|
|||||||
// Ungated like `discovery`: pure `jni` + `punktfunk_core::wol` (no Android framework), so it links
|
// Ungated like `discovery`: pure `jni` + `punktfunk_core::wol` (no Android framework), so it links
|
||||||
// into the host workspace build too. Kotlin only ever calls it on device.
|
// into the host workspace build too. Kotlin only ever calls it on device.
|
||||||
mod wol;
|
mod wol;
|
||||||
|
// Ungated like `wol`: pure `jni` + `punktfunk_core::client` (the reachability probe). Kotlin calls
|
||||||
|
// it off the main thread to light saved-host "online" pips independently of mDNS.
|
||||||
|
mod probe;
|
||||||
|
|
||||||
/// Initialize `android_logger` once when the JVM loads the library. Logs land in logcat under the
|
/// Initialize `android_logger` once when the JVM loads the library. Logs land in logcat under the
|
||||||
/// `punktfunk` tag. Core `tracing` events (transport warnings: socket-buffer clamp, QoS failures)
|
/// `punktfunk` tag. Core `tracing` events (transport warnings: socket-buffer clamp, QoS failures)
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
//! JNI seam for the reachability probe: a bounded, trust-agnostic QUIC handshake to a saved host
|
||||||
|
//! (`punktfunk_core::client::NativeClient::probe`). Like [`crate::wol`] it takes no session handle
|
||||||
|
//! and links into the host workspace build (pure `jni` + `punktfunk_core`). Kotlin calls it
|
||||||
|
//! periodically, on a background dispatcher, to light the "online" pip for saved hosts that never
|
||||||
|
//! advertise on mDNS (reached over Tailscale / VPN / another subnet) — the display-side companion
|
||||||
|
//! to the dial-first connect fix.
|
||||||
|
|
||||||
|
use jni::objects::{JObject, JString};
|
||||||
|
use jni::sys::{jboolean, jint};
|
||||||
|
use jni::JNIEnv;
|
||||||
|
use punktfunk_core::client::NativeClient;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// `NativeBridge.nativeProbe(host, port, timeoutMs): Boolean` — true if `host:port` completed a
|
||||||
|
/// QUIC handshake within `timeoutMs`. No pin/identity presented (trust-agnostic), mDNS-independent.
|
||||||
|
/// Blocking (builds its own runtime) — Kotlin runs it on `Dispatchers.IO`, never the main thread.
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeProbe<'local>(
|
||||||
|
mut env: JNIEnv<'local>,
|
||||||
|
_this: JObject<'local>,
|
||||||
|
host: JString<'local>,
|
||||||
|
port: jint,
|
||||||
|
timeout_ms: jint,
|
||||||
|
) -> jboolean {
|
||||||
|
let host: String = match env.get_string(&host) {
|
||||||
|
Ok(s) => s.into(),
|
||||||
|
Err(_) => return 0,
|
||||||
|
};
|
||||||
|
let port = port.clamp(0, u16::MAX as jint) as u16;
|
||||||
|
let timeout = Duration::from_millis(timeout_ms.max(0) as u64);
|
||||||
|
if NativeClient::probe(&host, port, timeout) {
|
||||||
|
1
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -76,6 +76,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
|||||||
gamepad_pref: jint,
|
gamepad_pref: jint,
|
||||||
hdr_enabled: jboolean,
|
hdr_enabled: jboolean,
|
||||||
audio_channels: jint,
|
audio_channels: jint,
|
||||||
|
video_codecs: jint,
|
||||||
preferred_codec: jint,
|
preferred_codec: jint,
|
||||||
timeout_ms: jint,
|
timeout_ms: jint,
|
||||||
launch: JString<'local>,
|
launch: JString<'local>,
|
||||||
@@ -142,10 +143,23 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
|||||||
// decoder + AAudio layout (read in `crate::audio::AudioPlayback::start`). Anything else
|
// decoder + AAudio layout (read in `crate::audio::AudioPlayback::start`). Anything else
|
||||||
// normalizes to stereo here.
|
// normalizes to stereo here.
|
||||||
punktfunk_core::audio::normalize_channels(audio_channels.clamp(0, u8::MAX as jint) as u8),
|
punktfunk_core::audio::normalize_channels(audio_channels.clamp(0, u8::MAX as jint) as u8),
|
||||||
// Codecs this device can decode — AMediaCodec decodes both HEVC and H.264 (AV1 isn't wired;
|
// Codecs this device can decode, ranked on the Kotlin side (`VideoDecoders.decodableCodecBits`:
|
||||||
// hosts don't emit it on the native path yet). The host resolves the emitted codec from these
|
// H.264 + HEVC always, AV1 when a real `video/av01` decoder exists — AMediaCodec is
|
||||||
// + the soft `preferred_codec` and echoes it in `connector.codec`, which drives the mime below.
|
// mime-driven, see `codec_mime`). Mask to the known bits and fall back to the pre-AV1
|
||||||
punktfunk_core::quic::CODEC_H264 | punktfunk_core::quic::CODEC_HEVC,
|
// H.264|HEVC pair on 0 so a bogus value can't advertise nothing and kill the handshake.
|
||||||
|
// The host resolves the emitted codec from these + the soft `preferred_codec` and echoes it
|
||||||
|
// in `connector.codec`, which drives the mime below.
|
||||||
|
{
|
||||||
|
let bits = (video_codecs.clamp(0, u8::MAX as jint) as u8)
|
||||||
|
& (punktfunk_core::quic::CODEC_H264
|
||||||
|
| punktfunk_core::quic::CODEC_HEVC
|
||||||
|
| punktfunk_core::quic::CODEC_AV1);
|
||||||
|
if bits == 0 {
|
||||||
|
punktfunk_core::quic::CODEC_H264 | punktfunk_core::quic::CODEC_HEVC
|
||||||
|
} else {
|
||||||
|
bits
|
||||||
|
}
|
||||||
|
},
|
||||||
preferred_codec.clamp(0, u8::MAX as jint) as u8,
|
preferred_codec.clamp(0, u8::MAX as jint) as u8,
|
||||||
launch, // a store-qualified library id to boot into a game, or None for the desktop
|
launch, // a store-qualified library id to boot into a game, or None for the desktop
|
||||||
pin, // Some → Crypto on host-fp mismatch
|
pin, // Some → Crypto on host-fp mismatch
|
||||||
|
|||||||
@@ -144,16 +144,25 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// `NativeBridge.nativeVideoStats(handle): DoubleArray?` — drain ~1 s of decode stats for the HUD
|
/// `NativeBridge.nativeVideoStats(handle): DoubleArray?` — drain ~1 s of decode stats for the HUD
|
||||||
/// (unified stats spec, `design/stats-unification.md`). Returns 18 doubles
|
/// (unified stats spec, `design/stats-unification.md`). Returns 26 doubles
|
||||||
/// `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skewCorrected, width, height, refreshHz, framesLost,
|
/// `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skewCorrected, width, height, refreshHz, framesLost,
|
||||||
/// bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms,
|
/// bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms,
|
||||||
/// netP50Ms]`
|
/// netP50Ms, lostWindow, skippedWindow, fecWindow, framesWindow, dispValid, displayP50Ms,
|
||||||
/// (the two flags are 1.0/0.0; indexes 0–15 match the previous 16-double layout — 0–13 the original
|
/// e2eDispP50Ms, e2eDispP95Ms]`
|
||||||
|
/// (the flags are 1.0/0.0; indexes 0–21 match the previous 22-double layout — 0–13 the original
|
||||||
/// 14-double one with the latency pair re-based to the end-to-end capture→decoded headline, 14/15
|
/// 14-double one with the latency pair re-based to the end-to-end capture→decoded headline, 14/15
|
||||||
/// the stage p50s tiling it: `host+network` = capture→received, `decode` = received→decoded; 16/17
|
/// the stage p50s tiling it: `host+network` = capture→received, `decode` = received→decoded; 16/17
|
||||||
/// are the Phase-2 split of the `host+network` term from the per-AU 0xCF host timings — `host` =
|
/// are the Phase-2 split of the `host+network` term from the per-AU 0xCF host timings — `host` =
|
||||||
/// the host's capture→sent, `network` = the remainder — both 0.0 when no timing matched this
|
/// the host's capture→sent, `network` = the remainder — both 0.0 when no timing matched this
|
||||||
/// window, i.e. an old host), or `null` when no decode thread is running. Poll ~1 Hz from the UI; each call
|
/// window, i.e. an old host; 18–21 are the spec's per-window line-4 counters — `lost` =
|
||||||
|
/// unrecoverable drops this window, `skipped` = client newest-wins/pacing drops, `fec` = shards
|
||||||
|
/// recovered, `frames` = AUs received, so the HUD can compute `lost/(frames+lost)` — index 9 stays
|
||||||
|
/// the cumulative session total for older readers; 22–25 are the `display` stage from the
|
||||||
|
/// OnFrameRendered render timestamps — when `dispValid` is 1.0 the HUD headline becomes the
|
||||||
|
/// directly-measured capture→displayed pair at 24/25 with `display` = decoded→displayed p50 at 23
|
||||||
|
/// closing the equation, and when 0.0 — no render callback landed this window — it falls back to
|
||||||
|
/// the capture→decoded headline at 2/3), or `null` when no decode thread is running.
|
||||||
|
/// Poll ~1 Hz from the UI; each call
|
||||||
/// resets the measurement window. Not android-gated — pure `jni` + connector reads, so it links on
|
/// resets the measurement window. Not android-gated — pure `jni` + connector reads, so it links on
|
||||||
/// the host build too (Kotlin only ever calls it on device).
|
/// the host build too (Kotlin only ever calls it on device).
|
||||||
#[no_mangle]
|
#[no_mangle]
|
||||||
@@ -171,10 +180,12 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
|
|||||||
if h.video.lock().unwrap().is_none() {
|
if h.video.lock().unwrap().is_none() {
|
||||||
return std::ptr::null_mut(); // not streaming → no stats
|
return std::ptr::null_mut(); // not streaming → no stats
|
||||||
}
|
}
|
||||||
let snap = h.stats.drain();
|
let snap = h
|
||||||
|
.stats
|
||||||
|
.drain(h.client.frames_dropped(), h.client.fec_recovered_shards());
|
||||||
let mode = h.client.mode();
|
let mode = h.client.mode();
|
||||||
let color = h.client.color;
|
let color = h.client.color;
|
||||||
let buf: [f64; 18] = [
|
let buf: [f64; 26] = [
|
||||||
snap.fps,
|
snap.fps,
|
||||||
snap.mbps,
|
snap.mbps,
|
||||||
snap.e2e_p50_ms,
|
snap.e2e_p50_ms,
|
||||||
@@ -200,6 +211,21 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
|
|||||||
// when no timing matched this window (old host) — the HUD keeps the combined term.
|
// when no timing matched this window (old host) — the HUD keeps the combined term.
|
||||||
snap.host_p50_ms,
|
snap.host_p50_ms,
|
||||||
snap.net_p50_ms,
|
snap.net_p50_ms,
|
||||||
|
// Spec line-4 counters, per-window: lost (unrecoverable drops), skipped (client
|
||||||
|
// newest-wins/pacing drops), FEC shards recovered, and the received-AU count so the
|
||||||
|
// HUD computes the loss percentage `lost/(frames+lost)` exactly.
|
||||||
|
snap.lost as f64,
|
||||||
|
snap.skipped as f64,
|
||||||
|
snap.fec as f64,
|
||||||
|
snap.frames as f64,
|
||||||
|
// `display` stage (OnFrameRendered render timestamps): validity flag, the
|
||||||
|
// decoded→displayed stage p50, and the directly-measured capture→displayed headline
|
||||||
|
// pair that supersedes 2/3 whenever the flag is set (spec: the equation always tiles
|
||||||
|
// the headline interval, so endpoint and terms move together).
|
||||||
|
if snap.disp_valid { 1.0 } else { 0.0 },
|
||||||
|
snap.display_p50_ms,
|
||||||
|
snap.e2e_disp_p50_ms,
|
||||||
|
snap.e2e_disp_p95_ms,
|
||||||
];
|
];
|
||||||
let arr = match env.new_double_array(buf.len() as jsize) {
|
let arr = match env.new_double_array(buf.len() as jsize) {
|
||||||
Ok(a) => a,
|
Ok(a) => a,
|
||||||
@@ -228,7 +254,13 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetVideoSta
|
|||||||
if handle != 0 {
|
if handle != 0 {
|
||||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||||
h.stats.set_enabled(enabled != 0);
|
// The current cumulative counters seed the window baselines, so the first snapshot's
|
||||||
|
// `lost`/`FEC` cover only time the HUD was actually up.
|
||||||
|
h.stats.set_enabled(
|
||||||
|
enabled != 0,
|
||||||
|
h.client.frames_dropped(),
|
||||||
|
h.client.fec_recovered_shards(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
//! Live decode stats for the on-stream HUD, following the unified stats spec
|
//! Live decode stats for the on-stream HUD, following the unified stats spec
|
||||||
//! (`design/stats-unification.md`): FPS, receive throughput, and the Android v1 stage split —
|
//! (`design/stats-unification.md`): FPS, receive throughput, and the full stage split — headline
|
||||||
//! headline `end-to-end` = capture→decoded (p50/p95) tiled by `host+network` = capture→received
|
//! `end-to-end` = capture→displayed (p50/p95, measured directly from the `OnFrameRendered` render
|
||||||
//! and `decode` = received→decoded (stage p50s). When the host emits per-AU 0xCF host timings, the
|
//! timestamps — `note_displayed`) tiled by `host+network` = capture→received, `decode` =
|
||||||
//! `host+network` term further splits into `host` + `network` (Phase 2, `note_host_split`); an old
|
//! received→decoded, and `display` = decoded→displayed (stage p50s). When the platform delivers no
|
||||||
//! host emits none and the combined term stands. The decode thread is the sole writer
|
//! render callbacks (allowed under load; `disp_valid` false), the HUD falls back to the v1
|
||||||
|
//! capture→decoded headline without the `display` term. When the host emits per-AU 0xCF host
|
||||||
|
//! timings, the `host+network` term further splits into `host` + `network` (Phase 2,
|
||||||
|
//! `note_host_split`); an old host emits none and the combined term stands. The spec's line-4 counters are per-window too:
|
||||||
|
//! `lost` / `FEC` are windowed here from the connector's cumulative counters (the caller passes the
|
||||||
|
//! current totals into `set_enabled`/`drain`), `skipped` counts the client's own newest-wins drops
|
||||||
|
//! (`note_skipped`). The decode thread is the sole writer
|
||||||
//! (`note_received` per access unit at receipt, `note_decoded` per decoder output buffer); the JNI
|
//! (`note_received` per access unit at receipt, `note_decoded` per decoder output buffer); the JNI
|
||||||
//! accessor `nativeVideoStats` drains a snapshot ~1 Hz and resets the window. Sampling is gated on
|
//! accessor `nativeVideoStats` drains a snapshot ~1 Hz and resets the window. Sampling is gated on
|
||||||
//! the HUD actually being visible (`set_enabled`, driven by `nativeSetVideoStatsEnabled`) so the
|
//! the HUD actually being visible (`set_enabled`, driven by `nativeSetVideoStatsEnabled`) so the
|
||||||
@@ -54,6 +60,21 @@ struct Inner {
|
|||||||
net_us: Vec<u64>,
|
net_us: Vec<u64>,
|
||||||
/// `decode` stage = received→decoded samples, in microseconds (client-local, single clock).
|
/// `decode` stage = received→decoded samples, in microseconds (client-local, single clock).
|
||||||
decode_us: Vec<u64>,
|
decode_us: Vec<u64>,
|
||||||
|
/// `display` stage = decoded→displayed samples, in microseconds (client-local, single clock),
|
||||||
|
/// from the `OnFrameRendered` render timestamps. Empty when the platform delivers no render
|
||||||
|
/// callbacks — the HUD then drops the term and the headline endpoint moves back to `decoded`.
|
||||||
|
display_us: Vec<u64>,
|
||||||
|
/// `end-to-end` = capture→displayed samples, µs (skew-corrected) — the spec's headline,
|
||||||
|
/// measured directly (not summed from stages). Empty under the same fallback as `display_us`.
|
||||||
|
e2e_disp_us: Vec<u64>,
|
||||||
|
/// Client-side newest-wins/pacing drops this window (decoded frames released without
|
||||||
|
/// rendering, or parked AUs dropped on overflow) — the spec's `skipped` counter.
|
||||||
|
skipped: u64,
|
||||||
|
/// Baselines for windowing the session-cumulative connector counters: the unrecoverable-drop
|
||||||
|
/// and FEC-recovered totals as of the last drain (or the enable that opened the window), so
|
||||||
|
/// each snapshot reports only THIS window's `lost` / `FEC` (spec line 4).
|
||||||
|
last_dropped_total: u64,
|
||||||
|
last_fec_total: u64,
|
||||||
/// Whether the host answered the clock-skew handshake (latency is cross-machine valid).
|
/// Whether the host answered the clock-skew handshake (latency is cross-machine valid).
|
||||||
skew_corrected: bool,
|
skew_corrected: bool,
|
||||||
}
|
}
|
||||||
@@ -64,18 +85,38 @@ struct Inner {
|
|||||||
pub struct Snapshot {
|
pub struct Snapshot {
|
||||||
pub fps: f64,
|
pub fps: f64,
|
||||||
pub mbps: f64,
|
pub mbps: f64,
|
||||||
/// Headline `end-to-end` (capture→decoded) percentiles, ms.
|
/// Headline `end-to-end` (capture→decoded) percentiles, ms — the fallback headline when no
|
||||||
|
/// render callback landed this window (`disp_valid` false).
|
||||||
pub e2e_p50_ms: f64,
|
pub e2e_p50_ms: f64,
|
||||||
pub e2e_p95_ms: f64,
|
pub e2e_p95_ms: f64,
|
||||||
/// Stage p50s (ms): `host+network` (capture→received) and `decode` (received→decoded).
|
/// The full headline: `end-to-end` = capture→displayed percentiles, ms, measured directly from
|
||||||
|
/// the render timestamps. Meaningful only when `disp_valid`.
|
||||||
|
pub e2e_disp_p50_ms: f64,
|
||||||
|
pub e2e_disp_p95_ms: f64,
|
||||||
|
/// Stage p50s (ms): `host+network` (capture→received), `decode` (received→decoded), and
|
||||||
|
/// `display` (decoded→displayed; 0.0 when `disp_valid` is false — the HUD drops the term).
|
||||||
pub hostnet_p50_ms: f64,
|
pub hostnet_p50_ms: f64,
|
||||||
pub decode_p50_ms: f64,
|
pub decode_p50_ms: f64,
|
||||||
|
pub display_p50_ms: f64,
|
||||||
|
/// Whether any capture→displayed sample landed this window — gates the HUD's headline endpoint
|
||||||
|
/// (`capture→displayed` vs the capture→decoded fallback) and the equation's `display` term.
|
||||||
|
pub disp_valid: bool,
|
||||||
/// Phase-2 `host` / `network` split p50s (ms) — 0.0 when no 0xCF timing matched this window
|
/// Phase-2 `host` / `network` split p50s (ms) — 0.0 when no 0xCF timing matched this window
|
||||||
/// (old host / no samples yet), in which case the HUD keeps the combined `host+network` term.
|
/// (old host / no samples yet), in which case the HUD keeps the combined `host+network` term.
|
||||||
pub host_p50_ms: f64,
|
pub host_p50_ms: f64,
|
||||||
pub net_p50_ms: f64,
|
pub net_p50_ms: f64,
|
||||||
pub lat_valid: bool,
|
pub lat_valid: bool,
|
||||||
pub skew_corrected: bool,
|
pub skew_corrected: bool,
|
||||||
|
/// Access units received this window (the count behind `fps`) — lets the HUD compute the
|
||||||
|
/// spec's loss percentage `lost / (received + lost)` exactly.
|
||||||
|
pub frames: u64,
|
||||||
|
/// Unrecoverable network frame drops this window (spec `lost`, windowed from the
|
||||||
|
/// session-cumulative connector counter).
|
||||||
|
pub lost: u64,
|
||||||
|
/// Client-side newest-wins/pacing drops this window (spec `skipped`).
|
||||||
|
pub skipped: u64,
|
||||||
|
/// FEC shards recovered this window (spec `FEC`, windowed from the cumulative counter).
|
||||||
|
pub fec: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Percentile over a sorted-in-place µs sample vec, in ms. 0.0 when empty.
|
/// Percentile over a sorted-in-place µs sample vec, in ms. 0.0 when empty.
|
||||||
@@ -101,6 +142,11 @@ impl VideoStats {
|
|||||||
host_us: Vec::with_capacity(256),
|
host_us: Vec::with_capacity(256),
|
||||||
net_us: Vec::with_capacity(256),
|
net_us: Vec::with_capacity(256),
|
||||||
decode_us: Vec::with_capacity(256),
|
decode_us: Vec::with_capacity(256),
|
||||||
|
display_us: Vec::with_capacity(256),
|
||||||
|
e2e_disp_us: Vec::with_capacity(256),
|
||||||
|
skipped: 0,
|
||||||
|
last_dropped_total: 0,
|
||||||
|
last_fec_total: 0,
|
||||||
skew_corrected: false,
|
skew_corrected: false,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
@@ -115,8 +161,10 @@ impl VideoStats {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle sampling. Enabling resets the window, so the first HUD poll after a show never mixes
|
/// Toggle sampling. Enabling resets the window, so the first HUD poll after a show never mixes
|
||||||
/// in counters (or a window start) from before the overlay was visible.
|
/// in counters (or a window start) from before the overlay was visible. `dropped_total` /
|
||||||
pub fn set_enabled(&self, on: bool) {
|
/// `fec_total` are the connector's session-cumulative counters at this instant — they seed the
|
||||||
|
/// windowing baselines so the first snapshot's `lost` / `FEC` cover only time the HUD was up.
|
||||||
|
pub fn set_enabled(&self, on: bool, dropped_total: u64, fec_total: u64) {
|
||||||
let was = self.enabled.swap(on, Ordering::Relaxed);
|
let was = self.enabled.swap(on, Ordering::Relaxed);
|
||||||
if on && !was {
|
if on && !was {
|
||||||
let mut g = self
|
let mut g = self
|
||||||
@@ -131,6 +179,11 @@ impl VideoStats {
|
|||||||
g.host_us.clear();
|
g.host_us.clear();
|
||||||
g.net_us.clear();
|
g.net_us.clear();
|
||||||
g.decode_us.clear();
|
g.decode_us.clear();
|
||||||
|
g.display_us.clear();
|
||||||
|
g.e2e_disp_us.clear();
|
||||||
|
g.skipped = 0;
|
||||||
|
g.last_dropped_total = dropped_total;
|
||||||
|
g.last_fec_total = fec_total;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,6 +259,22 @@ impl VideoStats {
|
|||||||
g.net_us.push(net_us);
|
g.net_us.push(net_us);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Record client-side frame skips (spec `skipped`): decoded output buffers released without
|
||||||
|
/// rendering under the newest-wins policy, or parked AUs dropped on queue overflow.
|
||||||
|
// Driven only by the android-only decode thread; unreferenced on the host build — expected.
|
||||||
|
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||||
|
pub fn note_skipped(&self, n: u64) {
|
||||||
|
if n == 0 || !self.enabled.load(Ordering::Relaxed) {
|
||||||
|
return; // HUD hidden — skip the lock
|
||||||
|
}
|
||||||
|
// Poison-proof for the same reason as `note_received`.
|
||||||
|
let mut g = self
|
||||||
|
.inner
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
g.skipped += n;
|
||||||
|
}
|
||||||
|
|
||||||
/// Record one decoded output frame: its capture→decoded `end-to-end` sample and its
|
/// Record one decoded output frame: its capture→decoded `end-to-end` sample and its
|
||||||
/// received→decoded `decode` stage sample (either may be absent — e.g. the receipt stamp for
|
/// received→decoded `decode` stage sample (either may be absent — e.g. the receipt stamp for
|
||||||
/// this pts predates the HUD being shown).
|
/// this pts predates the HUD being shown).
|
||||||
@@ -228,8 +297,34 @@ impl VideoStats {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Record one displayed frame (the `OnFrameRendered` render timestamp, re-based to the
|
||||||
|
/// realtime clock): its capture→displayed `end-to-end` sample and its decoded→displayed
|
||||||
|
/// `display` stage sample (either may be absent — the e2e clamp rejected an out-of-range
|
||||||
|
/// value, or the decoded stamp for this pts was already evicted/pre-HUD). Fired from the
|
||||||
|
/// codec's render-callback thread, not the decode thread — the lock makes that safe.
|
||||||
|
// Driven only by the android-only decode path; unreferenced on the host build — expected.
|
||||||
|
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||||
|
pub fn note_displayed(&self, e2e_us: Option<u64>, display_us: Option<u64>) {
|
||||||
|
if !self.enabled.load(Ordering::Relaxed) {
|
||||||
|
return; // HUD hidden — skip the lock (the callback already skipped the clock reads)
|
||||||
|
}
|
||||||
|
// Poison-proof for the same reason as `note_received`.
|
||||||
|
let mut g = self
|
||||||
|
.inner
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
if let Some(l) = e2e_us {
|
||||||
|
g.e2e_disp_us.push(l);
|
||||||
|
}
|
||||||
|
if let Some(l) = display_us {
|
||||||
|
g.display_us.push(l);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Compute the window's rates + latency percentiles, then reset for the next window.
|
/// Compute the window's rates + latency percentiles, then reset for the next window.
|
||||||
pub fn drain(&self) -> Snapshot {
|
/// `dropped_total` / `fec_total` are the connector's session-cumulative counters now; the
|
||||||
|
/// snapshot's `lost` / `fec` are their deltas since the last drain (or the enabling show).
|
||||||
|
pub fn drain(&self, dropped_total: u64, fec_total: u64) -> Snapshot {
|
||||||
// Poison-proof for the same reason as `note_received` — a poisoned window still drains
|
// Poison-proof for the same reason as `note_received` — a poisoned window still drains
|
||||||
// fine.
|
// fine.
|
||||||
let mut g = self
|
let mut g = self
|
||||||
@@ -244,17 +339,27 @@ impl VideoStats {
|
|||||||
g.host_us.sort_unstable();
|
g.host_us.sort_unstable();
|
||||||
g.net_us.sort_unstable();
|
g.net_us.sort_unstable();
|
||||||
g.decode_us.sort_unstable();
|
g.decode_us.sort_unstable();
|
||||||
|
g.display_us.sort_unstable();
|
||||||
|
g.e2e_disp_us.sort_unstable();
|
||||||
let snap = Snapshot {
|
let snap = Snapshot {
|
||||||
fps,
|
fps,
|
||||||
mbps,
|
mbps,
|
||||||
e2e_p50_ms: pctl_ms(&g.e2e_us, 0.50),
|
e2e_p50_ms: pctl_ms(&g.e2e_us, 0.50),
|
||||||
e2e_p95_ms: pctl_ms(&g.e2e_us, 0.95),
|
e2e_p95_ms: pctl_ms(&g.e2e_us, 0.95),
|
||||||
|
e2e_disp_p50_ms: pctl_ms(&g.e2e_disp_us, 0.50),
|
||||||
|
e2e_disp_p95_ms: pctl_ms(&g.e2e_disp_us, 0.95),
|
||||||
hostnet_p50_ms: pctl_ms(&g.hostnet_us, 0.50),
|
hostnet_p50_ms: pctl_ms(&g.hostnet_us, 0.50),
|
||||||
decode_p50_ms: pctl_ms(&g.decode_us, 0.50),
|
decode_p50_ms: pctl_ms(&g.decode_us, 0.50),
|
||||||
|
display_p50_ms: pctl_ms(&g.display_us, 0.50),
|
||||||
|
disp_valid: !g.e2e_disp_us.is_empty(),
|
||||||
host_p50_ms: pctl_ms(&g.host_us, 0.50),
|
host_p50_ms: pctl_ms(&g.host_us, 0.50),
|
||||||
net_p50_ms: pctl_ms(&g.net_us, 0.50),
|
net_p50_ms: pctl_ms(&g.net_us, 0.50),
|
||||||
lat_valid: !g.e2e_us.is_empty(),
|
lat_valid: !g.e2e_us.is_empty(),
|
||||||
skew_corrected: g.skew_corrected,
|
skew_corrected: g.skew_corrected,
|
||||||
|
frames: g.frames,
|
||||||
|
lost: dropped_total.saturating_sub(g.last_dropped_total),
|
||||||
|
skipped: g.skipped,
|
||||||
|
fec: fec_total.saturating_sub(g.last_fec_total),
|
||||||
};
|
};
|
||||||
g.window_start = Instant::now();
|
g.window_start = Instant::now();
|
||||||
g.frames = 0;
|
g.frames = 0;
|
||||||
@@ -264,6 +369,11 @@ impl VideoStats {
|
|||||||
g.host_us.clear();
|
g.host_us.clear();
|
||||||
g.net_us.clear();
|
g.net_us.clear();
|
||||||
g.decode_us.clear();
|
g.decode_us.clear();
|
||||||
|
g.display_us.clear();
|
||||||
|
g.e2e_disp_us.clear();
|
||||||
|
g.skipped = 0;
|
||||||
|
g.last_dropped_total = dropped_total;
|
||||||
|
g.last_fec_total = fec_total;
|
||||||
snap
|
snap
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
/target
|
||||||
|
**/*.rs.bk
|
||||||
|
Cargo.lock
|
||||||
+137
@@ -0,0 +1,137 @@
|
|||||||
|
# Unreleased
|
||||||
|
|
||||||
|
# 0.9.0 (2024-04-26)
|
||||||
|
|
||||||
|
- Move `MediaFormat` from `media::media_codec` to its own `media::media_format` module. (#442)
|
||||||
|
- media_format: Expose `MediaFormat::copy()` and `MediaFormat::clear()` from API level 29. (#449)
|
||||||
|
- **Breaking:** media_format: Mark all `fn set_*()` and `fn str()` as taking `self` by `&mut`. (#452)
|
||||||
|
- **Breaking:** Require all `dyn Fn*` types to implement `Send` when the FFI implementation invokes them on a separate thread: (#455)
|
||||||
|
- `audio::AudioStreamDataCallback`;
|
||||||
|
- `audio::AudioStreamErrorCallback`;
|
||||||
|
- `media::image_reader::BufferRemovedListener`;
|
||||||
|
- `media::image_reader::ImageListener`;
|
||||||
|
- `media::media_codec::ErrorCallback`;
|
||||||
|
- `media::media_codec::FormatChangedCallback`;
|
||||||
|
- `media::media_codec::InputAvailableCallback`;
|
||||||
|
- `media::media_codec::OutputAvailableCallback`.
|
||||||
|
- Drop previous `Box`ed callbacks _after_ registering new ones, instead of before. (#455)
|
||||||
|
- input_queue: Add `from_java()` constructor, available since API level 33. (#456)
|
||||||
|
- event: Add `from_java()` constructors to `KeyEvent` and `MotionEvent`, available since API level 31. (#456)
|
||||||
|
- **Breaking:** image_reader: Special-case return statuses in `Image`-acquire functions. (#457)
|
||||||
|
- **Breaking:** image_reader: Mark `ImageReader::acquire_latest_image_async()` `unsafe` to match the safety requirements on `ImageReader::acquire_next_image_async()`. (#457)
|
||||||
|
- event: Implement `SourceClass` `bitflag` and provide `Source::class()` getter. (#458)
|
||||||
|
- Ensure all `bitflags` implementations consider all (including unknown) bits in negation and `all()`. (#458)
|
||||||
|
- **Breaking:** Mark all enums as `non_exhaustive` and fix `repr` types. (#459)
|
||||||
|
- **Breaking:** native_window: Remove redundant `TRANSFORM_` prefix from `NativeWindowTransform` variants. (#460)
|
||||||
|
- **Breaking:** hardware_buffer: Convert `HardwareBufferUsage` to `bitflags`. (#461)
|
||||||
|
- bitmap: Guard `BitmapCompressError` behind missing `api-level-30` feature. (#462)
|
||||||
|
- native_window: Require linking against `libnativewindow` for most API >= 26 functions. (#465)
|
||||||
|
- **Breaking:** audio: Merge `AudioResult` variant enum into `AudioError`. (#467)
|
||||||
|
- data_space: Add missing `DataSpaceRange::Unspecified` variant. (#468)
|
||||||
|
- **Breaking:** looper: Require `Send` marker when adding fd event callbacks on `ForeignLooper`. (#469)
|
||||||
|
- **Breaking:** Upgrade to [`ndk-sys 0.6.0`](../ndk-sys/CHANGELOG.md#060-2024-04-26). (#472)
|
||||||
|
|
||||||
|
# 0.8.0 (2023-10-15)
|
||||||
|
|
||||||
|
- event: Add `tool_type` getter for `Pointer`. (#323)
|
||||||
|
- input_queue: Allow any non-zero return code from `pre_dispatch()` again, as per documentation. (#325)
|
||||||
|
- asset: Use entire asset length when mapping buffer. (#387)
|
||||||
|
- Bump MSRV to 1.66 for `raw-window-handle 0.5.1`, `num_enum`'s `catch_all` with arbitrary enum discriminants. (#388, #431)
|
||||||
|
- Bump optional `jni` dependency for doctest example from `0.19` to `0.21`. (#390)
|
||||||
|
- **Breaking:** Upgrade to [`ndk-sys 0.5.0`](../ndk-sys/CHANGELOG.md#050-2023-10-15). (#370)
|
||||||
|
- **Breaking:** Upgrade `bitflags` crate from `1` to `2`. (#394)
|
||||||
|
- bitmap: Add `try_format()` to `AndroidBitmapInfo` to handle unexpected formats without panicking. (#395)
|
||||||
|
- Add `Font` bindings. (#397)
|
||||||
|
- **Breaking:** Upgrade `num_enum` crate from `0.5.1` to `0.7`. (#398, #419)
|
||||||
|
- **Breaking:** Renamed, moved and flattened "`media`" error types and helpers to a new `media_error` module. (#399, #432)
|
||||||
|
- **Breaking:** media_codec: Wrap common dequeued-buffer status codes in enum. (#401)
|
||||||
|
- **Breaking:** media_codec: Return `MaybeUninit` bytes in `buffer_mut()`. (#403)
|
||||||
|
- native_window: Add `lock()` to blit raw pixel data. (#404)
|
||||||
|
- hardware_buffer_format: Add `YCbCr_P010` and `R8_UNORM` variants. (#405)
|
||||||
|
- **Breaking:** hardware_buffer_format: Add catch-all variant. (#407)
|
||||||
|
- asset: Add missing `is_allocated()` and `open_file_descriptor()` methods. (#409)
|
||||||
|
- **Breaking:** media_codec: Add support for asynchronous notification callbacks. (#410)
|
||||||
|
- Add panic guards to callbacks. (#412)
|
||||||
|
- looper: Add `remove_fd()` to unregister events/callbacks for a file descriptor. (#416)
|
||||||
|
- **Breaking:** Use `BorrowedFd` and `OwnedFd` to clarify possible ownership transitions. (#417)
|
||||||
|
- **Breaking:** Upgrade to [`ndk-sys 0.5.0`](../ndk-sys/CHANGELOG.md#050-2023-10-15). (#420)
|
||||||
|
- Add bindings for `sync.h`. (#423)
|
||||||
|
- **Breaking:** bitmap: Provide detailed implementation for `AndroidBitmapInfoFlags`. (#424)
|
||||||
|
- native_window: Add `set_buffers_transform()`, `try_allocate_buffers()` and `set_frame_rate*()`. (#425)
|
||||||
|
- Add bindings for `ASharedMemory`. (#427)
|
||||||
|
- hardware_buffer: Add `id()` to retrieve a system-wide unique identifier for a `HardwareBuffer`. (#428)
|
||||||
|
- **Breaking:** bitmap: Strip `Android` prefix from structs and enums, and `Bitmap` from `Result`. (#430)
|
||||||
|
- **Breaking:** `raw-window-handle 0.5` support is now behind an _optional_ `rwh_05` crate feature and `raw-window-handle` `0.4` and `0.6` support is provided via the new `rwh_04` and (default-enabled) `rwh_06` crate features. (#434)
|
||||||
|
- **Breaking:** looper: Provide `event` value to file descriptor poll callback. (#435)
|
||||||
|
- **Breaking:** `HardwareBufferFormat` is no longer exported from `hardware_buffer` and `native_window`, and can only be reached through the `hardware_buffer_format` module. (#436)
|
||||||
|
- **Breaking:** `get_` prefixes have been removed from all public functions in light of the [C-GETTER](https://rust-lang.github.io/api-guidelines/naming.html#getter-names-follow-rust-convention-c-getter) convention. (#437)
|
||||||
|
- Add `DataSpace` type and relevant functions on `Bitmap` and `NativeWindow`. (#438)
|
||||||
|
- bitmap: Add `Bitmap::compress()` and `Bitmap::compress_raw()` functions. (#440)
|
||||||
|
- **Breaking:** Turn `BitmapError` into a `non_exhaustive` `enum`. (#440)
|
||||||
|
- **Breaking:** audio: Rename `AudioErrorResult` to `AudioResult` and turn into a `non_exhaustive` `enum`. (#441)
|
||||||
|
|
||||||
|
# 0.7.0 (2022-07-24)
|
||||||
|
|
||||||
|
- hardware_buffer: Make `HardwareBuffer::as_ptr()` public for interop with Vulkan. (#213)
|
||||||
|
- **Breaking:** `Configuration::country()` now returns `None` when the country is unset (akin to `Configuration::language()`). (#220)
|
||||||
|
- Add `MediaCodec` and `MediaFormat` bindings. (#216)
|
||||||
|
- **Breaking:** Upgrade to [`ndk-sys 0.4.0`](../ndk-sys/CHANGELOG.md#040-2022-07-24) and use new `enum` newtype wrappers. (#245)
|
||||||
|
- native_window: Use `release`/`acquire` for `Drop` and `Clone` respectively. (#207)
|
||||||
|
- **Breaking:** audio: Rename from `aaudio` to `audio` and drop `A` prefix. (#273)
|
||||||
|
- Implement `HasRawWindowHandle` directly on `NativeWindow`. (#274, #319)
|
||||||
|
- **Breaking:** native_activity: Replace `CStr` return types with `Path`. (#279)
|
||||||
|
- native_window: Add `format()` getter and `set_buffers_geometry()` setter. (#276)
|
||||||
|
- native_activity: Add `set_window_format()` setter. (#277)
|
||||||
|
- native_activity: Add `set_window_flags()` to change window behavior. (#278)
|
||||||
|
- Add `SurfaceTexture` bindings. (#267)
|
||||||
|
- Improve library and structure documentation, linking back to the NDK docs more rigorously. (#290)
|
||||||
|
- **Breaking:** input_queue: `get_event()` now returns a `Result` with `std::io::Error`; `InputQueueError` has been removed. (#292)
|
||||||
|
- **Breaking:** input_queue: `has_events()` now returns a `bool` directly without being wrapped in `Result`. (#294)
|
||||||
|
- **Breaking:** hardware_buffer: `HardwareBufferError` has been removed and replaced with `std::io::Error` in return types. (#295)
|
||||||
|
- Fixed `HardwareBuffer` leak on buffers returned from `AndroidBitmap::get_hardware_buffer()`. (#296)
|
||||||
|
- Bump optional `jni` dependency for doctest example from `0.18` to `0.19`. (#300)
|
||||||
|
- hardware_buffer: Made `HardwareBufferDesc` fields `pub`. (#313)
|
||||||
|
- **Breaking:** Remove `hardware_buffer` and `trace` features in favour of using `api-level-26` or `api-level-23` directly. (#320)
|
||||||
|
|
||||||
|
# 0.6.0 (2022-01-05)
|
||||||
|
|
||||||
|
- **Breaking:** Upgrade to [`ndk-sys 0.3.0`](../ndk-sys/CHANGELOG.md#030-2022-01-05) and migrate to `jni-sys` types that it now directly uses in its bindings. (#209 / #214)
|
||||||
|
|
||||||
|
# 0.5.0 (2021-11-22)
|
||||||
|
|
||||||
|
- **Breaking:** Replace `add_fd_with_callback` `ident` with constant value `ALOOPER_POLL_CALLBACK`,
|
||||||
|
as per <https://developer.android.com/ndk/reference/group/looper#alooper_addfd>.
|
||||||
|
- **Breaking:** Accept unboxed closure in `add_fd_with_callback`.
|
||||||
|
- aaudio: Replace "Added in" comments with missing `#[cfg(feature)]`.
|
||||||
|
- aaudio: Add missing `fn get_allowed_capture_policy()`.
|
||||||
|
- configuration: Add missing `api-level-30` feature to `fn screen_round()`.
|
||||||
|
|
||||||
|
# 0.4.0 (2021-08-02)
|
||||||
|
|
||||||
|
- **Breaking:** Model looper file descriptor events integer as `bitflags`.
|
||||||
|
|
||||||
|
# 0.3.0 (2021-01-30)
|
||||||
|
|
||||||
|
- **Breaking:** Looper `ident` not passed in `data` pointer anymore.
|
||||||
|
`attach_looper` now only sets the `ident` field when attaching an
|
||||||
|
`InputQueue` to a `ForeignLooper`.
|
||||||
|
If you are relying on `Poll::Event::data` to tell event fd and
|
||||||
|
input queue apart, please use `Poll::Event::ident` and the new
|
||||||
|
constants introduced in `ndk-glue`!
|
||||||
|
|
||||||
|
# 0.2.1 (2020-10-15)
|
||||||
|
|
||||||
|
- Fix documentation build on docs.rs
|
||||||
|
|
||||||
|
# 0.2.0 (2020-09-15)
|
||||||
|
|
||||||
|
- **Breaking:** Updated to use [ndk-sys 0.2.0](../ndk-sys/CHANGELOG.md#020-2020-09-15)
|
||||||
|
- Added `media` bindings
|
||||||
|
- Added `bitmap` and `hardware_buffer` bindings
|
||||||
|
- Added `aaudio` bindings
|
||||||
|
- Fixed assets directory path to be relative to the manifest
|
||||||
|
- Added `trace` feature for native tracing
|
||||||
|
|
||||||
|
# 0.1.0 (2020-04-22)
|
||||||
|
|
||||||
|
- Initial release! 🎉
|
||||||
+125
@@ -0,0 +1,125 @@
|
|||||||
|
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
|
||||||
|
#
|
||||||
|
# When uploading crates to the registry Cargo will automatically
|
||||||
|
# "normalize" Cargo.toml files for maximal compatibility
|
||||||
|
# with all versions of Cargo and also rewrite `path` dependencies
|
||||||
|
# to registry (e.g., crates.io) dependencies.
|
||||||
|
#
|
||||||
|
# If you are reading this file be aware that the original Cargo.toml
|
||||||
|
# will likely look very different (and much more reasonable).
|
||||||
|
# See Cargo.toml.orig for the original contents.
|
||||||
|
|
||||||
|
[package]
|
||||||
|
edition = "2021"
|
||||||
|
rust-version = "1.66"
|
||||||
|
name = "ndk"
|
||||||
|
version = "0.9.0"
|
||||||
|
authors = ["The Rust Mobile contributors"]
|
||||||
|
description = "Safe Rust bindings to the Android NDK"
|
||||||
|
homepage = "https://github.com/rust-mobile/ndk"
|
||||||
|
documentation = "https://docs.rs/ndk"
|
||||||
|
readme = "README.md"
|
||||||
|
keywords = [
|
||||||
|
"android",
|
||||||
|
"ndk",
|
||||||
|
]
|
||||||
|
license = "MIT OR Apache-2.0"
|
||||||
|
repository = "https://github.com/rust-mobile/ndk"
|
||||||
|
|
||||||
|
[package.metadata.docs.rs]
|
||||||
|
features = [
|
||||||
|
"jni",
|
||||||
|
"all",
|
||||||
|
]
|
||||||
|
rustdoc-args = [
|
||||||
|
"--cfg",
|
||||||
|
"docsrs",
|
||||||
|
]
|
||||||
|
targets = [
|
||||||
|
"aarch64-linux-android",
|
||||||
|
"armv7-linux-androideabi",
|
||||||
|
"i686-linux-android",
|
||||||
|
"x86_64-linux-android",
|
||||||
|
]
|
||||||
|
|
||||||
|
[dependencies.bitflags]
|
||||||
|
version = "2.4"
|
||||||
|
|
||||||
|
[dependencies.ffi]
|
||||||
|
version = "0.6.0"
|
||||||
|
package = "ndk-sys"
|
||||||
|
|
||||||
|
[dependencies.jni]
|
||||||
|
version = "0.21"
|
||||||
|
optional = true
|
||||||
|
|
||||||
|
[dependencies.jni-sys]
|
||||||
|
version = "0.3"
|
||||||
|
|
||||||
|
[dependencies.log]
|
||||||
|
version = "0.4.6"
|
||||||
|
|
||||||
|
[dependencies.num_enum]
|
||||||
|
version = "0.7"
|
||||||
|
|
||||||
|
[dependencies.rwh_04]
|
||||||
|
version = "0.4"
|
||||||
|
optional = true
|
||||||
|
package = "raw-window-handle"
|
||||||
|
|
||||||
|
[dependencies.rwh_05]
|
||||||
|
version = "0.5"
|
||||||
|
optional = true
|
||||||
|
package = "raw-window-handle"
|
||||||
|
|
||||||
|
[dependencies.rwh_06]
|
||||||
|
version = "0.6"
|
||||||
|
optional = true
|
||||||
|
package = "raw-window-handle"
|
||||||
|
|
||||||
|
[dependencies.thiserror]
|
||||||
|
version = "1.0.23"
|
||||||
|
|
||||||
|
[dev-dependencies.libc]
|
||||||
|
version = "0.2.3"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
all = [
|
||||||
|
"audio",
|
||||||
|
"bitmap",
|
||||||
|
"media",
|
||||||
|
"nativewindow",
|
||||||
|
"sync",
|
||||||
|
"api-level-33",
|
||||||
|
"rwh_04",
|
||||||
|
"rwh_05",
|
||||||
|
"rwh_06",
|
||||||
|
]
|
||||||
|
api-level-23 = []
|
||||||
|
api-level-24 = ["api-level-23"]
|
||||||
|
api-level-25 = ["api-level-24"]
|
||||||
|
api-level-26 = ["api-level-25"]
|
||||||
|
api-level-27 = ["api-level-26"]
|
||||||
|
api-level-28 = ["api-level-27"]
|
||||||
|
api-level-29 = ["api-level-28"]
|
||||||
|
api-level-30 = ["api-level-29"]
|
||||||
|
api-level-31 = ["api-level-30"]
|
||||||
|
api-level-32 = ["api-level-31"]
|
||||||
|
api-level-33 = ["api-level-32"]
|
||||||
|
audio = [
|
||||||
|
"ffi/audio",
|
||||||
|
"api-level-26",
|
||||||
|
]
|
||||||
|
bitmap = ["ffi/bitmap"]
|
||||||
|
default = ["rwh_06"]
|
||||||
|
media = ["ffi/media"]
|
||||||
|
nativewindow = ["ffi/nativewindow"]
|
||||||
|
sync = [
|
||||||
|
"ffi/sync",
|
||||||
|
"api-level-26",
|
||||||
|
]
|
||||||
|
test = [
|
||||||
|
"ffi/test",
|
||||||
|
"jni",
|
||||||
|
"all",
|
||||||
|
]
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
[](https://github.com/rust-mobile/ndk/actions/workflows/rust.yml)  
|
||||||
|
|
||||||
|
Rust bindings to the [Android NDK](https://developer.android.com/ndk)
|
||||||
|
|
||||||
|
Name | Description | Badges
|
||||||
|
--- | --- | ---
|
||||||
|
[`ndk-sys`](./ndk-sys) | Raw FFI bindings to the NDK | [](https://crates.io/crates/ndk-sys) [](https://docs.rs/ndk-sys) [](https://blog.rust-lang.org/2022/04/07/Rust-1.60.0.html)
|
||||||
|
[`ndk`](./ndk) | Safe abstraction of the bindings | [](https://crates.io/crates/ndk) [](https://docs.rs/ndk) [](https://blog.rust-lang.org/2022/09/22/Rust-1.64.0.html)
|
||||||
|
|
||||||
|
See these [`ndk-examples`](https://github.com/rust-mobile/cargo-apk/tree/main/examples/examples) and these [`rust-android-examples`](https://github.com/rust-mobile/rust-android-examples) for examples using the NDK.
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> This repository was recently [modularized](https://github.com/rust-mobile/ndk/issues/372) and the following crates were split into separate repositories:
|
||||||
|
>
|
||||||
|
> Crate | New Location | Notes
|
||||||
|
> ------|--------------|------
|
||||||
|
> ndk-context | https://github.com/rust-mobile/ndk-context |
|
||||||
|
> ndk-glue | https://github.com/rust-mobile/ndk-glue | ⛔ _deprecated_ - see [android-activity](https://github.com/rust-mobile/android-activity)
|
||||||
|
> ndk-macro | https://github.com/rust-mobile/ndk-glue | ⛔ _deprecated_ - see [android-activity](https://github.com/rust-mobile/android-activity)
|
||||||
|
> ndk-build | https://github.com/rust-mobile/cargo-apk | ⛔ _deprecated_ - see [xbuild](https://github.com/rust-mobile/xbuild)
|
||||||
|
> cargo-apk | https://github.com/rust-mobile/cargo-apk | ⛔ _deprecated_ - see [xbuild](https://github.com/rust-mobile/xbuild)
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Vendored `ndk` 0.9.0 (patched)
|
||||||
|
|
||||||
|
Verbatim copy of the published `ndk` 0.9.0 crate (https://crates.io/crates/ndk,
|
||||||
|
MIT OR Apache-2.0, © the rust-mobile contributors), wired in via `[patch.crates-io]`
|
||||||
|
in the workspace root.
|
||||||
|
|
||||||
|
**The only change** is in `src/media/media_codec.rs`: `MediaCodec::as_ptr` is made
|
||||||
|
`pub` (upstream keeps it private) so the Android client can register
|
||||||
|
`AMediaCodec_setOnFrameRenderedCallback` through `ndk-sys` — the render-timestamp
|
||||||
|
callback behind the HUD's `display` stage (`design/stats-unification.md`), which the
|
||||||
|
wrapper doesn't expose. Grep for `punktfunk vendored patch` to find it.
|
||||||
|
|
||||||
|
Drop this vendor copy when upstream exposes the raw pointer or a frame-rendered
|
||||||
|
callback binding (tracked against https://github.com/rust-mobile/ndk).
|
||||||
+323
@@ -0,0 +1,323 @@
|
|||||||
|
//! Bindings for [`AAsset`], [`AAssetDir`] and [`AAssetManager`]
|
||||||
|
//!
|
||||||
|
//! [`AAsset`]: https://developer.android.com/ndk/reference/group/asset#aasset
|
||||||
|
//! [`AAssetDir`]: https://developer.android.com/ndk/reference/group/asset#aassetdir
|
||||||
|
//! [`AAssetManager`]: https://developer.android.com/ndk/reference/group/asset#aassetmanager
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
ffi::{CStr, CString},
|
||||||
|
io,
|
||||||
|
os::fd::{FromRawFd, OwnedFd},
|
||||||
|
ptr::NonNull,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A native [`AAssetManager *`]
|
||||||
|
///
|
||||||
|
/// [`AAssetManager *`]: https://developer.android.com/ndk/reference/group/asset#aassetmanager
|
||||||
|
#[derive(Debug)]
|
||||||
|
#[doc(alias = "AAssetManager")]
|
||||||
|
pub struct AssetManager {
|
||||||
|
ptr: NonNull<ffi::AAssetManager>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// AAssetManager is thread safe.
|
||||||
|
// See https://developer.android.com/ndk/reference/group/asset#aassetmanager
|
||||||
|
unsafe impl Send for AssetManager {}
|
||||||
|
unsafe impl Sync for AssetManager {}
|
||||||
|
|
||||||
|
impl AssetManager {
|
||||||
|
/// Create an `AssetManager` from a pointer
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// By calling this function, you assert that the pointer is a valid pointer to a native
|
||||||
|
/// `AAssetManager`.
|
||||||
|
pub unsafe fn from_ptr(ptr: NonNull<ffi::AAssetManager>) -> Self {
|
||||||
|
Self { ptr }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the pointer to the native `AAssetManager`.
|
||||||
|
pub fn ptr(&self) -> NonNull<ffi::AAssetManager> {
|
||||||
|
self.ptr
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open the asset. Returns [`None`] if opening the asset fails.
|
||||||
|
///
|
||||||
|
/// This currently always opens the asset in the streaming mode.
|
||||||
|
#[doc(alias = "AAssetManager_open")]
|
||||||
|
pub fn open(&self, filename: &CStr) -> Option<Asset> {
|
||||||
|
unsafe {
|
||||||
|
let ptr = ffi::AAssetManager_open(
|
||||||
|
self.ptr.as_ptr(),
|
||||||
|
filename.as_ptr(),
|
||||||
|
ffi::AASSET_MODE_STREAMING as i32,
|
||||||
|
);
|
||||||
|
Some(Asset::from_ptr(NonNull::new(ptr)?))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open an asset directory. Returns [`None`] if opening the directory fails.
|
||||||
|
#[doc(alias = "AAssetManager_openDir")]
|
||||||
|
pub fn open_dir(&self, filename: &CStr) -> Option<AssetDir> {
|
||||||
|
unsafe {
|
||||||
|
let ptr = ffi::AAssetManager_openDir(self.ptr.as_ptr(), filename.as_ptr());
|
||||||
|
Some(AssetDir::from_ptr(NonNull::new(ptr)?))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A native [`AAssetDir *`]
|
||||||
|
///
|
||||||
|
/// ```no_run
|
||||||
|
/// # use std::ffi::CString;
|
||||||
|
/// # use ndk::asset::AssetManager;
|
||||||
|
/// # let asset_manager: AssetManager = unimplemented!();
|
||||||
|
/// use std::io::Read;
|
||||||
|
///
|
||||||
|
/// let mut my_dir = asset_manager
|
||||||
|
/// .open_dir(&CString::new("my_dir").unwrap())
|
||||||
|
/// .expect("Could not open directory");
|
||||||
|
///
|
||||||
|
/// // Use it as an iterator
|
||||||
|
/// let all_files = my_dir.collect::<Vec<CString>>();
|
||||||
|
///
|
||||||
|
/// // Reset the iterator
|
||||||
|
/// my_dir.rewind();
|
||||||
|
///
|
||||||
|
/// // Use .with_next() to iterate without allocating `CString`s
|
||||||
|
/// while let Some(asset) = my_dir.with_next(|cstr| asset_manager.open(cstr).unwrap()) {
|
||||||
|
/// let mut text = String::new();
|
||||||
|
/// asset.read_to_string(&mut text);
|
||||||
|
/// // ...
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// [`AAssetDir *`]: https://developer.android.com/ndk/reference/group/asset#aassetdir
|
||||||
|
#[derive(Debug)]
|
||||||
|
#[doc(alias = "AAssetDir")]
|
||||||
|
pub struct AssetDir {
|
||||||
|
ptr: NonNull<ffi::AAssetDir>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// It's unclear if AAssetDir is thread safe.
|
||||||
|
// However, AAsset is not, so there's a good chance that AAssetDir is not either.
|
||||||
|
|
||||||
|
impl Drop for AssetDir {
|
||||||
|
#[doc(alias = "AAssetDir_close")]
|
||||||
|
fn drop(&mut self) {
|
||||||
|
unsafe { ffi::AAssetDir_close(self.ptr.as_ptr()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AssetDir {
|
||||||
|
/// Construct an `AssetDir` from the native `AAssetDir *`. This gives ownership of the
|
||||||
|
/// `AAssetDir *` to the `AssetDir`, which will handle closing the asset. Avoid using
|
||||||
|
/// the pointer after calling this function.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// By calling this function, you assert that it points to a valid native `AAssetDir`.
|
||||||
|
pub unsafe fn from_ptr(ptr: NonNull<ffi::AAssetDir>) -> Self {
|
||||||
|
Self { ptr }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The corresponding native `AAssetDir *`
|
||||||
|
pub fn ptr(&self) -> NonNull<ffi::AAssetDir> {
|
||||||
|
self.ptr
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the next filename, if any, and process it. Like [`Iterator::next()`], but performs
|
||||||
|
/// no additional allocation.
|
||||||
|
///
|
||||||
|
/// The filenames are in the correct format to be passed to [`AssetManager::open()`].
|
||||||
|
#[doc(alias = "AAssetDir_getNextFileName")]
|
||||||
|
pub fn with_next<T>(&mut self, f: impl for<'a> FnOnce(&'a CStr) -> T) -> Option<T> {
|
||||||
|
unsafe {
|
||||||
|
let next_name = ffi::AAssetDir_getNextFileName(self.ptr.as_ptr());
|
||||||
|
if next_name.is_null() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(f(CStr::from_ptr(next_name)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reset the iteration state
|
||||||
|
#[doc(alias = "AAssetDir_rewind")]
|
||||||
|
pub fn rewind(&mut self) {
|
||||||
|
unsafe {
|
||||||
|
ffi::AAssetDir_rewind(self.ptr.as_ptr());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Iterator for AssetDir {
|
||||||
|
type Item = CString;
|
||||||
|
|
||||||
|
fn next(&mut self) -> Option<CString> {
|
||||||
|
self.with_next(|cstr| cstr.to_owned())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A native [`AAsset *`], opened in streaming mode
|
||||||
|
///
|
||||||
|
/// ```no_run
|
||||||
|
/// # use std::ffi::CString;
|
||||||
|
/// # use ndk::asset::AssetManager;
|
||||||
|
/// # let asset_manager: AssetManager = unimplemented!();
|
||||||
|
/// use std::io::Read;
|
||||||
|
///
|
||||||
|
/// let asset = asset_manager
|
||||||
|
/// .open(&CString::new("path/to/asset").unwrap())
|
||||||
|
/// .expect("Could not open asset");
|
||||||
|
///
|
||||||
|
/// let mut data = vec![];
|
||||||
|
/// asset.read_to_end(&mut data);
|
||||||
|
/// // ... use data ...
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// [`AAsset *`]: https://developer.android.com/ndk/reference/group/asset#aasset
|
||||||
|
#[derive(Debug)]
|
||||||
|
#[doc(alias = "AAsset")]
|
||||||
|
pub struct Asset {
|
||||||
|
ptr: NonNull<ffi::AAsset>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// AAsset is *not* thread safe.
|
||||||
|
// See https://developer.android.com/ndk/reference/group/asset#aasset
|
||||||
|
|
||||||
|
impl Drop for Asset {
|
||||||
|
#[doc(alias = "AAsset_close")]
|
||||||
|
fn drop(&mut self) {
|
||||||
|
unsafe { ffi::AAsset_close(self.ptr.as_ptr()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Asset {
|
||||||
|
/// Construct an `Asset` from the native `AAsset *`. This gives ownership of the `AAsset *` to
|
||||||
|
/// the `Asset`, which will handle closing the asset. Avoid using the pointer after calling
|
||||||
|
/// this function.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// By calling this function, you assert that it points to a valid native `AAsset`, open
|
||||||
|
/// in the streaming mode.
|
||||||
|
pub unsafe fn from_ptr(ptr: NonNull<ffi::AAsset>) -> Self {
|
||||||
|
Self { ptr }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The corresponding native `AAsset *`
|
||||||
|
pub fn ptr(&self) -> NonNull<ffi::AAsset> {
|
||||||
|
self.ptr
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the total length of the asset, in bytes
|
||||||
|
#[doc(alias = "AAsset_getLength64")]
|
||||||
|
pub fn length(&self) -> usize {
|
||||||
|
unsafe { ffi::AAsset_getLength64(self.ptr.as_ptr()) as usize }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the remaining length of the asset, in bytes
|
||||||
|
#[doc(alias = "AAsset_getRemainingLength64")]
|
||||||
|
pub fn remaining_length(&self) -> usize {
|
||||||
|
unsafe { ffi::AAsset_getRemainingLength64(self.ptr.as_ptr()) as usize }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maps all data into a buffer and returns it
|
||||||
|
#[doc(alias = "AAsset_getBuffer")]
|
||||||
|
pub fn buffer(&mut self) -> io::Result<&[u8]> {
|
||||||
|
unsafe {
|
||||||
|
let buf_ptr = ffi::AAsset_getBuffer(self.ptr.as_ptr());
|
||||||
|
if buf_ptr.is_null() {
|
||||||
|
Err(io::Error::new(
|
||||||
|
io::ErrorKind::Other,
|
||||||
|
"Android Asset error creating buffer",
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
Ok(std::slice::from_raw_parts(
|
||||||
|
buf_ptr as *const u8,
|
||||||
|
self.length(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether this asset's internal buffer is allocated in ordinary RAM (i.e. not `mmap`ped).
|
||||||
|
#[doc(alias = "AAsset_isAllocated")]
|
||||||
|
pub fn is_allocated(&self) -> bool {
|
||||||
|
unsafe { ffi::AAsset_isAllocated(self.ptr.as_ptr()) != 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open a new file descriptor that can be used to read the asset data.
|
||||||
|
///
|
||||||
|
/// Returns an error if direct fd access is not possible (for example, if the asset is compressed).
|
||||||
|
#[doc(alias = "AAsset_openFileDescriptor64")]
|
||||||
|
pub fn open_file_descriptor(&self) -> io::Result<OpenedFileDescriptor> {
|
||||||
|
let mut offset = 0;
|
||||||
|
let mut size = 0;
|
||||||
|
let res =
|
||||||
|
unsafe { ffi::AAsset_openFileDescriptor64(self.ptr.as_ptr(), &mut offset, &mut size) };
|
||||||
|
if res >= 0 {
|
||||||
|
Ok(OpenedFileDescriptor {
|
||||||
|
fd: unsafe { OwnedFd::from_raw_fd(res) },
|
||||||
|
offset: offset as usize,
|
||||||
|
size: size as usize,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
Err(io::Error::new(
|
||||||
|
io::ErrorKind::Other,
|
||||||
|
"Android Asset openFileDescriptor error",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl io::Read for Asset {
|
||||||
|
#[doc(alias = "AAsset_read")]
|
||||||
|
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||||
|
unsafe {
|
||||||
|
let res = ffi::AAsset_read(self.ptr.as_ptr(), buf.as_mut_ptr() as *mut _, buf.len());
|
||||||
|
if res >= 0 {
|
||||||
|
Ok(res as usize)
|
||||||
|
} else {
|
||||||
|
Err(io::Error::new(
|
||||||
|
io::ErrorKind::Other,
|
||||||
|
"Android Asset read error",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl io::Seek for Asset {
|
||||||
|
#[doc(alias = "AAsset_seek64")]
|
||||||
|
fn seek(&mut self, seek: io::SeekFrom) -> io::Result<u64> {
|
||||||
|
unsafe {
|
||||||
|
let res = match seek {
|
||||||
|
io::SeekFrom::Start(x) => {
|
||||||
|
ffi::AAsset_seek64(self.ptr.as_ptr(), x as i64, ffi::SEEK_SET as i32)
|
||||||
|
}
|
||||||
|
io::SeekFrom::Current(x) => {
|
||||||
|
ffi::AAsset_seek64(self.ptr.as_ptr(), x, ffi::SEEK_CUR as i32)
|
||||||
|
}
|
||||||
|
io::SeekFrom::End(x) => {
|
||||||
|
ffi::AAsset_seek64(self.ptr.as_ptr(), x, ffi::SEEK_END as i32)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if res < 0 {
|
||||||
|
Err(io::Error::new(
|
||||||
|
io::ErrorKind::Other,
|
||||||
|
"Android Asset seek error",
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
Ok(res as u64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Contains the opened file descriptor returned by [`Asset::open_file_descriptor()`], together
|
||||||
|
/// with the offset and size of the given asset within that file descriptor.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct OpenedFileDescriptor {
|
||||||
|
pub fd: OwnedFd,
|
||||||
|
pub offset: usize,
|
||||||
|
pub size: usize,
|
||||||
|
}
|
||||||
+1419
File diff suppressed because it is too large
Load Diff
+489
@@ -0,0 +1,489 @@
|
|||||||
|
//! Bindings for [`AndroidBitmap`] functions
|
||||||
|
//!
|
||||||
|
//! These functions operate directly on a JNI [`android.graphics.Bitmap`] instance.
|
||||||
|
//!
|
||||||
|
//! [`AndroidBitmap`]: https://developer.android.com/ndk/reference/group/bitmap
|
||||||
|
//! [`android.graphics.Bitmap`]: https://developer.android.com/reference/android/graphics/Bitmap
|
||||||
|
#![cfg(feature = "bitmap")]
|
||||||
|
|
||||||
|
use jni_sys::{jobject, JNIEnv};
|
||||||
|
use num_enum::{FromPrimitive, IntoPrimitive};
|
||||||
|
use std::{error, fmt, mem::MaybeUninit};
|
||||||
|
|
||||||
|
#[cfg(feature = "api-level-30")]
|
||||||
|
use crate::data_space::DataSpace;
|
||||||
|
#[cfg(feature = "api-level-30")]
|
||||||
|
use crate::hardware_buffer::HardwareBufferRef;
|
||||||
|
|
||||||
|
#[repr(i32)]
|
||||||
|
#[derive(Copy, Clone, Debug, PartialEq, Eq, FromPrimitive, IntoPrimitive)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum BitmapError {
|
||||||
|
#[doc(alias = "ANDROID_BITMAP_RESULT_ALLOCATION_FAILED")]
|
||||||
|
AllocationFailed = ffi::ANDROID_BITMAP_RESULT_ALLOCATION_FAILED,
|
||||||
|
#[doc(alias = "ANDROID_BITMAP_RESULT_BAD_PARAMETER")]
|
||||||
|
BadParameter = ffi::ANDROID_BITMAP_RESULT_BAD_PARAMETER,
|
||||||
|
#[doc(alias = "ANDROID_BITMAP_RESULT_JNI_EXCEPTION")]
|
||||||
|
JniException = ffi::ANDROID_BITMAP_RESULT_JNI_EXCEPTION,
|
||||||
|
|
||||||
|
// Use the SUCCESS discriminant, as no-one will be able to call `as i32` and only has access to
|
||||||
|
// the constants via `From` provided by `IntoPrimitive` which reads the contained value.
|
||||||
|
// An autogenerated `<previous variant> + 1` discriminant is normally fine, except that the
|
||||||
|
// previous variant is negative and `+ 1` would match the variant before that.
|
||||||
|
#[doc(hidden)]
|
||||||
|
#[num_enum(catch_all)]
|
||||||
|
__Unknown(i32) = ffi::ANDROID_BITMAP_RESULT_SUCCESS,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for BitmapError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "{:?}", self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl error::Error for BitmapError {}
|
||||||
|
|
||||||
|
pub type Result<T, E = BitmapError> = std::result::Result<T, E>;
|
||||||
|
|
||||||
|
impl BitmapError {
|
||||||
|
pub(crate) fn from_status(status: i32) -> Result<()> {
|
||||||
|
match status {
|
||||||
|
ffi::ANDROID_BITMAP_RESULT_SUCCESS => Ok(()),
|
||||||
|
x => Err(Self::from(x)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn construct<T>(with_ptr: impl FnOnce(*mut T) -> i32) -> Result<T> {
|
||||||
|
let mut result = MaybeUninit::uninit();
|
||||||
|
let status = with_ptr(result.as_mut_ptr());
|
||||||
|
BitmapError::from_status(status).map(|()| unsafe { result.assume_init() })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(i32)]
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, IntoPrimitive, FromPrimitive)]
|
||||||
|
#[allow(non_camel_case_types)]
|
||||||
|
#[doc(alias = "AndroidBitmapFormat")]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum BitmapFormat {
|
||||||
|
#[doc(alias = "ANDROID_BITMAP_FORMAT_NONE")]
|
||||||
|
NONE = ffi::AndroidBitmapFormat::ANDROID_BITMAP_FORMAT_NONE.0 as i32,
|
||||||
|
#[doc(alias = "ANDROID_BITMAP_FORMAT_RGBA_8888")]
|
||||||
|
RGBA_8888 = ffi::AndroidBitmapFormat::ANDROID_BITMAP_FORMAT_RGBA_8888.0 as i32,
|
||||||
|
#[doc(alias = "ANDROID_BITMAP_FORMAT_RGB_565")]
|
||||||
|
RGB_565 = ffi::AndroidBitmapFormat::ANDROID_BITMAP_FORMAT_RGB_565.0 as i32,
|
||||||
|
#[deprecated = "Deprecated in API level 13. Because of the poor quality of this configuration, it is advised to use ARGB_8888 instead."]
|
||||||
|
#[doc(alias = "ANDROID_BITMAP_FORMAT_RGBA_4444")]
|
||||||
|
RGBA_4444 = ffi::AndroidBitmapFormat::ANDROID_BITMAP_FORMAT_RGBA_4444.0 as i32,
|
||||||
|
#[doc(alias = "ANDROID_BITMAP_FORMAT_A_8")]
|
||||||
|
A_8 = ffi::AndroidBitmapFormat::ANDROID_BITMAP_FORMAT_A_8.0 as i32,
|
||||||
|
#[doc(alias = "ANDROID_BITMAP_FORMAT_RGBA_F16")]
|
||||||
|
RGBA_F16 = ffi::AndroidBitmapFormat::ANDROID_BITMAP_FORMAT_RGBA_F16.0 as i32,
|
||||||
|
#[doc(alias = "ANDROID_BITMAP_FORMAT_RGBA_1010102")]
|
||||||
|
RGBA_1010102 = ffi::AndroidBitmapFormat::ANDROID_BITMAP_FORMAT_RGBA_1010102.0 as i32,
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
#[num_enum(catch_all)]
|
||||||
|
__Unknown(i32),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An immediate wrapper over [`android.graphics.Bitmap`]
|
||||||
|
///
|
||||||
|
/// [`android.graphics.Bitmap`]: https://developer.android.com/reference/android/graphics/Bitmap
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Bitmap {
|
||||||
|
env: *mut JNIEnv,
|
||||||
|
inner: jobject,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Bitmap {
|
||||||
|
/// Create a [`Bitmap`] wrapper from JNI pointers
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// This function should be called with a healthy JVM pointer and with a non-null
|
||||||
|
/// [`android.graphics.Bitmap`], which must be kept alive on the Java/Kotlin side.
|
||||||
|
///
|
||||||
|
/// [`android.graphics.Bitmap`]: https://developer.android.com/reference/android/graphics/Bitmap
|
||||||
|
pub unsafe fn from_jni(env: *mut JNIEnv, bitmap: jobject) -> Self {
|
||||||
|
Self { env, inner: bitmap }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fills out and returns the [`BitmapInfo`] struct for the given Java bitmap object.
|
||||||
|
#[doc(alias = "AndroidBitmap_getInfo")]
|
||||||
|
pub fn info(&self) -> Result<BitmapInfo> {
|
||||||
|
let inner =
|
||||||
|
construct(|res| unsafe { ffi::AndroidBitmap_getInfo(self.env, self.inner, res) })?;
|
||||||
|
|
||||||
|
Ok(BitmapInfo { inner })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the [`DataSpace`] of this [`Bitmap`].
|
||||||
|
///
|
||||||
|
/// Note that [`DataSpace`] only exposes a few values. This may return [`DataSpace::Unknown`],
|
||||||
|
/// even for Named ColorSpaces, if they have no corresponding [`DataSpace`].
|
||||||
|
#[cfg(feature = "api-level-30")]
|
||||||
|
#[doc(alias = "AndroidBitmap_getDataSpace")]
|
||||||
|
pub fn data_space(&self) -> DataSpace {
|
||||||
|
let value = unsafe { ffi::AndroidBitmap_getDataSpace(self.env, self.inner) };
|
||||||
|
value.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attempt to lock the pixel address.
|
||||||
|
///
|
||||||
|
/// Locking will ensure that the memory for the pixels will not move until the
|
||||||
|
/// [`Bitmap::unlock_pixels()`] call, and ensure that, if the pixels had been previously purged,
|
||||||
|
/// they will have been restored.
|
||||||
|
///
|
||||||
|
/// If this call succeeds, it must be balanced by a call to [`Bitmap::unlock_pixels()`], after
|
||||||
|
/// which time the address of the pixels should no longer be used.
|
||||||
|
#[doc(alias = "AndroidBitmap_lockPixels")]
|
||||||
|
pub fn lock_pixels(&self) -> Result<*mut std::os::raw::c_void> {
|
||||||
|
construct(|res| unsafe { ffi::AndroidBitmap_lockPixels(self.env, self.inner, res) })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Call this to balance a successful call to [`Bitmap::lock_pixels()`].
|
||||||
|
#[doc(alias = "AndroidBitmap_unlockPixels")]
|
||||||
|
pub fn unlock_pixels(&self) -> Result<()> {
|
||||||
|
let status = unsafe { ffi::AndroidBitmap_unlockPixels(self.env, self.inner) };
|
||||||
|
BitmapError::from_status(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieve the native object associated with an [`ffi::ANDROID_BITMAP_FLAGS_IS_HARDWARE`]
|
||||||
|
/// [`Bitmap`] (requires [`BitmapInfoFlags::is_hardware()`] on [`BitmapInfo::flags()`] to return
|
||||||
|
/// [`true`]).
|
||||||
|
///
|
||||||
|
/// Client must not modify it while a [`Bitmap`] is wrapping it.
|
||||||
|
#[cfg(feature = "api-level-30")]
|
||||||
|
#[doc(alias = "AndroidBitmap_getHardwareBuffer")]
|
||||||
|
pub fn hardware_buffer(&self) -> Result<HardwareBufferRef> {
|
||||||
|
unsafe {
|
||||||
|
let result =
|
||||||
|
construct(|res| ffi::AndroidBitmap_getHardwareBuffer(self.env, self.inner, res))?;
|
||||||
|
let non_null = if cfg!(debug_assertions) {
|
||||||
|
std::ptr::NonNull::new(result).expect("result should never be null")
|
||||||
|
} else {
|
||||||
|
std::ptr::NonNull::new_unchecked(result)
|
||||||
|
};
|
||||||
|
Ok(HardwareBufferRef::from_ptr(non_null))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [Lock] the pixels in `self` and compress them as described by [`info()`].
|
||||||
|
///
|
||||||
|
/// Unlike [`compress_raw()`] this requires a [`Bitmap`] object (as `self`) backed by a
|
||||||
|
/// [`jobject`].
|
||||||
|
///
|
||||||
|
/// # Parameters
|
||||||
|
/// - `format`: [`BitmapCompressFormat`] to compress to.
|
||||||
|
/// - `quality`: Hint to the compressor, `0-100`. The value is interpreted differently
|
||||||
|
/// depending on [`BitmapCompressFormat`].
|
||||||
|
/// - `compress_callback`: Closure that writes the compressed data. Will be called on the
|
||||||
|
/// current thread, each time the compressor has compressed more data that is ready to be
|
||||||
|
/// written. May be called more than once for each call to this method.
|
||||||
|
///
|
||||||
|
/// [Lock]: Self::lock_pixels()
|
||||||
|
/// [`info()`]: Self::info()
|
||||||
|
/// [`compress_raw()`]: Self::compress_raw()
|
||||||
|
#[cfg(feature = "api-level-30")]
|
||||||
|
#[doc(alias = "AndroidBitmap_compress")]
|
||||||
|
pub fn compress<F: FnMut(&[u8]) -> Result<(), ()>>(
|
||||||
|
&self,
|
||||||
|
format: BitmapCompressFormat,
|
||||||
|
quality: i32,
|
||||||
|
compress_callback: F,
|
||||||
|
) -> Result<(), BitmapCompressError> {
|
||||||
|
let info = self.info()?;
|
||||||
|
let data_space = self.data_space();
|
||||||
|
let pixels = self.lock_pixels()?;
|
||||||
|
// SAFETY: When lock_pixels() succeeds, assume it returns a valid pointer that stays
|
||||||
|
// valid until we call unlock_pixels().
|
||||||
|
let result = unsafe {
|
||||||
|
Self::compress_raw(
|
||||||
|
&info,
|
||||||
|
data_space,
|
||||||
|
pixels,
|
||||||
|
format,
|
||||||
|
quality,
|
||||||
|
compress_callback,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
self.unlock_pixels()?;
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compress `pixels` as described by `info`.
|
||||||
|
///
|
||||||
|
/// Unlike [`compress()`] this takes a raw pointer to pixels and does not need a [`Bitmap`]
|
||||||
|
/// object backed by a [`jobject`].
|
||||||
|
///
|
||||||
|
/// # Parameters
|
||||||
|
/// - `info`: Description of the pixels to compress.
|
||||||
|
/// - `data_space`: [`DataSpace`] describing the color space of the pixels. Should _not_ be
|
||||||
|
/// [`DataSpace::Unknown`] [^1].
|
||||||
|
/// - `pixels`: Pointer to pixels to compress.
|
||||||
|
/// - `format`: [`BitmapCompressFormat`] to compress to.
|
||||||
|
/// - `quality`: Hint to the compressor, `0-100`. The value is interpreted differently
|
||||||
|
/// depending on [`BitmapCompressFormat`].
|
||||||
|
/// - `compress_callback`: Closure that writes the compressed data. Will be called on the
|
||||||
|
/// current thread, each time the compressor has compressed more data that is ready to be
|
||||||
|
/// written. May be called more than once for each call to this method.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `pixels` must point to a valid buffer that matches the size, stride and format in `info`.
|
||||||
|
///
|
||||||
|
/// [`compress()`]: Self::compress()
|
||||||
|
/// [^1]: <https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/libs/hwui/apex/android_bitmap.cpp;l=275-279;drc=7ba5c2fb3d1e35eb37a9cc522b30ba51f49ea491>
|
||||||
|
#[cfg(feature = "api-level-30")]
|
||||||
|
#[doc(alias = "AndroidBitmap_compress")]
|
||||||
|
pub unsafe fn compress_raw<F: FnMut(&[u8]) -> Result<(), ()>>(
|
||||||
|
info: &BitmapInfo,
|
||||||
|
data_space: DataSpace,
|
||||||
|
pixels: *const std::ffi::c_void,
|
||||||
|
format: BitmapCompressFormat,
|
||||||
|
quality: i32,
|
||||||
|
compress_callback: F,
|
||||||
|
) -> Result<(), BitmapCompressError> {
|
||||||
|
if data_space == DataSpace::Unknown {
|
||||||
|
return Err(BitmapCompressError::DataSpaceUnknown);
|
||||||
|
}
|
||||||
|
|
||||||
|
use std::{any::Any, ffi::c_void, panic::AssertUnwindSafe};
|
||||||
|
struct CallbackState<F: FnMut(&[u8]) -> Result<(), ()>> {
|
||||||
|
callback: F,
|
||||||
|
panic: Option<Box<dyn Any + Send>>,
|
||||||
|
}
|
||||||
|
let mut cb_state = CallbackState::<F> {
|
||||||
|
callback: compress_callback,
|
||||||
|
panic: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
extern "C" fn compress_cb<F: FnMut(&[u8]) -> Result<(), ()>>(
|
||||||
|
context: *mut c_void,
|
||||||
|
data: *const c_void,
|
||||||
|
size: usize,
|
||||||
|
) -> bool {
|
||||||
|
// SAFETY: This callback will only be called serially on a single thread. Both the
|
||||||
|
// panic state and the FnMut context need to be available mutably.
|
||||||
|
let cb_state = unsafe { context.cast::<CallbackState<F>>().as_mut() }.unwrap();
|
||||||
|
let data = unsafe { std::slice::from_raw_parts(data.cast(), size) };
|
||||||
|
let panic = std::panic::catch_unwind(AssertUnwindSafe(|| (cb_state.callback)(data)));
|
||||||
|
match panic {
|
||||||
|
Ok(r) => r.is_ok(),
|
||||||
|
Err(e) => {
|
||||||
|
cb_state.panic = Some(e);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let status = unsafe {
|
||||||
|
ffi::AndroidBitmap_compress(
|
||||||
|
&info.inner,
|
||||||
|
data_space.into(),
|
||||||
|
pixels,
|
||||||
|
format.into(),
|
||||||
|
quality,
|
||||||
|
<*mut _>::cast(&mut cb_state),
|
||||||
|
Some(compress_cb::<F>),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(panic) = cb_state.panic {
|
||||||
|
std::panic::resume_unwind(panic)
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(BitmapError::from_status(status)?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Possible values for [`ffi::ANDROID_BITMAP_FLAGS_ALPHA_MASK`] within [`BitmapInfoFlags`]
|
||||||
|
#[repr(u32)]
|
||||||
|
#[cfg(feature = "api-level-30")]
|
||||||
|
#[derive(Clone, Copy, Debug, IntoPrimitive, FromPrimitive)]
|
||||||
|
#[doc(alias = "ANDROID_BITMAP_FLAGS_ALPHA_MASK")]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum BitmapInfoFlagsAlpha {
|
||||||
|
/// Pixel components are premultiplied by alpha.
|
||||||
|
#[doc(alias = "ANDROID_BITMAP_FLAGS_ALPHA_PREMUL")]
|
||||||
|
Premultiplied = ffi::ANDROID_BITMAP_FLAGS_ALPHA_PREMUL,
|
||||||
|
/// Pixels are opaque.
|
||||||
|
#[doc(alias = "ANDROID_BITMAP_FLAGS_ALPHA_OPAQUE")]
|
||||||
|
Opaque = ffi::ANDROID_BITMAP_FLAGS_ALPHA_OPAQUE,
|
||||||
|
/// Pixel components are independent of alpha.
|
||||||
|
#[doc(alias = "ANDROID_BITMAP_FLAGS_ALPHA_UNPREMUL")]
|
||||||
|
Unpremultiplied = ffi::ANDROID_BITMAP_FLAGS_ALPHA_UNPREMUL,
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
#[num_enum(catch_all)]
|
||||||
|
__Unknown(u32),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bitfield containing information about the bitmap.
|
||||||
|
#[cfg(feature = "api-level-30")]
|
||||||
|
#[repr(transparent)]
|
||||||
|
#[derive(Clone, Copy, Hash, PartialEq, Eq)]
|
||||||
|
pub struct BitmapInfoFlags(u32);
|
||||||
|
|
||||||
|
#[cfg(feature = "api-level-30")]
|
||||||
|
impl std::fmt::Debug for BitmapInfoFlags {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"BitmapInfoFlags({:#x}, alpha: {:?}, is_hardware: {})",
|
||||||
|
self.0,
|
||||||
|
self.alpha(),
|
||||||
|
self.is_hardware()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "api-level-30")]
|
||||||
|
impl BitmapInfoFlags {
|
||||||
|
/// Returns the alpha value contained in the [`ffi::ANDROID_BITMAP_FLAGS_ALPHA_MASK`] bit range
|
||||||
|
#[doc(alias = "ANDROID_BITMAP_FLAGS_ALPHA_MASK")]
|
||||||
|
pub fn alpha(self) -> BitmapInfoFlagsAlpha {
|
||||||
|
// Note that ffi::ANDROID_BITMAP_FLAGS_ALPHA_SHIFT is 0 and hence irrelevant.
|
||||||
|
(self.0 & ffi::ANDROID_BITMAP_FLAGS_ALPHA_MASK).into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns [`true`] when [`ffi::ANDROID_BITMAP_FLAGS_IS_HARDWARE`] is set, meaning this
|
||||||
|
/// [`Bitmap`] uses "HARDWARE Config" and its [`HardwareBufferRef`] can be retrieved via
|
||||||
|
/// [`Bitmap::hardware_buffer()`].
|
||||||
|
#[doc(alias = "ANDROID_BITMAP_FLAGS_IS_HARDWARE")]
|
||||||
|
pub fn is_hardware(self) -> bool {
|
||||||
|
// This constant is defined in a separate anonymous enum which bindgen treats as i32.
|
||||||
|
(self.0 & ffi::ANDROID_BITMAP_FLAGS_IS_HARDWARE as u32) != 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A native [`AndroidBitmapInfo`]
|
||||||
|
///
|
||||||
|
/// [`AndroidBitmapInfo`]: https://developer.android.com/ndk/reference/struct/android-bitmap-info#struct_android_bitmap_info
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
#[doc(alias = "AndroidBitmapInfo")]
|
||||||
|
pub struct BitmapInfo {
|
||||||
|
inner: ffi::AndroidBitmapInfo,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for BitmapInfo {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
let mut f = f.debug_struct("BitmapInfo");
|
||||||
|
f.field("width", &self.width())
|
||||||
|
.field("height", &self.height())
|
||||||
|
.field("stride", &self.stride())
|
||||||
|
.field("format", &self.format());
|
||||||
|
|
||||||
|
#[cfg(feature = "api-level-30")]
|
||||||
|
f.field("flags", &self.flags());
|
||||||
|
|
||||||
|
f.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BitmapInfo {
|
||||||
|
pub fn new(width: u32, height: u32, stride: u32, format: BitmapFormat) -> Self {
|
||||||
|
Self {
|
||||||
|
inner: ffi::AndroidBitmapInfo {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
stride,
|
||||||
|
format: format.into(),
|
||||||
|
flags: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "api-level-30")]
|
||||||
|
pub fn new_with_flags(
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
stride: u32,
|
||||||
|
format: BitmapFormat,
|
||||||
|
flags: BitmapInfoFlags,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
inner: ffi::AndroidBitmapInfo {
|
||||||
|
flags: flags.0,
|
||||||
|
..Self::new(width, height, stride, format).inner
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bitmap width in pixels.
|
||||||
|
pub fn width(&self) -> u32 {
|
||||||
|
self.inner.width
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bitmap height in pixels.
|
||||||
|
pub fn height(&self) -> u32 {
|
||||||
|
self.inner.height
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The number of byte per row.
|
||||||
|
pub fn stride(&self) -> u32 {
|
||||||
|
self.inner.stride
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert the internal, native [`ffi::AndroidBitmapInfo::format`] into a [`BitmapFormat`].
|
||||||
|
pub fn format(&self) -> BitmapFormat {
|
||||||
|
self.inner.format.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bitfield containing information about the bitmap.
|
||||||
|
#[cfg(feature = "api-level-30")]
|
||||||
|
pub fn flags(&self) -> BitmapInfoFlags {
|
||||||
|
BitmapInfoFlags(self.inner.flags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Specifies the formats that can be compressed to with [`Bitmap::compress()`] and
|
||||||
|
/// [`Bitmap::compress_raw()`].
|
||||||
|
#[cfg(feature = "api-level-30")]
|
||||||
|
#[repr(i32)]
|
||||||
|
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, FromPrimitive, IntoPrimitive)]
|
||||||
|
#[doc(alias = "AndroidBitmapCompressFormat")]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum BitmapCompressFormat {
|
||||||
|
/// Compress to the JPEG format.
|
||||||
|
///
|
||||||
|
/// quality of `0` means compress for the smallest size. `100` means compress for max visual
|
||||||
|
/// quality.
|
||||||
|
#[doc(alias = "ANDROID_BITMAP_COMPRESS_FORMAT_JPEG")]
|
||||||
|
Jpeg = ffi::AndroidBitmapCompressFormat::ANDROID_BITMAP_COMPRESS_FORMAT_JPEG.0 as i32,
|
||||||
|
/// Compress to the PNG format.
|
||||||
|
///
|
||||||
|
/// PNG is lossless, so quality is ignored.
|
||||||
|
#[doc(alias = "ANDROID_BITMAP_COMPRESS_FORMAT_PNG")]
|
||||||
|
Png = ffi::AndroidBitmapCompressFormat::ANDROID_BITMAP_COMPRESS_FORMAT_PNG.0 as i32,
|
||||||
|
/// Compress to the WEBP lossless format.
|
||||||
|
///
|
||||||
|
/// quality refers to how much effort to put into compression. A value of `0` means to
|
||||||
|
/// compress quickly, resulting in a relatively large file size. `100` means to spend more time
|
||||||
|
/// compressing, resulting in a smaller file.
|
||||||
|
#[doc(alias = "ANDROID_BITMAP_COMPRESS_FORMAT_WEBP_LOSSY")]
|
||||||
|
WebPLossy =
|
||||||
|
ffi::AndroidBitmapCompressFormat::ANDROID_BITMAP_COMPRESS_FORMAT_WEBP_LOSSY.0 as i32,
|
||||||
|
/// Compress to the WEBP lossy format.
|
||||||
|
///
|
||||||
|
/// quality of `0` means compress for the smallest size. `100` means compress for max visual quality.
|
||||||
|
#[doc(alias = "ANDROID_BITMAP_COMPRESS_FORMAT_WEBP_LOSSLESS")]
|
||||||
|
WebPLossless =
|
||||||
|
ffi::AndroidBitmapCompressFormat::ANDROID_BITMAP_COMPRESS_FORMAT_WEBP_LOSSLESS.0 as i32,
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
#[num_enum(catch_all)]
|
||||||
|
__Unknown(i32),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encapsulates possible errors returned by [`Bitmap::compress()`] or [`Bitmap::compress_raw()`].
|
||||||
|
#[cfg(feature = "api-level-30")]
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum BitmapCompressError {
|
||||||
|
#[error(transparent)]
|
||||||
|
BitmapError(#[from] BitmapError),
|
||||||
|
/// [`Bitmap`] compression requires a known [`DataSpace`]. [`DataSpace::Unknown`] is invalid
|
||||||
|
/// even though it is typically treated as `sRGB`, for that [`DataSpace::Srgb`] has to be passed
|
||||||
|
/// explicitly.
|
||||||
|
#[error("The dataspace for this Bitmap is Unknown")]
|
||||||
|
DataSpaceUnknown,
|
||||||
|
}
|
||||||
@@ -0,0 +1,600 @@
|
|||||||
|
//! Bindings for [`AConfiguration`]
|
||||||
|
//!
|
||||||
|
//! See also the [NDK docs](https://developer.android.com/ndk/reference/group/configuration) for
|
||||||
|
//! [`AConfiguration`], as well as the [docs for providing
|
||||||
|
//! resources](https://developer.android.com/guide/topics/resources/providing-resources.html),
|
||||||
|
//! which explain many of the configuration values. The [`android.content.res.Configuration`
|
||||||
|
//! javadoc](https://developer.android.com/reference/android/content/res/Configuration.html) may
|
||||||
|
//! also have useful information.
|
||||||
|
//!
|
||||||
|
//! [`AConfiguration`]: https://developer.android.com/ndk/reference/group/configuration#aconfiguration
|
||||||
|
|
||||||
|
use crate::asset::AssetManager;
|
||||||
|
use num_enum::{FromPrimitive, IntoPrimitive};
|
||||||
|
use std::fmt;
|
||||||
|
use std::ptr::NonNull;
|
||||||
|
|
||||||
|
/// A native [`AConfiguration *`]
|
||||||
|
///
|
||||||
|
/// [`Configuration`] is an opaque type used to get and set various subsystem configurations.
|
||||||
|
///
|
||||||
|
/// [`AConfiguration *`]: https://developer.android.com/ndk/reference/group/configuration#aconfiguration
|
||||||
|
pub struct Configuration {
|
||||||
|
ptr: NonNull<ffi::AConfiguration>,
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe impl Send for Configuration {}
|
||||||
|
unsafe impl Sync for Configuration {}
|
||||||
|
|
||||||
|
impl Drop for Configuration {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
unsafe { ffi::AConfiguration_delete(self.ptr.as_ptr()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Clone for Configuration {
|
||||||
|
fn clone(&self) -> Self {
|
||||||
|
let mut new = Self::new();
|
||||||
|
new.copy(self);
|
||||||
|
new
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PartialEq for Configuration {
|
||||||
|
fn eq(&self, other: &Self) -> bool {
|
||||||
|
self.diff(other).0 == 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Eq for Configuration {}
|
||||||
|
|
||||||
|
impl fmt::Debug for Configuration {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.debug_struct("Configuration")
|
||||||
|
.field("mcc", &self.mcc())
|
||||||
|
.field("mnc", &self.mnc())
|
||||||
|
.field("lang", &self.language())
|
||||||
|
.field("country", &self.country())
|
||||||
|
.field("orientation", &self.orientation())
|
||||||
|
.field("touchscreen", &self.touchscreen())
|
||||||
|
.field("density", &self.density())
|
||||||
|
.field("keyboard", &self.keyboard())
|
||||||
|
.field("navigation", &self.navigation())
|
||||||
|
.field("keys_hidden", &self.keys_hidden())
|
||||||
|
.field("nav_hidden", &self.nav_hidden())
|
||||||
|
.field("sdk_version", &self.sdk_version())
|
||||||
|
.field("screen_size", &self.screen_size())
|
||||||
|
.field("screen_long", &self.screen_long())
|
||||||
|
.field("ui_mode_type", &self.ui_mode_type())
|
||||||
|
.field("ui_mode_night", &self.ui_mode_night())
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Configuration {
|
||||||
|
/// Construct a `Configuration` from a pointer.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// By calling this function, you assert that it is a valid pointer to a native
|
||||||
|
/// `AConfiguration`, and give ownership of it to the `Configuration` instance.
|
||||||
|
pub unsafe fn from_ptr(ptr: NonNull<ffi::AConfiguration>) -> Self {
|
||||||
|
Self { ptr }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new `Configuration`, with the same contents as the `AConfiguration` referenced by
|
||||||
|
/// the pointer.
|
||||||
|
///
|
||||||
|
/// This is useful if you have a pointer, but not ownership of it.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// By calling this function, you assert that it is a valid pointer to a native
|
||||||
|
/// `AConfiguration`.
|
||||||
|
pub unsafe fn clone_from_ptr(ptr: NonNull<ffi::AConfiguration>) -> Self {
|
||||||
|
let conf = Self::new();
|
||||||
|
ffi::AConfiguration_copy(conf.ptr.as_ptr(), ptr.as_ptr());
|
||||||
|
conf
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pointer to the native `AConfiguration`. Keep in mind that the `Configuration` object
|
||||||
|
/// still has ownership, and will free it when dropped.
|
||||||
|
pub fn ptr(&self) -> NonNull<ffi::AConfiguration> {
|
||||||
|
self.ptr
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_asset_manager(am: &AssetManager) -> Self {
|
||||||
|
let config = Self::new();
|
||||||
|
unsafe {
|
||||||
|
ffi::AConfiguration_fromAssetManager(config.ptr().as_mut(), am.ptr().as_mut());
|
||||||
|
}
|
||||||
|
config
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new `Configuration`, with none of the values set.
|
||||||
|
pub fn new() -> Self {
|
||||||
|
unsafe {
|
||||||
|
Self {
|
||||||
|
ptr: NonNull::new(ffi::AConfiguration_new()).unwrap(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `dest.copy(&src)` copies the contents of `src` to `dest`
|
||||||
|
pub fn copy(&mut self, other: &Self) {
|
||||||
|
unsafe { ffi::AConfiguration_copy(self.ptr.as_ptr(), other.ptr.as_ptr()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Information about what fields differ between the two configurations
|
||||||
|
pub fn diff(&self, other: &Self) -> DiffResult {
|
||||||
|
unsafe {
|
||||||
|
DiffResult(ffi::AConfiguration_diff(self.ptr.as_ptr(), other.ptr.as_ptr()) as u32)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns false if anything in `self` conflicts with `requested`
|
||||||
|
pub fn matches(&self, requested: &Self) -> bool {
|
||||||
|
unsafe { ffi::AConfiguration_match(self.ptr.as_ptr(), requested.ptr.as_ptr()) != 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the country code, as a [`String`] of two characters, if set
|
||||||
|
pub fn country(&self) -> Option<String> {
|
||||||
|
let mut chars = [0u8; 2];
|
||||||
|
unsafe {
|
||||||
|
ffi::AConfiguration_getCountry(self.ptr.as_ptr(), chars.as_mut_ptr().cast());
|
||||||
|
}
|
||||||
|
if chars[0] == 0 {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(std::str::from_utf8(chars.as_slice()).unwrap().to_owned())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the screen density in dpi.
|
||||||
|
///
|
||||||
|
/// On some devices it can return values outside of the density enum.
|
||||||
|
pub fn density(&self) -> Option<u32> {
|
||||||
|
let density = unsafe { ffi::AConfiguration_getDensity(self.ptr.as_ptr()) as u32 };
|
||||||
|
match density {
|
||||||
|
ffi::ACONFIGURATION_DENSITY_DEFAULT => Some(160),
|
||||||
|
ffi::ACONFIGURATION_DENSITY_ANY => None,
|
||||||
|
ffi::ACONFIGURATION_DENSITY_NONE => None,
|
||||||
|
density => Some(density),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the keyboard type.
|
||||||
|
pub fn keyboard(&self) -> Keyboard {
|
||||||
|
unsafe { ffi::AConfiguration_getKeyboard(self.ptr.as_ptr()).into() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns keyboard visibility/availability.
|
||||||
|
pub fn keys_hidden(&self) -> KeysHidden {
|
||||||
|
unsafe { ffi::AConfiguration_getKeysHidden(self.ptr.as_ptr()).into() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the language, as a [`String`] of two characters, if set
|
||||||
|
pub fn language(&self) -> Option<String> {
|
||||||
|
let mut chars = [0u8; 2];
|
||||||
|
unsafe {
|
||||||
|
ffi::AConfiguration_getLanguage(self.ptr.as_ptr(), chars.as_mut_ptr().cast());
|
||||||
|
}
|
||||||
|
if chars[0] == 0 {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(std::str::from_utf8(chars.as_slice()).unwrap().to_owned())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the layout direction
|
||||||
|
pub fn layout_direction(&self) -> LayoutDir {
|
||||||
|
unsafe { ffi::AConfiguration_getLayoutDirection(self.ptr.as_ptr()).into() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the mobile country code.
|
||||||
|
pub fn mcc(&self) -> i32 {
|
||||||
|
unsafe { ffi::AConfiguration_getMcc(self.ptr.as_ptr()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the mobile network code, if one is defined
|
||||||
|
pub fn mnc(&self) -> Option<i32> {
|
||||||
|
unsafe {
|
||||||
|
match ffi::AConfiguration_getMnc(self.ptr.as_ptr()) {
|
||||||
|
0 => None,
|
||||||
|
x if x == ffi::ACONFIGURATION_MNC_ZERO as i32 => Some(0),
|
||||||
|
x => Some(x),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn nav_hidden(&self) -> NavHidden {
|
||||||
|
unsafe { ffi::AConfiguration_getNavHidden(self.ptr.as_ptr()).into() }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn navigation(&self) -> Navigation {
|
||||||
|
unsafe { ffi::AConfiguration_getNavigation(self.ptr.as_ptr()).into() }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn orientation(&self) -> Orientation {
|
||||||
|
unsafe { ffi::AConfiguration_getOrientation(self.ptr.as_ptr()).into() }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn screen_height_dp(&self) -> Option<i32> {
|
||||||
|
unsafe {
|
||||||
|
let height = ffi::AConfiguration_getScreenHeightDp(self.ptr.as_ptr());
|
||||||
|
if height == ffi::ACONFIGURATION_SCREEN_HEIGHT_DP_ANY as i32 {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn screen_width_dp(&self) -> Option<i32> {
|
||||||
|
unsafe {
|
||||||
|
let width = ffi::AConfiguration_getScreenWidthDp(self.ptr.as_ptr());
|
||||||
|
if width == ffi::ACONFIGURATION_SCREEN_WIDTH_DP_ANY as i32 {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(width)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn screen_long(&self) -> ScreenLong {
|
||||||
|
unsafe { ffi::AConfiguration_getScreenLong(self.ptr.as_ptr()).into() }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "api-level-30")]
|
||||||
|
pub fn screen_round(&self) -> ScreenRound {
|
||||||
|
unsafe { ffi::AConfiguration_getScreenRound(self.ptr.as_ptr()).into() }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn screen_size(&self) -> ScreenSize {
|
||||||
|
unsafe { ffi::AConfiguration_getScreenSize(self.ptr.as_ptr()).into() }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sdk_version(&self) -> i32 {
|
||||||
|
unsafe { ffi::AConfiguration_getSdkVersion(self.ptr.as_ptr()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn smallest_screen_width_dp(&self) -> Option<i32> {
|
||||||
|
unsafe {
|
||||||
|
let width = ffi::AConfiguration_getSmallestScreenWidthDp(self.ptr.as_ptr());
|
||||||
|
if width == ffi::ACONFIGURATION_SMALLEST_SCREEN_WIDTH_DP_ANY as i32 {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(width)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn touchscreen(&self) -> Touchscreen {
|
||||||
|
unsafe { ffi::AConfiguration_getTouchscreen(self.ptr.as_ptr()).into() }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ui_mode_night(&self) -> UiModeNight {
|
||||||
|
unsafe { ffi::AConfiguration_getUiModeNight(self.ptr.as_ptr()).into() }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ui_mode_type(&self) -> UiModeType {
|
||||||
|
unsafe { ffi::AConfiguration_getUiModeType(self.ptr.as_ptr()).into() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A bitfield representing the differences between two [`Configuration`]s
|
||||||
|
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct DiffResult(pub u32);
|
||||||
|
|
||||||
|
impl DiffResult {
|
||||||
|
pub fn mcc(self) -> bool {
|
||||||
|
self.0 & ffi::ACONFIGURATION_MCC != 0
|
||||||
|
}
|
||||||
|
pub fn mnc(self) -> bool {
|
||||||
|
self.0 & ffi::ACONFIGURATION_MNC != 0
|
||||||
|
}
|
||||||
|
pub fn locale(self) -> bool {
|
||||||
|
self.0 & ffi::ACONFIGURATION_LOCALE != 0
|
||||||
|
}
|
||||||
|
pub fn touchscreen(self) -> bool {
|
||||||
|
self.0 & ffi::ACONFIGURATION_TOUCHSCREEN != 0
|
||||||
|
}
|
||||||
|
pub fn keyboard(self) -> bool {
|
||||||
|
self.0 & ffi::ACONFIGURATION_KEYBOARD != 0
|
||||||
|
}
|
||||||
|
pub fn keyboard_hidden(self) -> bool {
|
||||||
|
self.0 & ffi::ACONFIGURATION_KEYBOARD_HIDDEN != 0
|
||||||
|
}
|
||||||
|
pub fn navigation(self) -> bool {
|
||||||
|
self.0 & ffi::ACONFIGURATION_NAVIGATION != 0
|
||||||
|
}
|
||||||
|
pub fn orientation(self) -> bool {
|
||||||
|
self.0 & ffi::ACONFIGURATION_ORIENTATION != 0
|
||||||
|
}
|
||||||
|
pub fn density(self) -> bool {
|
||||||
|
self.0 & ffi::ACONFIGURATION_DENSITY != 0
|
||||||
|
}
|
||||||
|
pub fn screen_size(self) -> bool {
|
||||||
|
self.0 & ffi::ACONFIGURATION_SCREEN_SIZE != 0
|
||||||
|
}
|
||||||
|
pub fn version(self) -> bool {
|
||||||
|
self.0 & ffi::ACONFIGURATION_VERSION != 0
|
||||||
|
}
|
||||||
|
pub fn screen_layout(self) -> bool {
|
||||||
|
self.0 & ffi::ACONFIGURATION_SCREEN_LAYOUT != 0
|
||||||
|
}
|
||||||
|
pub fn ui_mode(self) -> bool {
|
||||||
|
self.0 & ffi::ACONFIGURATION_UI_MODE != 0
|
||||||
|
}
|
||||||
|
pub fn smallest_screen_size(self) -> bool {
|
||||||
|
self.0 & ffi::ACONFIGURATION_SMALLEST_SCREEN_SIZE != 0
|
||||||
|
}
|
||||||
|
pub fn layout_dir(self) -> bool {
|
||||||
|
self.0 & ffi::ACONFIGURATION_LAYOUTDIR != 0
|
||||||
|
}
|
||||||
|
pub fn screen_round(self) -> bool {
|
||||||
|
self.0 & ffi::ACONFIGURATION_SCREEN_ROUND != 0
|
||||||
|
}
|
||||||
|
pub fn color_mode(self) -> bool {
|
||||||
|
self.0 & ffi::ACONFIGURATION_COLOR_MODE != 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, FromPrimitive, IntoPrimitive)]
|
||||||
|
#[repr(i32)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum Orientation {
|
||||||
|
Any = ffi::ACONFIGURATION_ORIENTATION_ANY as i32,
|
||||||
|
Port = ffi::ACONFIGURATION_ORIENTATION_PORT as i32,
|
||||||
|
Land = ffi::ACONFIGURATION_ORIENTATION_LAND as i32,
|
||||||
|
Square = ffi::ACONFIGURATION_ORIENTATION_SQUARE as i32,
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
#[num_enum(catch_all)]
|
||||||
|
__Unknown(i32),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, FromPrimitive, IntoPrimitive)]
|
||||||
|
#[repr(i32)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum Touchscreen {
|
||||||
|
Any = ffi::ACONFIGURATION_TOUCHSCREEN_ANY as i32,
|
||||||
|
NoTouch = ffi::ACONFIGURATION_TOUCHSCREEN_NOTOUCH as i32,
|
||||||
|
Stylus = ffi::ACONFIGURATION_TOUCHSCREEN_STYLUS as i32,
|
||||||
|
Finger = ffi::ACONFIGURATION_TOUCHSCREEN_FINGER as i32,
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
#[num_enum(catch_all)]
|
||||||
|
__Unknown(i32),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, FromPrimitive, IntoPrimitive)]
|
||||||
|
#[repr(i32)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum Density {
|
||||||
|
Default = ffi::ACONFIGURATION_DENSITY_DEFAULT as i32,
|
||||||
|
Low = ffi::ACONFIGURATION_DENSITY_LOW as i32,
|
||||||
|
Medium = ffi::ACONFIGURATION_DENSITY_MEDIUM as i32,
|
||||||
|
TV = ffi::ACONFIGURATION_DENSITY_TV as i32,
|
||||||
|
High = ffi::ACONFIGURATION_DENSITY_HIGH as i32,
|
||||||
|
XHigh = ffi::ACONFIGURATION_DENSITY_XHIGH as i32,
|
||||||
|
XXHigh = ffi::ACONFIGURATION_DENSITY_XXHIGH as i32,
|
||||||
|
XXXHigh = ffi::ACONFIGURATION_DENSITY_XXXHIGH as i32,
|
||||||
|
Any = ffi::ACONFIGURATION_DENSITY_ANY as i32,
|
||||||
|
None = ffi::ACONFIGURATION_DENSITY_NONE as i32,
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
#[num_enum(catch_all)]
|
||||||
|
__Unknown(i32),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Density {
|
||||||
|
/// The DPI associated with the density class.
|
||||||
|
/// See [the Android screen density
|
||||||
|
/// docs](https://developer.android.com/training/multiscreen/screendensities#TaskProvideAltBmp)
|
||||||
|
///
|
||||||
|
/// There are some [`Density`] values that have no associated DPI; these values return [`None`].
|
||||||
|
pub fn dpi(self) -> Option<u32> {
|
||||||
|
match self {
|
||||||
|
Self::Default => Some(160), // Or should it be None?
|
||||||
|
Self::Low => Some(120),
|
||||||
|
Self::Medium => Some(160),
|
||||||
|
Self::High => Some(240),
|
||||||
|
Self::XHigh => Some(320),
|
||||||
|
Self::XXHigh => Some(480),
|
||||||
|
Self::XXXHigh => Some(640),
|
||||||
|
Self::TV => Some(213),
|
||||||
|
Self::Any => None,
|
||||||
|
Self::None => None,
|
||||||
|
// TODO
|
||||||
|
Self::__Unknown(v) => Some(v as u32),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Hi-DPI factor associated with the density class. This is the factor by which an
|
||||||
|
/// image/resource should be scaled to match its size across devices. The baseline is a 160dpi
|
||||||
|
/// screen (i.e., Hi-DPI factor = DPI / 160).
|
||||||
|
/// See [the Android screen density
|
||||||
|
/// docs](https://developer.android.com/training/multiscreen/screendensities#TaskProvideAltBmp)
|
||||||
|
///
|
||||||
|
/// There are some [`Density`] values that have no associated DPI; these values return [`None`].
|
||||||
|
pub fn approx_hidpi_factor(self) -> Option<f64> {
|
||||||
|
match self {
|
||||||
|
Self::Default => Some(1.), // Or should it be None?
|
||||||
|
Self::Low => Some(0.75),
|
||||||
|
Self::Medium => Some(1.),
|
||||||
|
Self::High => Some(1.5),
|
||||||
|
Self::XHigh => Some(2.),
|
||||||
|
Self::XXHigh => Some(3.),
|
||||||
|
Self::XXXHigh => Some(4.),
|
||||||
|
Self::TV => Some(4. / 3.),
|
||||||
|
Self::Any => None,
|
||||||
|
Self::None => None,
|
||||||
|
Self::__Unknown(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, FromPrimitive, IntoPrimitive)]
|
||||||
|
#[repr(i32)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum Keyboard {
|
||||||
|
Any = ffi::ACONFIGURATION_KEYBOARD_ANY as i32,
|
||||||
|
NoKeys = ffi::ACONFIGURATION_KEYBOARD_NOKEYS as i32,
|
||||||
|
Qwerty = ffi::ACONFIGURATION_KEYBOARD_QWERTY as i32,
|
||||||
|
TwelveKey = ffi::ACONFIGURATION_KEYBOARD_12KEY as i32,
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
#[num_enum(catch_all)]
|
||||||
|
__Unknown(i32),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, FromPrimitive, IntoPrimitive)]
|
||||||
|
#[repr(i32)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum Navigation {
|
||||||
|
Any = ffi::ACONFIGURATION_NAVIGATION_ANY as i32,
|
||||||
|
NoNav = ffi::ACONFIGURATION_NAVIGATION_NONAV as i32,
|
||||||
|
DPad = ffi::ACONFIGURATION_NAVIGATION_DPAD as i32,
|
||||||
|
Trackball = ffi::ACONFIGURATION_NAVIGATION_TRACKBALL as i32,
|
||||||
|
Wheel = ffi::ACONFIGURATION_NAVIGATION_WHEEL as i32,
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
#[num_enum(catch_all)]
|
||||||
|
__Unknown(i32),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, FromPrimitive, IntoPrimitive)]
|
||||||
|
#[repr(i32)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum KeysHidden {
|
||||||
|
Any = ffi::ACONFIGURATION_KEYSHIDDEN_ANY as i32,
|
||||||
|
No = ffi::ACONFIGURATION_KEYSHIDDEN_NO as i32,
|
||||||
|
Yes = ffi::ACONFIGURATION_KEYSHIDDEN_YES as i32,
|
||||||
|
Soft = ffi::ACONFIGURATION_KEYSHIDDEN_SOFT as i32,
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
#[num_enum(catch_all)]
|
||||||
|
__Unknown(i32),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, FromPrimitive, IntoPrimitive)]
|
||||||
|
#[repr(i32)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum NavHidden {
|
||||||
|
Any = ffi::ACONFIGURATION_NAVHIDDEN_ANY as i32,
|
||||||
|
No = ffi::ACONFIGURATION_NAVHIDDEN_NO as i32,
|
||||||
|
Yes = ffi::ACONFIGURATION_NAVHIDDEN_YES as i32,
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
#[num_enum(catch_all)]
|
||||||
|
__Unknown(i32),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, FromPrimitive, IntoPrimitive)]
|
||||||
|
#[repr(i32)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum ScreenSize {
|
||||||
|
Any = ffi::ACONFIGURATION_SCREENSIZE_ANY as i32,
|
||||||
|
Small = ffi::ACONFIGURATION_SCREENSIZE_SMALL as i32,
|
||||||
|
Normal = ffi::ACONFIGURATION_SCREENSIZE_NORMAL as i32,
|
||||||
|
Large = ffi::ACONFIGURATION_SCREENSIZE_LARGE as i32,
|
||||||
|
XLarge = ffi::ACONFIGURATION_SCREENSIZE_XLARGE as i32,
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
#[num_enum(catch_all)]
|
||||||
|
__Unknown(i32),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, FromPrimitive, IntoPrimitive)]
|
||||||
|
#[repr(i32)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum ScreenLong {
|
||||||
|
Any = ffi::ACONFIGURATION_SCREENLONG_ANY as i32,
|
||||||
|
No = ffi::ACONFIGURATION_SCREENLONG_NO as i32,
|
||||||
|
Yes = ffi::ACONFIGURATION_SCREENLONG_YES as i32,
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
#[num_enum(catch_all)]
|
||||||
|
__Unknown(i32),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, FromPrimitive, IntoPrimitive)]
|
||||||
|
#[repr(i32)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum ScreenRound {
|
||||||
|
Any = ffi::ACONFIGURATION_SCREENROUND_ANY as i32,
|
||||||
|
No = ffi::ACONFIGURATION_SCREENROUND_NO as i32,
|
||||||
|
Yes = ffi::ACONFIGURATION_SCREENROUND_YES as i32,
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
#[num_enum(catch_all)]
|
||||||
|
__Unknown(i32),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, FromPrimitive, IntoPrimitive)]
|
||||||
|
#[repr(i32)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum WideColorGamut {
|
||||||
|
Any = ffi::ACONFIGURATION_WIDE_COLOR_GAMUT_ANY as i32,
|
||||||
|
No = ffi::ACONFIGURATION_WIDE_COLOR_GAMUT_NO as i32,
|
||||||
|
Yes = ffi::ACONFIGURATION_WIDE_COLOR_GAMUT_YES as i32,
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
#[num_enum(catch_all)]
|
||||||
|
__Unknown(i32),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, FromPrimitive, IntoPrimitive)]
|
||||||
|
#[repr(i32)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum HDR {
|
||||||
|
Any = ffi::ACONFIGURATION_HDR_ANY as i32,
|
||||||
|
No = ffi::ACONFIGURATION_HDR_NO as i32,
|
||||||
|
Yes = ffi::ACONFIGURATION_HDR_YES as i32,
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
#[num_enum(catch_all)]
|
||||||
|
__Unknown(i32),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, FromPrimitive, IntoPrimitive)]
|
||||||
|
#[repr(i32)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum LayoutDir {
|
||||||
|
Any = ffi::ACONFIGURATION_LAYOUTDIR_ANY as i32,
|
||||||
|
Ltr = ffi::ACONFIGURATION_LAYOUTDIR_LTR as i32,
|
||||||
|
Rtl = ffi::ACONFIGURATION_LAYOUTDIR_RTL as i32,
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
#[num_enum(catch_all)]
|
||||||
|
__Unknown(i32),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, FromPrimitive, IntoPrimitive)]
|
||||||
|
#[repr(i32)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum UiModeType {
|
||||||
|
Any = ffi::ACONFIGURATION_UI_MODE_TYPE_ANY as i32,
|
||||||
|
Normal = ffi::ACONFIGURATION_UI_MODE_TYPE_NORMAL as i32,
|
||||||
|
Desk = ffi::ACONFIGURATION_UI_MODE_TYPE_DESK as i32,
|
||||||
|
Car = ffi::ACONFIGURATION_UI_MODE_TYPE_CAR as i32,
|
||||||
|
Television = ffi::ACONFIGURATION_UI_MODE_TYPE_TELEVISION as i32,
|
||||||
|
Applicance = ffi::ACONFIGURATION_UI_MODE_TYPE_APPLIANCE as i32,
|
||||||
|
Watch = ffi::ACONFIGURATION_UI_MODE_TYPE_WATCH as i32,
|
||||||
|
VrHeadset = ffi::ACONFIGURATION_UI_MODE_TYPE_VR_HEADSET as i32,
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
#[num_enum(catch_all)]
|
||||||
|
__Unknown(i32),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, FromPrimitive, IntoPrimitive)]
|
||||||
|
#[repr(i32)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum UiModeNight {
|
||||||
|
Any = ffi::ACONFIGURATION_UI_MODE_NIGHT_ANY as i32,
|
||||||
|
No = ffi::ACONFIGURATION_UI_MODE_NIGHT_NO as i32,
|
||||||
|
Yes = ffi::ACONFIGURATION_UI_MODE_NIGHT_YES as i32,
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
#[num_enum(catch_all)]
|
||||||
|
__Unknown(i32),
|
||||||
|
}
|
||||||
+648
@@ -0,0 +1,648 @@
|
|||||||
|
//! Bindings for [`ADataSpace`]
|
||||||
|
//!
|
||||||
|
//! [`ADataSpace`]: https://developer.android.com/ndk/reference/group/a-data-space#group___a_data_space_1ga2759ad19cae46646cc5f7002758c4a1c
|
||||||
|
#![cfg(feature = "api-level-28")]
|
||||||
|
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
use num_enum::{FromPrimitive, IntoPrimitive};
|
||||||
|
|
||||||
|
/// Describes how to interpret colors.
|
||||||
|
///
|
||||||
|
/// <https://developer.android.com/ndk/reference/group/a-data-space#group___a_data_space_1ga2759ad19cae46646cc5f7002758c4a1c>
|
||||||
|
#[repr(i32)]
|
||||||
|
#[derive(Clone, Copy, Hash, PartialEq, Eq, FromPrimitive, IntoPrimitive)]
|
||||||
|
#[doc(alias = "ADataSpace")]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum DataSpace {
|
||||||
|
/// Default-assumption data space, when not explicitly specified.
|
||||||
|
///
|
||||||
|
/// It is safest to assume the buffer is an image with `sRGB` primaries and encoding ranges,
|
||||||
|
/// but the consumer and/or the producer of the data may simply be using defaults. No automatic
|
||||||
|
/// gamma transform should be expected, except for a possible display gamma transform when drawn
|
||||||
|
/// to a screen.
|
||||||
|
#[doc(alias = "ADATASPACE_UNKNOWN")]
|
||||||
|
Unknown = ffi::ADataSpace::ADATASPACE_UNKNOWN.0,
|
||||||
|
|
||||||
|
/// Adobe RGB.
|
||||||
|
///
|
||||||
|
/// Uses [full range], [gamma `2.2` transfer] and [Adobe RGB standard].
|
||||||
|
///
|
||||||
|
/// Note: Application is responsible for gamma encoding the data as a `2.2` gamma encoding is
|
||||||
|
/// not supported in HW.
|
||||||
|
///
|
||||||
|
/// [full range]: DataSpaceRange::Full
|
||||||
|
/// [gamma `2.2` transfer]: DataSpaceTransfer::Gamma2_2
|
||||||
|
/// [Adobe RGB standard]: DataSpaceStandard::AdobeRgb
|
||||||
|
#[doc(alias = "ADATASPACE_ADOBE_RGB")]
|
||||||
|
AdobeRgb = ffi::ADataSpace::ADATASPACE_ADOBE_RGB.0,
|
||||||
|
/// ITU-R Recommendation 2020 (`BT.2020`).
|
||||||
|
///
|
||||||
|
/// Ultra High-definition television.
|
||||||
|
///
|
||||||
|
/// Uses [full range], [`SMPTE 170M` transfer] and [`BT2020` standard].
|
||||||
|
///
|
||||||
|
/// [full range]: DataSpaceRange::Full
|
||||||
|
/// [`SMPTE 170M` transfer]: DataSpaceTransfer::Smpte170M
|
||||||
|
/// [`BT2020` standard]: DataSpaceStandard::Bt2020
|
||||||
|
#[doc(alias = "ADATASPACE_BT2020")]
|
||||||
|
Bt2020 = ffi::ADataSpace::ADATASPACE_BT2020.0,
|
||||||
|
/// Hybrid Log Gamma encoding.
|
||||||
|
///
|
||||||
|
/// Uses [full range], [hybrid log gamma transfer] and [`BT2020` standard].
|
||||||
|
///
|
||||||
|
/// [full range]: DataSpaceRange::Full
|
||||||
|
/// [hybrid log gamma transfer]: DataSpaceTransfer::HLG
|
||||||
|
/// [`BT2020` standard]: DataSpaceStandard::Bt2020
|
||||||
|
#[doc(alias = "ADATASPACE_BT2020_HLG")]
|
||||||
|
Bt2020Hlg = ffi::ADataSpace::ADATASPACE_BT2020_HLG.0,
|
||||||
|
/// ITU Hybrid Log Gamma encoding.
|
||||||
|
///
|
||||||
|
/// Uses [limited range], [hybrid log gamma transfer] and [`BT2020` standard].
|
||||||
|
///
|
||||||
|
/// [limited range]: DataSpaceRange::Limited
|
||||||
|
/// [hybrid log gamma transfer]: DataSpaceTransfer::HLG
|
||||||
|
/// [`BT2020` standard]: DataSpaceStandard::Bt2020
|
||||||
|
#[doc(alias = "ADATASPACE_BT2020_ITU_HLG")]
|
||||||
|
Bt2020ItuHlg = ffi::ADataSpace::ADATASPACE_BT2020_ITU_HLG.0,
|
||||||
|
/// ITU-R Recommendation 2020 (`BT.2020`).
|
||||||
|
///
|
||||||
|
/// Ultra High-definition television.
|
||||||
|
///
|
||||||
|
/// Uses [limited range], [`SMPTE 2084 (PQ)` transfer] and [`BT2020` standard].
|
||||||
|
///
|
||||||
|
/// [limited range]: DataSpaceRange::Limited
|
||||||
|
/// [`SMPTE 2084 (PQ)` transfer]: DataSpaceTransfer::St2084
|
||||||
|
/// [`BT2020` standard]: DataSpaceStandard::Bt2020
|
||||||
|
#[doc(alias = "ADATASPACE_BT2020_ITU_PQ")]
|
||||||
|
Bt2020ItuPq = ffi::ADataSpace::ADATASPACE_BT2020_ITU_PQ.0,
|
||||||
|
/// ITU-R Recommendation 2020 (`BT.2020`).
|
||||||
|
///
|
||||||
|
/// Ultra High-definition television.
|
||||||
|
///
|
||||||
|
/// Uses [full range], [`SMPTE 2084 (PQ)` transfer] and [`BT2020` standard].
|
||||||
|
///
|
||||||
|
/// [full range]: DataSpaceRange::Full
|
||||||
|
/// [`SMPTE 2084 (PQ)` transfer]: DataSpaceTransfer::St2084
|
||||||
|
/// [`BT2020` standard]: DataSpaceStandard::Bt2020
|
||||||
|
#[doc(alias = "ADATASPACE_BT2020_PQ")]
|
||||||
|
Bt2020Pq = ffi::ADataSpace::ADATASPACE_BT2020_PQ.0,
|
||||||
|
/// ITU-R Recommendation 601 (`BT.601`) - 525-line.
|
||||||
|
///
|
||||||
|
/// Standard-definition television, 525 Lines (NTSC).
|
||||||
|
///
|
||||||
|
/// Uses [limited range], [`SMPTE 170M` transfer] and [`BT.601_525` standard].
|
||||||
|
///
|
||||||
|
/// [limited range]: DataSpaceRange::Limited
|
||||||
|
/// [`SMPTE 170M` transfer]: DataSpaceTransfer::Smpte170M
|
||||||
|
/// [`BT.601_525` standard]: DataSpaceStandard::Bt601_525
|
||||||
|
#[doc(alias = "ADATASPACE_BT601_525")]
|
||||||
|
Bt601_525 = ffi::ADataSpace::ADATASPACE_BT601_525.0,
|
||||||
|
/// ITU-R Recommendation 601 (`BT.601`) - 625-line.
|
||||||
|
///
|
||||||
|
/// Standard-definition television, 625 Lines (PAL).
|
||||||
|
///
|
||||||
|
/// Uses [limited range], [`SMPTE 170M` transfer] and [`BT.601_625` standard].
|
||||||
|
///
|
||||||
|
/// [limited range]: DataSpaceRange::Limited
|
||||||
|
/// [`SMPTE 170M` transfer]: DataSpaceTransfer::Smpte170M
|
||||||
|
/// [`BT.601_625` standard]: DataSpaceStandard::Bt601_625
|
||||||
|
#[doc(alias = "ADATASPACE_BT601_625")]
|
||||||
|
Bt601_625 = ffi::ADataSpace::ADATASPACE_BT601_625.0,
|
||||||
|
/// ITU-R Recommendation 709 (`BT.709`).
|
||||||
|
///
|
||||||
|
/// High-definition television.
|
||||||
|
///
|
||||||
|
/// Uses [limited range], [`SMPTE 170M` transfer] and [`BT.709` standard].
|
||||||
|
///
|
||||||
|
/// [limited range]: DataSpaceRange::Limited
|
||||||
|
/// [`SMPTE 170M` transfer]: DataSpaceTransfer::Smpte170M
|
||||||
|
/// [`BT.709` standard]: DataSpaceStandard::Bt709
|
||||||
|
#[doc(alias = "ADATASPACE_BT709")]
|
||||||
|
Bt709 = ffi::ADataSpace::ADATASPACE_BT709.0,
|
||||||
|
/// `SMPTE EG 432-1` and `SMPTE RP 431-2`.
|
||||||
|
///
|
||||||
|
/// Digital Cinema `DCI-P3`.
|
||||||
|
///
|
||||||
|
/// Uses [full range], [gamma `2.6` transfer] and [`D65` `DCI-P3` standard].
|
||||||
|
///
|
||||||
|
/// Note: Application is responsible for gamma encoding the data as a `2.6` gamma encoding is
|
||||||
|
/// not supported in HW.
|
||||||
|
///
|
||||||
|
/// [full range]: DataSpaceRange::Full
|
||||||
|
/// [gamma `2.6` transfer]: DataSpaceTransfer::Gamma2_6
|
||||||
|
/// [`D65` `DCI-P3` standard]: DataSpaceStandard::DciP3
|
||||||
|
#[doc(alias = "ADATASPACE_DCI_P3")]
|
||||||
|
DciP3 = ffi::ADataSpace::ADATASPACE_DCI_P3.0,
|
||||||
|
/// Display P3.
|
||||||
|
///
|
||||||
|
/// Uses [full range], [`sRGB` transfer] and [`D65` `DCI-P3` standard].
|
||||||
|
///
|
||||||
|
/// [full range]: DataSpaceRange::Full
|
||||||
|
/// [`sRGB` transfer]: DataSpaceTransfer::Srgb
|
||||||
|
/// [`D65` `DCI-P3` standard]: DataSpaceStandard::DciP3
|
||||||
|
#[doc(alias = "ADATASPACE_DISPLAY_P3")]
|
||||||
|
DisplayP3 = ffi::ADataSpace::ADATASPACE_DISPLAY_P3.0,
|
||||||
|
/// JPEG File Interchange Format (`JFIF`).
|
||||||
|
///
|
||||||
|
/// Same model as `BT.601-625`, but all values (`Y`, `Cb`, `Cr`) range from `0` to `255`.
|
||||||
|
///
|
||||||
|
/// Uses [full range], [`SMPTE 170M` transfer] and [`BT.601_625` standard].
|
||||||
|
///
|
||||||
|
/// [full range]: DataSpaceRange::Full
|
||||||
|
/// [`SMPTE 170M` transfer]: DataSpaceTransfer::Smpte170M
|
||||||
|
/// [`BT.601_625` standard]: DataSpaceStandard::Bt601_625
|
||||||
|
#[doc(alias = "ADATASPACE_JFIF")]
|
||||||
|
Jfif = ffi::ADataSpace::ADATASPACE_JFIF.0,
|
||||||
|
/// `scRGB`.
|
||||||
|
///
|
||||||
|
/// The `red`, `green`, and `blue` components are stored in [extended][extended range] `sRGB`
|
||||||
|
/// space, and gamma- encoded using the [`sRGB` transfer] function.
|
||||||
|
///
|
||||||
|
/// The values are floating point. A pixel value of `1.0`, `1.0`, `1.0` corresponds to `sRGB`
|
||||||
|
/// white (`D65`) at `80` nits. Values beyond the range `[0.0 - 1.0]` would correspond to other
|
||||||
|
/// colors spaces and/or HDR content.
|
||||||
|
///
|
||||||
|
/// Uses [extended range], [`sRGB` transfer] and [`BT.709` standard].
|
||||||
|
///
|
||||||
|
/// [extended range]: DataSpaceRange::Extended
|
||||||
|
/// [`sRGB` transfer]: DataSpaceTransfer::Srgb
|
||||||
|
/// [`BT.709` standard]: DataSpaceStandard::Bt709
|
||||||
|
#[doc(alias = "ADATASPACE_SCRGB")]
|
||||||
|
Scrgb = ffi::ADataSpace::ADATASPACE_SCRGB.0,
|
||||||
|
/// `scRGB` linear encoding
|
||||||
|
///
|
||||||
|
/// The `red`, `green`, and `blue` components are stored in [extended][extended range] `sRGB`
|
||||||
|
/// space, but are linear, not gamma-encoded.
|
||||||
|
///
|
||||||
|
/// The values are floating point. A pixel value of `1.0`, `1.0`, `1.0` corresponds to `sRGB`
|
||||||
|
/// white (`D65`) at `80` nits. Values beyond the range `[0.0 - 1.0]` would correspond to other
|
||||||
|
/// colors spaces and/or HDR content.
|
||||||
|
///
|
||||||
|
/// Uses [extended range], [linear transfer] and [`BT.709` standard].
|
||||||
|
///
|
||||||
|
/// [extended range]: DataSpaceRange::Extended
|
||||||
|
/// [linear transfer]: DataSpaceTransfer::Linear
|
||||||
|
/// [`BT.709` standard]: DataSpaceStandard::Bt709
|
||||||
|
#[doc(alias = "ADATASPACE_SCRGB_LINEAR")]
|
||||||
|
ScrgbLinear = ffi::ADataSpace::ADATASPACE_SCRGB_LINEAR.0,
|
||||||
|
/// `sRGB` gamma encoding.
|
||||||
|
///
|
||||||
|
/// The `red`, `green` and `blue` components are stored in `sRGB` space, and converted to linear
|
||||||
|
/// space when read, using the [`sRGB` transfer] function for each of the `R`, `G` and `B`
|
||||||
|
/// components. When written, the inverse transformation is performed.
|
||||||
|
///
|
||||||
|
/// The `alpha` component, if present, is always stored in linear space and is left unmodified
|
||||||
|
/// when read or written.
|
||||||
|
///
|
||||||
|
/// Uses [full range], [`sRGB` transfer] and [`BT.709` standard].
|
||||||
|
///
|
||||||
|
/// [full range]: DataSpaceRange::Full
|
||||||
|
/// [`sRGB` transfer]: DataSpaceTransfer::Srgb
|
||||||
|
/// [`BT.709` standard]: DataSpaceStandard::Bt709
|
||||||
|
#[doc(alias = "ADATASPACE_SRGB")]
|
||||||
|
Srgb = ffi::ADataSpace::ADATASPACE_SRGB.0,
|
||||||
|
/// `sRGB` linear encoding.
|
||||||
|
///
|
||||||
|
/// The `red`, `green`, and `blue` components are stored in `sRGB` space, but are linear, not
|
||||||
|
/// gamma-encoded. The `RGB` primaries and the white point are the same as [`BT.709]`.
|
||||||
|
///
|
||||||
|
/// The values are encoded using the [full range] (`[0, 255]` for 8-bit) for all components.
|
||||||
|
///
|
||||||
|
/// Uses [full range], [linear transfer] and [`BT.709` standard].
|
||||||
|
///
|
||||||
|
/// [full range]: DataSpaceRange::Full
|
||||||
|
/// [linear transfer]: DataSpaceTransfer::Linear
|
||||||
|
/// [`BT.709` standard]: DataSpaceStandard::Bt709
|
||||||
|
#[doc(alias = "ADATASPACE_SRGB_LINEAR")]
|
||||||
|
SrgbLinear = ffi::ADataSpace::ADATASPACE_SRGB_LINEAR.0,
|
||||||
|
|
||||||
|
/// Depth.
|
||||||
|
///
|
||||||
|
/// This value is valid with formats [`HAL_PIXEL_FORMAT_Y16`] and [`HAL_PIXEL_FORMAT_BLOB`].
|
||||||
|
///
|
||||||
|
/// [`HAL_PIXEL_FORMAT_Y16`]: https://cs.android.com/android/platform/superproject/main/+/main:frameworks/native/libs/nativewindow/include/vndk/hardware_buffer.h;l=74-75;drc=45317f5c7c966fc816843217adc96a2ddea8bf29
|
||||||
|
/// [`HAL_PIXEL_FORMAT_BLOB`]: super::hardware_buffer_format::HardwareBufferFormat::BLOB
|
||||||
|
#[doc(alias = "ADATASPACE_DEPTH")]
|
||||||
|
Depth = ffi::ADataSpace::ADATASPACE_DEPTH.0,
|
||||||
|
/// ISO `16684-1:2011(E)` Dynamic Depth.
|
||||||
|
///
|
||||||
|
/// Embedded depth metadata following the dynamic depth specification.
|
||||||
|
#[doc(alias = "ADATASPACE_DYNAMIC_DEPTH")]
|
||||||
|
DynamicDepth = ffi::ADataSpace::ADATASPACE_DYNAMIC_DEPTH.0,
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
#[num_enum(catch_all)]
|
||||||
|
__Unknown(i32),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for DataSpace {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.write_str(match self {
|
||||||
|
Self::Unknown => "Unknown",
|
||||||
|
Self::AdobeRgb => "AdobeRgb",
|
||||||
|
Self::Bt2020 => "Bt2020",
|
||||||
|
Self::Bt2020Hlg => "Bt2020Hlg",
|
||||||
|
Self::Bt2020ItuHlg => "Bt2020ItuHlg",
|
||||||
|
Self::Bt2020ItuPq => "Bt2020ItuPq",
|
||||||
|
Self::Bt2020Pq => "Bt2020Pq",
|
||||||
|
Self::Bt601_525 => "Bt601_525",
|
||||||
|
Self::Bt601_625 => "Bt601_625",
|
||||||
|
Self::Bt709 => "Bt709",
|
||||||
|
Self::DciP3 => "DciP3",
|
||||||
|
Self::DisplayP3 => "DisplayP3",
|
||||||
|
Self::Jfif => "Jfif",
|
||||||
|
Self::Scrgb => "Scrgb",
|
||||||
|
Self::ScrgbLinear => "ScrgbLinear",
|
||||||
|
Self::Srgb => "Srgb",
|
||||||
|
Self::SrgbLinear => "SrgbLinear",
|
||||||
|
Self::Depth => "Depth",
|
||||||
|
Self::DynamicDepth => "DynamicDepth",
|
||||||
|
Self::__Unknown(u) => {
|
||||||
|
return write!(
|
||||||
|
f,
|
||||||
|
"Unknown DataSpace({u:x?}, standard: {:?}, transfer: {:?}, range: {:?})",
|
||||||
|
self.standard(),
|
||||||
|
self.transfer(),
|
||||||
|
self.range()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for DataSpace {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"DataSpace({self}, standard: {:?}, transfer: {:?}, range: {:?})",
|
||||||
|
self.standard(),
|
||||||
|
self.transfer(),
|
||||||
|
self.range(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DataSpace {
|
||||||
|
/// Construct a [`DataSpace`] from individual `standard`, `transfer` and `range` components.
|
||||||
|
///
|
||||||
|
/// Together these should correspond to a single format.
|
||||||
|
pub fn from_parts(
|
||||||
|
standard: DataSpaceStandard,
|
||||||
|
transfer: DataSpaceTransfer,
|
||||||
|
range: DataSpaceRange,
|
||||||
|
) -> Self {
|
||||||
|
Self::from(i32::from(standard) | i32::from(transfer) | i32::from(range))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extracts and returns the color-description aspect from this [`DataSpace`].
|
||||||
|
#[doc(alias = "STANDARD_MASK")]
|
||||||
|
pub fn standard(self) -> DataSpaceStandard {
|
||||||
|
let standard = i32::from(self) & ffi::ADataSpace::STANDARD_MASK.0;
|
||||||
|
standard.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extracts and returns the transfer aspect from this [`DataSpace`].
|
||||||
|
#[doc(alias = "TRANSFER_MASK")]
|
||||||
|
pub fn transfer(self) -> DataSpaceTransfer {
|
||||||
|
let transfer = i32::from(self) & ffi::ADataSpace::TRANSFER_MASK.0;
|
||||||
|
transfer.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extracts and returns the range aspect from this [`DataSpace`].
|
||||||
|
#[doc(alias = "RANGE_MASK")]
|
||||||
|
pub fn range(self) -> DataSpaceRange {
|
||||||
|
let range = i32::from(self) & ffi::ADataSpace::RANGE_MASK.0;
|
||||||
|
range.into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Color-description aspects.
|
||||||
|
///
|
||||||
|
/// The following aspects define various characteristics of the color specification. These represent
|
||||||
|
/// bitfields, so that a data space value can specify each of them independently. Standard aspect
|
||||||
|
/// defines the chromaticity coordinates of the source primaries in terms of the CIE 1931 definition
|
||||||
|
/// of `x` and `y` specified in ISO 11664-1.
|
||||||
|
#[repr(i32)]
|
||||||
|
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, FromPrimitive, IntoPrimitive)]
|
||||||
|
#[doc(alias = "STANDARD_MASK")]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum DataSpaceStandard {
|
||||||
|
/// Chromacity coordinates are unknown or are determined by the application. Implementations
|
||||||
|
/// shall use the following suggested standards:
|
||||||
|
///
|
||||||
|
/// All `YCbCr` formats: [`BT.709`] if size is `720p` or larger (since most video content is
|
||||||
|
/// letterboxed this corresponds to width is `1280` or greater, or height
|
||||||
|
/// is 720 or greater). [`BT.601_625`] if size is smaller than `720p` or
|
||||||
|
/// is `JPEG`.
|
||||||
|
/// All `RGB` formats: [`BT.709`].
|
||||||
|
///
|
||||||
|
/// For all other formats the standard is undefined, and implementations should use an
|
||||||
|
/// appropriate standard for the data represented.
|
||||||
|
///
|
||||||
|
/// [`BT.709`]: Self::Bt709
|
||||||
|
/// [`BT.601_625`]: Self::Bt601_625
|
||||||
|
#[doc(alias = "STANDARD_UNSPECIFIED")]
|
||||||
|
Unspecified = ffi::ADataSpace::STANDARD_UNSPECIFIED.0,
|
||||||
|
/// | Primaries | x | y |
|
||||||
|
/// | ----------- | ------ | ------ |
|
||||||
|
/// | green | 0.300 | 0.600 |
|
||||||
|
/// | blue | 0.150 | 0.060 |
|
||||||
|
/// | red | 0.640 | 0.330 |
|
||||||
|
/// | white (D65) | 0.3127 | 0.3290 |
|
||||||
|
///
|
||||||
|
/// Use the unadjusted `KR = 0.2126`, `KB = 0.0722` luminance interpretation for `RGB`
|
||||||
|
/// conversion.
|
||||||
|
#[doc(alias = "STANDARD_BT709")]
|
||||||
|
Bt709 = ffi::ADataSpace::STANDARD_BT709.0,
|
||||||
|
/// | Primaries | x | y |
|
||||||
|
/// | ----------- | ------ | ------ |
|
||||||
|
/// | green | 0.290 | 0.600 |
|
||||||
|
/// | blue | 0.150 | 0.060 |
|
||||||
|
/// | red | 0.640 | 0.330 |
|
||||||
|
/// | white (D65) | 0.3127 | 0.3290 |
|
||||||
|
///
|
||||||
|
/// `KR = 0.299`, `KB = 0.114`. This adjusts the luminance interpretation for `RGB` conversion
|
||||||
|
/// from the one purely determined by the primaries to minimize the color shift into `RGB`
|
||||||
|
/// space that uses [`BT.709`] primaries.
|
||||||
|
///
|
||||||
|
/// [`BT.709`]: Self::Bt709
|
||||||
|
#[doc(alias = "STANDARD_BT601_625")]
|
||||||
|
Bt601_625 = ffi::ADataSpace::STANDARD_BT601_625.0,
|
||||||
|
/// | Primaries | x | y |
|
||||||
|
/// | ----------- | ------ | ------ |
|
||||||
|
/// | green | 0.290 | 0.600 |
|
||||||
|
/// | blue | 0.150 | 0.060 |
|
||||||
|
/// | red | 0.640 | 0.330 |
|
||||||
|
/// | white (D65) | 0.3127 | 0.3290 |
|
||||||
|
///
|
||||||
|
/// Use the unadjusted `KR = 0.222`, `KB = 0.071` luminance interpretation for `RGB` conversion.
|
||||||
|
#[doc(alias = "STANDARD_BT601_625_UNADJUSTED")]
|
||||||
|
Bt601_625Unadjusted = ffi::ADataSpace::STANDARD_BT601_625_UNADJUSTED.0,
|
||||||
|
/// | Primaries | x | y |
|
||||||
|
/// | ----------- | ------ | ------ |
|
||||||
|
/// | green | 0.310 | 0.595 |
|
||||||
|
/// | blue | 0.155 | 0.070 |
|
||||||
|
/// | red | 0.630 | 0.340 |
|
||||||
|
/// | white (D65) | 0.3127 | 0.3290 |
|
||||||
|
///
|
||||||
|
/// `KR = 0.299`, `KB = 0.114`. This adjusts the luminance interpretation for `RGB` conversion
|
||||||
|
/// from the one purely determined by the primaries to minimize the color shift into `RGB` space
|
||||||
|
/// that uses [`BT.709`] primaries.
|
||||||
|
///
|
||||||
|
/// [`BT.709`]: Self::Bt709
|
||||||
|
#[doc(alias = "STANDARD_BT601_525")]
|
||||||
|
Bt601_525 = ffi::ADataSpace::STANDARD_BT601_525.0,
|
||||||
|
/// | Primaries | x | y |
|
||||||
|
/// | ----------- | ------ | ------ |
|
||||||
|
/// | green | 0.310 | 0.595 |
|
||||||
|
/// | blue | 0.155 | 0.070 |
|
||||||
|
/// | red | 0.630 | 0.340 |
|
||||||
|
/// | white (D65) | 0.3127 | 0.3290 |
|
||||||
|
///
|
||||||
|
/// Use the unadjusted `KR = 0.212`, `KB = 0.087` luminance interpretation
|
||||||
|
/// for `RGB` conversion (as in `SMPTE 240M`).
|
||||||
|
#[doc(alias = "STANDARD_BT601_525_UNADJUSTED")]
|
||||||
|
Bt601_525Unadjusted = ffi::ADataSpace::STANDARD_BT601_525_UNADJUSTED.0,
|
||||||
|
/// | Primaries | x | y |
|
||||||
|
/// | ----------- | ------ | ------ |
|
||||||
|
/// | green | 0.170 | 0.797 |
|
||||||
|
/// | blue | 0.131 | 0.046 |
|
||||||
|
/// | red | 0.708 | 0.292 |
|
||||||
|
/// | white (D65) | 0.3127 | 0.3290 |
|
||||||
|
///
|
||||||
|
/// Use the unadjusted `KR = 0.2627`, `KB = 0.0593` luminance interpretation for `RGB`
|
||||||
|
/// conversion.
|
||||||
|
#[doc(alias = "STANDARD_BT2020")]
|
||||||
|
Bt2020 = ffi::ADataSpace::STANDARD_BT2020.0,
|
||||||
|
/// | Primaries | x | y |
|
||||||
|
/// | ----------- | ------ | ------ |
|
||||||
|
/// | green | 0.170 | 0.797 |
|
||||||
|
/// | blue | 0.131 | 0.046 |
|
||||||
|
/// | red | 0.708 | 0.292 |
|
||||||
|
/// | white (D65) | 0.3127 | 0.3290 |
|
||||||
|
///
|
||||||
|
/// Use the unadjusted `KR = 0.2627`, `KB = 0.0593` luminance interpretation for `RGB`
|
||||||
|
/// conversion using the linear domain.
|
||||||
|
#[doc(alias = "STANDARD_BT2020_CONSTANT_LUMINANCE")]
|
||||||
|
Bt2020ConstantLuminance = ffi::ADataSpace::STANDARD_BT2020_CONSTANT_LUMINANCE.0,
|
||||||
|
/// | Primaries | x | y |
|
||||||
|
/// | --------- | ----- | ---- |
|
||||||
|
/// | green | 0.21 |0.71 |
|
||||||
|
/// | blue | 0.14 |0.08 |
|
||||||
|
/// | red | 0.67 |0.33 |
|
||||||
|
/// | white (C) | 0.310 |0.316 |
|
||||||
|
///
|
||||||
|
/// Use the unadjusted `KR = 0.30`, `KB = 0.11` luminance interpretation for `RGB` conversion.
|
||||||
|
#[doc(alias = "STANDARD_BT470M")]
|
||||||
|
Bt470M = ffi::ADataSpace::STANDARD_BT470M.0,
|
||||||
|
/// | Primaries | x | y |
|
||||||
|
/// | --------- | ----- | ----- |
|
||||||
|
/// | green | 0.243 | 0.692 |
|
||||||
|
/// | blue | 0.145 | 0.049 |
|
||||||
|
/// | red | 0.681 | 0.319 |
|
||||||
|
/// | white (C) | 0.310 | 0.316 |
|
||||||
|
///
|
||||||
|
/// Use the unadjusted `KR = 0.254`, `KB = 0.068` luminance interpretation for `RGB` conversion.
|
||||||
|
#[doc(alias = "STANDARD_FILM")]
|
||||||
|
Film = ffi::ADataSpace::STANDARD_FILM.0,
|
||||||
|
/// `SMPTE EG 432-1` and `SMPTE RP 431-2`. (`DCI-P3`)
|
||||||
|
///
|
||||||
|
/// | Primaries | x | y |
|
||||||
|
/// | ----------- | ------ | ------ |
|
||||||
|
/// | green | 0.265 | 0.690 |
|
||||||
|
/// | blue | 0.150 | 0.060 |
|
||||||
|
/// | red | 0.680 | 0.320 |
|
||||||
|
/// | white (D65) | 0.3127 | 0.3290 |
|
||||||
|
#[doc(alias = "STANDARD_DCI_P3")]
|
||||||
|
DciP3 = ffi::ADataSpace::STANDARD_DCI_P3.0,
|
||||||
|
/// Adobe RGB
|
||||||
|
///
|
||||||
|
/// | Primaries | x | y |
|
||||||
|
/// | ----------- | ------ | ------ |
|
||||||
|
/// | green | 0.210 | 0.710 |
|
||||||
|
/// | blue | 0.150 | 0.060 |
|
||||||
|
/// | red | 0.640 | 0.330 |
|
||||||
|
/// | white (D65) | 0.3127 | 0.3290 |
|
||||||
|
#[doc(alias = "STANDARD_ADOBE_RGB")]
|
||||||
|
AdobeRgb = ffi::ADataSpace::STANDARD_ADOBE_RGB.0,
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
#[num_enum(catch_all)]
|
||||||
|
__Unknown(i32),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transfer aspect.
|
||||||
|
///
|
||||||
|
/// Transfer characteristics are the opto-electronic transfer characteristic at the source as a
|
||||||
|
///function of linear optical intensity (luminance).
|
||||||
|
///
|
||||||
|
/// For digital signals, `E` corresponds to the recorded value. Normally, the transfer function is
|
||||||
|
/// applied in `RGB` space to each of the `R`, `G` and `B` components independently. This may result
|
||||||
|
/// in color shift that can be minimized by applying the transfer function in `Lab` space only for
|
||||||
|
/// the `L` component. Implementation may apply the transfer function in `RGB` space for all pixel
|
||||||
|
/// formats if desired.
|
||||||
|
#[repr(i32)]
|
||||||
|
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, FromPrimitive, IntoPrimitive)]
|
||||||
|
#[doc(alias = "TRANSFER_MASK")]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum DataSpaceTransfer {
|
||||||
|
/// Transfer characteristics are unknown or are determined by the application.
|
||||||
|
///
|
||||||
|
/// Implementations should use the following transfer functions:
|
||||||
|
///
|
||||||
|
/// - For `YCbCr` formats: use [`DataSpaceTransfer::Smpte170M`]
|
||||||
|
/// - For `RGB` formats: use [`DataSpaceTransfer::Srgb`]
|
||||||
|
///
|
||||||
|
/// For all other formats the transfer function is undefined, and implementations should use an
|
||||||
|
/// appropriate standard for the data represented.
|
||||||
|
#[doc(alias = "TRANSFER_UNSPECIFIED")]
|
||||||
|
Unspecified = ffi::ADataSpace::TRANSFER_UNSPECIFIED.0,
|
||||||
|
|
||||||
|
/// Linear transfer.
|
||||||
|
///
|
||||||
|
/// Transfer characteristic curve:
|
||||||
|
/// ```ignore
|
||||||
|
/// E = L
|
||||||
|
/// ```
|
||||||
|
/// - `L`: luminance of image `0 <= L <= 1` for conventional colorimetry
|
||||||
|
/// - `E`: corresponding electrical signal
|
||||||
|
#[doc(alias = "TRANSFER_LINEAR")]
|
||||||
|
Linear = ffi::ADataSpace::TRANSFER_LINEAR.0,
|
||||||
|
/// `sRGB` transfer.
|
||||||
|
///
|
||||||
|
/// Transfer characteristic curve:
|
||||||
|
///
|
||||||
|
/// ```ignore
|
||||||
|
/// E = 1.055 * L^(1/2.4) - 0.055 for 0.0031308 <= L <= 1
|
||||||
|
/// = 12.92 * L for 0 <= L < 0.0031308
|
||||||
|
/// ```
|
||||||
|
/// - `L`: luminance of image `0 <= L <= 1` for conventional colorimetry
|
||||||
|
/// - `E`: corresponding electrical signal
|
||||||
|
#[doc(alias = "TRANSFER_SRGB")]
|
||||||
|
Srgb = ffi::ADataSpace::TRANSFER_SRGB.0,
|
||||||
|
/// SMPTE 170M transfer.
|
||||||
|
///
|
||||||
|
/// Transfer characteristic curve:
|
||||||
|
/// ```ignore
|
||||||
|
/// E = 1.099 * L ^ 0.45 - 0.099 for 0.018 <= L <= 1
|
||||||
|
/// = 4.500 * L for 0 <= L < 0.018
|
||||||
|
/// ```
|
||||||
|
/// - `L`: luminance of image `0 <= L <= 1` for conventional colorimetry
|
||||||
|
/// - `E`: corresponding electrical signal
|
||||||
|
#[doc(alias = "TRANSFER_SMPTE_170M")]
|
||||||
|
Smpte170M = ffi::ADataSpace::TRANSFER_SMPTE_170M.0,
|
||||||
|
/// Display gamma `2.2`.
|
||||||
|
///
|
||||||
|
/// Transfer characteristic curve:
|
||||||
|
/// ```ignore
|
||||||
|
/// E = L ^ (1/2.2)
|
||||||
|
/// ```
|
||||||
|
/// - `L`: luminance of image `0 <= L <= 1` for conventional colorimetry
|
||||||
|
/// - `E`: corresponding electrical signal
|
||||||
|
#[doc(alias = "TRANSFER_GAMMA2_2")]
|
||||||
|
Gamma2_2 = ffi::ADataSpace::TRANSFER_GAMMA2_2.0,
|
||||||
|
/// Display gamma `2.6`.
|
||||||
|
///
|
||||||
|
/// Transfer characteristic curve:
|
||||||
|
/// ```ignore
|
||||||
|
/// E = L ^ (1/2.6)
|
||||||
|
/// ```
|
||||||
|
/// - `L`: luminance of image `0 <= L <= 1` for conventional colorimetry
|
||||||
|
/// - `E`: corresponding electrical signal
|
||||||
|
#[doc(alias = "TRANSFER_GAMMA2_6")]
|
||||||
|
Gamma2_6 = ffi::ADataSpace::TRANSFER_GAMMA2_6.0,
|
||||||
|
/// Display gamma `2.8`.
|
||||||
|
///
|
||||||
|
/// Transfer characteristic curve:
|
||||||
|
/// ```ignore
|
||||||
|
/// E = L ^ (1/2.8)
|
||||||
|
/// ```
|
||||||
|
/// - `L`: luminance of image `0 <= L <= 1` for conventional colorimetry
|
||||||
|
/// - `E`: corresponding electrical signal
|
||||||
|
#[doc(alias = "TRANSFER_GAMMA2_8")]
|
||||||
|
Gamma2_8 = ffi::ADataSpace::TRANSFER_GAMMA2_8.0,
|
||||||
|
/// SMPTE ST 2084 (Dolby Perceptual Quantizer).
|
||||||
|
///
|
||||||
|
/// Transfer characteristic curve:
|
||||||
|
/// ```ignore
|
||||||
|
/// E = ((c1 + c2 * L^n) / (1 + c3 * L^n)) ^ m
|
||||||
|
/// c1 = c3 - c2 + 1 = 3424 / 4096 = 0.8359375
|
||||||
|
/// c2 = 32 * 2413 / 4096 = 18.8515625
|
||||||
|
/// c3 = 32 * 2392 / 4096 = 18.6875
|
||||||
|
/// m = 128 * 2523 / 4096 = 78.84375
|
||||||
|
/// n = 0.25 * 2610 / 4096 = 0.1593017578125
|
||||||
|
/// ```
|
||||||
|
/// - `L`: luminance of image 0 <= L <= 1 for HDR colorimetry.
|
||||||
|
/// `L = 1` corresponds to `10000 cd/m2`
|
||||||
|
#[doc(alias = "TRANSFER_ST2084")]
|
||||||
|
St2084 = ffi::ADataSpace::TRANSFER_ST2084.0,
|
||||||
|
/// ARIB STD-B67 Hybrid Log Gamma.
|
||||||
|
///
|
||||||
|
/// Transfer characteristic curve:
|
||||||
|
/// ```ignore
|
||||||
|
/// E = r * L^0.5 for 0 <= L <= 1
|
||||||
|
/// = a * ln(L - b) + c for 1 < L
|
||||||
|
/// a = 0.17883277
|
||||||
|
/// b = 0.28466892
|
||||||
|
/// c = 0.55991073
|
||||||
|
/// r = 0.5
|
||||||
|
/// ```
|
||||||
|
/// - `L`: luminance of image `0 <= L` for HDR colorimetry.
|
||||||
|
/// `L = 1` corresponds to reference white level of `100 cd/m2`
|
||||||
|
/// - `E`: corresponding electrical signal
|
||||||
|
#[doc(alias = "TRANSFER_HLG")]
|
||||||
|
HLG = ffi::ADataSpace::TRANSFER_HLG.0,
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
#[num_enum(catch_all)]
|
||||||
|
__Unknown(i32),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Range aspect.
|
||||||
|
///
|
||||||
|
/// Defines the range of values corresponding to the unit range of `0-1`. This is defined for
|
||||||
|
/// `YCbCr` only, but can be expanded to `RGB` space.
|
||||||
|
#[repr(i32)]
|
||||||
|
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, FromPrimitive, IntoPrimitive)]
|
||||||
|
#[doc(alias = "RANGE_MASK")]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum DataSpaceRange {
|
||||||
|
/// Range is unknown or are determined by the application. Implementations shall use the
|
||||||
|
/// following suggested ranges:
|
||||||
|
///
|
||||||
|
/// - All YCbCr formats: limited range.
|
||||||
|
/// - All RGB or RGBA formats (including RAW and Bayer): full range.
|
||||||
|
/// - All Y formats: full range
|
||||||
|
///
|
||||||
|
/// For all other formats range is undefined, and implementations should use an appropriate
|
||||||
|
/// range for the data represented.
|
||||||
|
#[doc(alias = "RANGE_UNSPECIFIED")]
|
||||||
|
Unspecified = ffi::ADataSpace::RANGE_UNSPECIFIED.0,
|
||||||
|
/// Full range uses all values for `Y`, `Cb` and `Cr` from `0` to `2^b-1`, where `b` is the bit
|
||||||
|
/// depth of the color format.
|
||||||
|
#[doc(alias = "RANGE_FULL")]
|
||||||
|
Full = ffi::ADataSpace::RANGE_FULL.0,
|
||||||
|
/// Limited range uses values `16/256*2^b` to `235/256*2^b` for `Y`, and `1/16*2^b` to
|
||||||
|
/// `15/16*2^b` for `Cb`, `Cr`, `R`, `G` and `B`, where `b` is the bit depth of the color
|
||||||
|
/// format.
|
||||||
|
///
|
||||||
|
/// E.g. For 8-bit-depth formats: Luma (`Y`) samples should range from `16` to `235`, inclusive
|
||||||
|
/// Chroma `(Cb, Cr)` samples should range from `16` to `240`, inclusive.
|
||||||
|
///
|
||||||
|
/// For 10-bit-depth formats: Luma (`Y`) samples should range from `64` to `940`, inclusive
|
||||||
|
/// Chroma `(Cb, Cr)` samples should range from `64` to `960`, inclusive.
|
||||||
|
#[doc(alias = "RANGE_LIMITED")]
|
||||||
|
Limited = ffi::ADataSpace::RANGE_LIMITED.0,
|
||||||
|
/// Extended range is used for `scRGB`.
|
||||||
|
///
|
||||||
|
/// Intended for use with floating point pixel formats. `[0.0 - 1.0]` is the standard `sRGB`
|
||||||
|
/// space. Values outside the range `0.0 - 1.0` can encode color outside the `sRGB` gamut. Used
|
||||||
|
/// to blend / merge multiple dataspaces on a single display.
|
||||||
|
#[doc(alias = "RANGE_EXTENDED")]
|
||||||
|
Extended = ffi::ADataSpace::RANGE_EXTENDED.0,
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
#[num_enum(catch_all)]
|
||||||
|
__Unknown(i32),
|
||||||
|
}
|
||||||
+1583
File diff suppressed because it is too large
Load Diff
+552
@@ -0,0 +1,552 @@
|
|||||||
|
//! Bindings for [`AFont`], [`AFontMatcher`], and [`ASystemFontIterator`]
|
||||||
|
//!
|
||||||
|
//! [`AFont`]: https://developer.android.com/ndk/reference/group/font
|
||||||
|
//! [`AFontMatcher`]: https://developer.android.com/ndk/reference/group/font#afontmatcher_create
|
||||||
|
//! [`ASystemFontIterator`]: https://developer.android.com/ndk/reference/group/font#asystemfontiterator_open
|
||||||
|
|
||||||
|
#![cfg(feature = "api-level-29")]
|
||||||
|
|
||||||
|
use std::convert::TryFrom;
|
||||||
|
use std::ffi::{CStr, OsStr};
|
||||||
|
use std::fmt::{self, Write};
|
||||||
|
use std::os::unix::prelude::OsStrExt;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::ptr::NonNull;
|
||||||
|
|
||||||
|
use num_enum::IntoPrimitive;
|
||||||
|
|
||||||
|
/// An integer holding a valid font weight value between 1 and 1000.
|
||||||
|
///
|
||||||
|
/// See the [`Font::weight`] definition for more details.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
pub struct FontWeight(u16);
|
||||||
|
|
||||||
|
impl FontWeight {
|
||||||
|
pub const fn new(value: u16) -> Result<Self, FontWeightValueError> {
|
||||||
|
if Self::MIN.0 <= value && value <= Self::MAX.0 {
|
||||||
|
Ok(Self(value))
|
||||||
|
} else {
|
||||||
|
Err(FontWeightValueError(()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn to_u16(self) -> u16 {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The minimum value for the font weight value. Unlike [`ffi::AFONT_WEIGHT_MIN`] being `0`,
|
||||||
|
/// [`FontWeight::MIN`] is `1` to make the `MIN..MAX` range be inclusive, keeping consistency
|
||||||
|
/// between [`FontWeight`] and other types like `std::num::NonZeroU*`.
|
||||||
|
pub const MIN: FontWeight = FontWeight(ffi::AFONT_WEIGHT_MIN as u16 + 1);
|
||||||
|
|
||||||
|
/// A font weight value for the thin weight.
|
||||||
|
pub const THIN: FontWeight = FontWeight(ffi::AFONT_WEIGHT_THIN as u16);
|
||||||
|
|
||||||
|
/// A font weight value for the extra-light weight.
|
||||||
|
pub const EXTRA_LIGHT: FontWeight = FontWeight(ffi::AFONT_WEIGHT_EXTRA_LIGHT as u16);
|
||||||
|
|
||||||
|
/// A font weight value for the light weight.
|
||||||
|
pub const LIGHT: FontWeight = FontWeight(ffi::AFONT_WEIGHT_LIGHT as u16);
|
||||||
|
|
||||||
|
/// A font weight value for the normal weight.
|
||||||
|
pub const NORMAL: FontWeight = FontWeight(ffi::AFONT_WEIGHT_NORMAL as u16);
|
||||||
|
|
||||||
|
/// A font weight value for the medium weight.
|
||||||
|
pub const MEDIUM: FontWeight = FontWeight(ffi::AFONT_WEIGHT_MEDIUM as u16);
|
||||||
|
|
||||||
|
/// A font weight value for the semi-bold weight.
|
||||||
|
pub const SEMI_BOLD: FontWeight = FontWeight(ffi::AFONT_WEIGHT_SEMI_BOLD as u16);
|
||||||
|
|
||||||
|
/// A font weight value for the bold weight.
|
||||||
|
pub const BOLD: FontWeight = FontWeight(ffi::AFONT_WEIGHT_BOLD as u16);
|
||||||
|
|
||||||
|
/// A font weight value for the extra-bold weight.
|
||||||
|
pub const EXTRA_BOLD: FontWeight = FontWeight(ffi::AFONT_WEIGHT_EXTRA_BOLD as u16);
|
||||||
|
|
||||||
|
/// A font weight value for the black weight.
|
||||||
|
pub const BLACK: FontWeight = FontWeight(ffi::AFONT_WEIGHT_BLACK as u16);
|
||||||
|
|
||||||
|
/// The maximum value for the font weight value.
|
||||||
|
pub const MAX: FontWeight = FontWeight(ffi::AFONT_WEIGHT_MAX as u16);
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for FontWeight {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.write_str(match *self {
|
||||||
|
FontWeight::THIN => "Thin",
|
||||||
|
FontWeight::EXTRA_LIGHT => "Extra Light (Ultra Light)",
|
||||||
|
FontWeight::LIGHT => "Light",
|
||||||
|
FontWeight::NORMAL => "Normal (Regular)",
|
||||||
|
FontWeight::MEDIUM => "Medium",
|
||||||
|
FontWeight::SEMI_BOLD => "Semi Bold (Demi Bold)",
|
||||||
|
FontWeight::BOLD => "Bold",
|
||||||
|
FontWeight::EXTRA_BOLD => "Extra Bold (Ultra Bold)",
|
||||||
|
FontWeight::BLACK => "Black (Heavy)",
|
||||||
|
_ => return writeln!(f, "{}", self.0),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The error type returned when an invalid font weight value is passed.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct FontWeightValueError(());
|
||||||
|
|
||||||
|
impl fmt::Display for FontWeightValueError {
|
||||||
|
fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
fmt.write_str("font weight must be positive and less than or equal to 1000")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for FontWeightValueError {}
|
||||||
|
|
||||||
|
impl TryFrom<u16> for FontWeight {
|
||||||
|
type Error = FontWeightValueError;
|
||||||
|
|
||||||
|
fn try_from(value: u16) -> Result<Self, Self::Error> {
|
||||||
|
FontWeight::new(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A 4-byte integer representing an OpenType axis tag.
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct AxisTag(u32);
|
||||||
|
|
||||||
|
impl AxisTag {
|
||||||
|
/// Checks whether the given 4-byte array can construct a valid axis tag and returns
|
||||||
|
/// [`Ok(AxisTag)`] if the array is valid.
|
||||||
|
///
|
||||||
|
/// Each byte in a tag must be in the range 0x20 to 0x7E. A space character cannot be followed
|
||||||
|
/// by a non-space character. A tag must have one to four non-space characters. See the
|
||||||
|
/// [OpenType spec] for more details.
|
||||||
|
///
|
||||||
|
/// [OpenType spec]: https://learn.microsoft.com/en-us/typography/opentype/spec/otff#data-types
|
||||||
|
pub const fn from_be_bytes_checked(value: [u8; 4]) -> Result<Self, AxisTagValueError> {
|
||||||
|
// Each byte in a tag must be in the range 0x20 to 0x7E.
|
||||||
|
macro_rules! check_byte_range {
|
||||||
|
($($e:expr)+) => {
|
||||||
|
$(
|
||||||
|
if !(value[$e] as char).is_ascii_graphic() && value[$e] != b' ' {
|
||||||
|
return Err(AxisTagValueError::InvalidCharacter);
|
||||||
|
}
|
||||||
|
)+
|
||||||
|
};
|
||||||
|
}
|
||||||
|
check_byte_range!(0 1 2 3);
|
||||||
|
|
||||||
|
if value[0] == b' ' {
|
||||||
|
return Err(
|
||||||
|
if value[1] == b' ' && value[2] == b' ' && value[3] == b' ' {
|
||||||
|
// A tag must have one to four non-space characters.
|
||||||
|
AxisTagValueError::EmptyTag
|
||||||
|
} else {
|
||||||
|
// A space character cannot be followed by a non-space character.
|
||||||
|
AxisTagValueError::InvalidSpacePadding
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! check_if_valid {
|
||||||
|
($e:expr ; $($f:expr)+) => {
|
||||||
|
if value[$e] == b' ' {
|
||||||
|
return if true $(&& value[$f] == b' ')+ {
|
||||||
|
Ok(Self(u32::from_be_bytes(value)))
|
||||||
|
} else {
|
||||||
|
// A space character cannot be followed by a non-space character.
|
||||||
|
Err(AxisTagValueError::InvalidSpacePadding)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
check_if_valid!(1; 2 3);
|
||||||
|
check_if_valid!(2; 3);
|
||||||
|
|
||||||
|
// Whether or not value[3] is b' ', value is a valid axis tag.
|
||||||
|
Ok(Self(u32::from_be_bytes(value)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks whether the given 4-byte array can construct a valid axis tag and returns
|
||||||
|
/// [`Ok(AxisTag)`] if the array is valid.
|
||||||
|
///
|
||||||
|
/// See [`AxisTag::from_be()`] for more details.
|
||||||
|
pub const fn from_be_checked(value: u32) -> Result<Self, AxisTagValueError> {
|
||||||
|
Self::from_be_bytes_checked(value.to_be_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Construct an axis tag from the given 4-byte array. If the resulting axis tag is invalid,
|
||||||
|
/// this function panics.
|
||||||
|
///
|
||||||
|
/// See [`AxisTag::from_be()`] for more details.
|
||||||
|
pub const fn from_be_bytes(value: [u8; 4]) -> Self {
|
||||||
|
Self::unwrap_result(Self::from_be_bytes_checked(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Construct an axis tag from the given 4-byte integer. If the resulting axis tag is invalid,
|
||||||
|
/// this function panics.
|
||||||
|
///
|
||||||
|
/// See [`AxisTag::from_be()`] for more details.
|
||||||
|
pub const fn from_be(value: u32) -> Self {
|
||||||
|
Self::unwrap_result(Self::from_be_checked(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// const-version of [`Result::unwrap`]. Should be removed when [`Option::unwrap`] or
|
||||||
|
/// [`Result::unwrap`] become `const`-stable.
|
||||||
|
const fn unwrap_result(result: Result<Self, AxisTagValueError>) -> Self {
|
||||||
|
match result {
|
||||||
|
Ok(t) => t,
|
||||||
|
Err(e) => panic!("{}", e.as_str()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn to_u32(self) -> u32 {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn to_be_bytes(self) -> [u8; 4] {
|
||||||
|
self.0.to_be_bytes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for AxisTag {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
let bytes = self.to_be_bytes();
|
||||||
|
f.write_char(bytes[0] as char)?;
|
||||||
|
f.write_char(bytes[1] as char)?;
|
||||||
|
f.write_char(bytes[2] as char)?;
|
||||||
|
f.write_char(bytes[3] as char)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for AxisTag {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "AxisTag({} {:#x})", self, self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The error type returned when an invalid axis tag value is passed.
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub enum AxisTagValueError {
|
||||||
|
/// There is a byte not in the range 0x20 to 0x7E.
|
||||||
|
InvalidCharacter,
|
||||||
|
/// There is a space character followed by a non-space character.
|
||||||
|
InvalidSpacePadding,
|
||||||
|
/// The tag only consists of space characters.
|
||||||
|
EmptyTag,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AxisTagValueError {
|
||||||
|
pub const fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::InvalidCharacter => "each byte in an axis tag must be in the range 0x20 to 0x7E",
|
||||||
|
Self::InvalidSpacePadding => {
|
||||||
|
"a space character cannot be followed by a non-space character"
|
||||||
|
}
|
||||||
|
Self::EmptyTag => "a tag must have one to four non-space characters",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for AxisTagValueError {
|
||||||
|
fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
fmt.write_str(self.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for AxisTagValueError {}
|
||||||
|
|
||||||
|
/// A native [`AFont *`]
|
||||||
|
///
|
||||||
|
/// [`AFont *`]: https://developer.android.com/ndk/reference/group/font
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Font {
|
||||||
|
ptr: NonNull<ffi::AFont>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Font {
|
||||||
|
/// Assumes ownership of `ptr`.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `ptr` must be a valid owning pointer to an Android [`ffi::AFont`].
|
||||||
|
pub unsafe fn from_ptr(ptr: NonNull<ffi::AFont>) -> Self {
|
||||||
|
Self { ptr }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns s the pointer to the native [`ffi::AFont`].
|
||||||
|
pub fn ptr(&self) -> NonNull<ffi::AFont> {
|
||||||
|
self.ptr
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a count of font variation settings associated with the current font.
|
||||||
|
///
|
||||||
|
/// The font variation settings are provided as multiple tag-value pairs.
|
||||||
|
///
|
||||||
|
/// For example, bold italic font may have following font variation settings: `'wght' 700`,
|
||||||
|
/// `'slnt' -12`. In this case, [`Font::axis_count()`] returns `2` and [`Font::axis_tag_at()`] and
|
||||||
|
/// [`Font::axis_value_at()`] return those variation names and the corresponding values.
|
||||||
|
///
|
||||||
|
/// ```no_run
|
||||||
|
/// use ndk::font::Font;
|
||||||
|
///
|
||||||
|
/// let font: Font = todo!();
|
||||||
|
/// for idx in 0..font.axis_count() {
|
||||||
|
/// log::debug!("{}: {}", font.axis_tag_at(idx), font.axis_value_at(idx));
|
||||||
|
/// }
|
||||||
|
/// // Output:
|
||||||
|
/// // wght: 700
|
||||||
|
/// // slnt: -12
|
||||||
|
/// ```
|
||||||
|
pub fn axis_count(&self) -> usize {
|
||||||
|
unsafe { ffi::AFont_getAxisCount(self.ptr.as_ptr()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns an OpenType axis tag associated with the current font.
|
||||||
|
///
|
||||||
|
/// See [`Font::axis_count()`] for more details.
|
||||||
|
pub fn axis_tag_at(&self, idx: usize) -> AxisTag {
|
||||||
|
// Android returns Axis Tag in big-endian.
|
||||||
|
// See https://cs.android.com/android/platform/superproject/+/refs/heads/master:frameworks/base/native/android/system_fonts.cpp;l=197 for details
|
||||||
|
AxisTag(unsafe { ffi::AFont_getAxisTag(self.ptr.as_ptr(), idx as u32) })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns an OpenType axis value associated with the current font.
|
||||||
|
///
|
||||||
|
/// See [`Font::axis_count()`] for more details.
|
||||||
|
pub fn axis_value_at(&self, idx: usize) -> f32 {
|
||||||
|
unsafe { ffi::AFont_getAxisValue(self.ptr.as_ptr(), idx as u32) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a font collection index value associated with the current font.
|
||||||
|
///
|
||||||
|
/// In case the target font file is a font collection (e.g. `.ttc` or `.otc`), this returns a
|
||||||
|
/// non-negative value as a font offset in the collection. This always returns 0 if the target
|
||||||
|
/// font file is a regular font.
|
||||||
|
pub fn collection_index(&self) -> usize {
|
||||||
|
unsafe { ffi::AFont_getCollectionIndex(self.ptr.as_ptr()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns an absolute path to the current font file.
|
||||||
|
///
|
||||||
|
/// Here is a list of font formats returned by this method:
|
||||||
|
///
|
||||||
|
/// * OpenType
|
||||||
|
/// * OpenType Font Collection
|
||||||
|
/// * TrueType
|
||||||
|
/// * TrueType Collection
|
||||||
|
///
|
||||||
|
/// The file extension could be one of `*.otf`, `*.ttf`, `*.otc` or `*.ttc`.
|
||||||
|
/// The font file specified by the returned path is guaranteed to be openable with `O_RDONLY`.
|
||||||
|
pub fn path(&self) -> &Path {
|
||||||
|
let path = unsafe { CStr::from_ptr(ffi::AFont_getFontFilePath(self.ptr.as_ptr())) };
|
||||||
|
OsStr::from_bytes(path.to_bytes()).as_ref()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns an IETF BCP47 compliant language tag associated with the current font.
|
||||||
|
///
|
||||||
|
/// For information about IETF BCP47, read [`Locale.forLanguageTag(java.lang.String)`].
|
||||||
|
///
|
||||||
|
/// [`Locale.forLanguageTag(java.lang.String)`]: https://developer.android.com/reference/java/util/Locale.html#forLanguageTag(java.lang.String)
|
||||||
|
pub fn locale(&self) -> Option<&CStr> {
|
||||||
|
let ptr = unsafe { ffi::AFont_getLocale(self.ptr.as_ptr()) };
|
||||||
|
if ptr.is_null() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(unsafe { CStr::from_ptr(ptr) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a weight value associated with the current font.
|
||||||
|
///
|
||||||
|
/// The weight values are positive and less than or equal to 1000. Here are pairs of the common
|
||||||
|
/// names and their values.
|
||||||
|
///
|
||||||
|
/// | Value | Name | NDK Definition |
|
||||||
|
/// | ----- | ------------------------- | --------------------------- |
|
||||||
|
/// | 100 | Thin | [`FontWeight::THIN`] |
|
||||||
|
/// | 200 | Extra Light (Ultra Light) | [`FontWeight::EXTRA_LIGHT`] |
|
||||||
|
/// | 300 | Light | [`FontWeight::LIGHT`] |
|
||||||
|
/// | 400 | Normal (Regular) | [`FontWeight::NORMAL`] |
|
||||||
|
/// | 500 | Medium | [`FontWeight::MEDIUM`] |
|
||||||
|
/// | 600 | Semi Bold (Demi Bold) | [`FontWeight::SEMI_BOLD`] |
|
||||||
|
/// | 700 | Bold | [`FontWeight::BOLD`] |
|
||||||
|
/// | 800 | Extra Bold (Ultra Bold) | [`FontWeight::EXTRA_BOLD`] |
|
||||||
|
/// | 900 | Black (Heavy) | [`FontWeight::BLACK`] |
|
||||||
|
pub fn weight(&self) -> FontWeight {
|
||||||
|
FontWeight(unsafe { ffi::AFont_getWeight(self.ptr.as_ptr()) })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns [`true`] if the current font is italic, otherwise returns [`false`].
|
||||||
|
pub fn is_italic(&self) -> bool {
|
||||||
|
unsafe { ffi::AFont_isItalic(self.ptr.as_ptr()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Font {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
unsafe { ffi::AFont_close(self.ptr.as_ptr()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Corresponds to [`AFAMILY_VARIANT_*`].
|
||||||
|
///
|
||||||
|
/// [`AFAMILY_VARIANT_*`]: https://developer.android.com/ndk/reference/group/font#group___font_1gga96a58e29e8dbf2b5bdeb775cba46556ea662aafc7016e35d6758da93416fc0833
|
||||||
|
#[repr(u32)]
|
||||||
|
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, IntoPrimitive)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum FamilyVariant {
|
||||||
|
/// A family variant value for the compact font family variant.
|
||||||
|
/// The compact font family has Latin-based vertical metrics.
|
||||||
|
Compact = ffi::AFAMILY_VARIANT_COMPACT,
|
||||||
|
/// A family variant value for the system default variant.
|
||||||
|
Default = ffi::AFAMILY_VARIANT_DEFAULT,
|
||||||
|
/// A family variant value for the elegant font family variant.
|
||||||
|
/// The elegant font family may have larger vertical metrics than Latin font.
|
||||||
|
Elegant = ffi::AFAMILY_VARIANT_ELEGANT,
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
#[num_enum(catch_all)]
|
||||||
|
__Unknown(u32),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A native [`AFontMatcher *`]
|
||||||
|
///
|
||||||
|
/// [`AFontMatcher *`]: https://developer.android.com/ndk/reference/group/font#afontmatcher_create
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct FontMatcher {
|
||||||
|
ptr: NonNull<ffi::AFontMatcher>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FontMatcher {
|
||||||
|
/// Assumes ownership of `ptr`.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `ptr` must be a valid owning pointer to an Android [`ffi::AFontMatcher`].
|
||||||
|
pub unsafe fn from_ptr(ptr: NonNull<ffi::AFontMatcher>) -> Self {
|
||||||
|
Self { ptr }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns s the pointer to the native [`ffi::AFontMatcher`].
|
||||||
|
pub fn ptr(&self) -> NonNull<ffi::AFontMatcher> {
|
||||||
|
self.ptr
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a new [`FontMatcher`] object. [`FontMatcher`] selects the best font from the
|
||||||
|
/// parameters set by the user.
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let ptr = NonNull::new(unsafe { ffi::AFontMatcher_create() })
|
||||||
|
.expect("AFontMatcher_create returned NULL");
|
||||||
|
unsafe { FontMatcher::from_ptr(ptr) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Performs the matching from the generic font family for the text and select one font.
|
||||||
|
///
|
||||||
|
/// For more information about generic font families, please read the
|
||||||
|
/// [W3C spec](https://www.w3.org/TR/css-fonts-4/#generic-font-families).
|
||||||
|
///
|
||||||
|
/// Even if no font can render the given text, this function will return a non-null result for
|
||||||
|
/// drawing Tofu character.
|
||||||
|
///
|
||||||
|
/// # Parameters
|
||||||
|
///
|
||||||
|
/// - `family_name`: A font family name.
|
||||||
|
/// - `text`: A UTF-16 encoded text buffer to be rendered. If an empty string is given, this
|
||||||
|
/// function will panic.
|
||||||
|
/// - `run_length_out`: Set this to [`Some`] if you want to get the length of the text run with
|
||||||
|
/// the font returned.
|
||||||
|
pub fn match_font(
|
||||||
|
&mut self,
|
||||||
|
family_name: &CStr,
|
||||||
|
text: &[u16],
|
||||||
|
run_length_out: Option<&mut u32>,
|
||||||
|
) -> Font {
|
||||||
|
if text.is_empty() {
|
||||||
|
panic!("text is empty");
|
||||||
|
}
|
||||||
|
unsafe {
|
||||||
|
Font::from_ptr(
|
||||||
|
NonNull::new(ffi::AFontMatcher_match(
|
||||||
|
self.ptr.as_ptr(),
|
||||||
|
family_name.as_ptr(),
|
||||||
|
text.as_ptr(),
|
||||||
|
text.len() as _,
|
||||||
|
run_length_out.map_or(std::ptr::null_mut(), |u| u),
|
||||||
|
))
|
||||||
|
.expect("AFontMatcher_match returned NULL"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the family variant of the font to be matched.
|
||||||
|
///
|
||||||
|
/// If this function is not called, the match is performed with [`FamilyVariant::Default`].
|
||||||
|
pub fn set_family_variant(&mut self, family_variant: FamilyVariant) {
|
||||||
|
unsafe { ffi::AFontMatcher_setFamilyVariant(self.ptr.as_ptr(), family_variant.into()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the locale of the font to be matched.
|
||||||
|
///
|
||||||
|
/// If this function is not called, the match is performed with an empty locale list.
|
||||||
|
///
|
||||||
|
/// # Parameters
|
||||||
|
///
|
||||||
|
/// - `language_tags`: comma separated IETF BCP47 compliant language tags.
|
||||||
|
pub fn set_locales(&mut self, language_tags: &CStr) {
|
||||||
|
unsafe { ffi::AFontMatcher_setLocales(self.ptr.as_ptr(), language_tags.as_ptr()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the style of the font to be matched.
|
||||||
|
///
|
||||||
|
/// If this function is not called, the match is performed with [`FontWeight::NORMAL`] with non-italic style.
|
||||||
|
pub fn set_style(&mut self, weight: FontWeight, italic: bool) {
|
||||||
|
unsafe { ffi::AFontMatcher_setStyle(self.ptr.as_ptr(), weight.to_u16(), italic) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for FontMatcher {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
unsafe { ffi::AFontMatcher_destroy(self.ptr.as_ptr()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A native [`ASystemFontIterator *`]
|
||||||
|
///
|
||||||
|
/// [`ASystemFontIterator *`]: https://developer.android.com/ndk/reference/group/font#asystemfontiterator_open
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct SystemFontIterator {
|
||||||
|
ptr: NonNull<ffi::ASystemFontIterator>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SystemFontIterator {
|
||||||
|
/// Assumes ownership of `ptr`.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `ptr` must be a valid owning pointer to an Android [`ffi::ASystemFontIterator`].
|
||||||
|
pub unsafe fn from_ptr(ptr: NonNull<ffi::ASystemFontIterator>) -> Self {
|
||||||
|
Self { ptr }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the pointer to the native [`ffi::ASystemFontIterator`].
|
||||||
|
pub fn ptr(&self) -> NonNull<ffi::ASystemFontIterator> {
|
||||||
|
self.ptr
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a system font iterator.
|
||||||
|
pub fn new() -> Option<Self> {
|
||||||
|
NonNull::new(unsafe { ffi::ASystemFontIterator_open() })
|
||||||
|
.map(|p| unsafe { SystemFontIterator::from_ptr(p) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Iterator for SystemFontIterator {
|
||||||
|
type Item = Font;
|
||||||
|
|
||||||
|
fn next(&mut self) -> Option<Self::Item> {
|
||||||
|
NonNull::new(unsafe { ffi::ASystemFontIterator_next(self.ptr.as_ptr()) })
|
||||||
|
.map(|p| unsafe { Font::from_ptr(p) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for SystemFontIterator {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
unsafe { ffi::ASystemFontIterator_close(self.ptr.as_ptr()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,640 @@
|
|||||||
|
//! Bindings for [`AHardwareBuffer`]
|
||||||
|
//!
|
||||||
|
//! [`AHardwareBuffer`]: https://developer.android.com/ndk/reference/group/a-hardware-buffer#ahardwarebuffer
|
||||||
|
|
||||||
|
#![cfg(feature = "api-level-26")]
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
io::Result,
|
||||||
|
mem::MaybeUninit,
|
||||||
|
ops::Deref,
|
||||||
|
os::{
|
||||||
|
fd::{AsRawFd, BorrowedFd, FromRawFd, IntoRawFd, OwnedFd},
|
||||||
|
raw::c_void,
|
||||||
|
},
|
||||||
|
ptr::NonNull,
|
||||||
|
};
|
||||||
|
|
||||||
|
use jni_sys::{jobject, JNIEnv};
|
||||||
|
|
||||||
|
use super::{hardware_buffer_format::HardwareBufferFormat, utils::status_to_io_result};
|
||||||
|
|
||||||
|
bitflags::bitflags! {
|
||||||
|
/// Buffer usage flags, specifying how the buffer will be accessed.
|
||||||
|
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
|
||||||
|
#[doc(alias = "AHardwareBuffer_UsageFlags")]
|
||||||
|
pub struct HardwareBufferUsage : u64 {
|
||||||
|
/// The buffer will never be locked for direct CPU reads using the
|
||||||
|
/// [`HardwareBuffer::lock()`] function. Note that reading the buffer using OpenGL or Vulkan
|
||||||
|
/// functions or memory mappings is still allowed.
|
||||||
|
#[doc(alias = "AHARDWAREBUFFER_USAGE_CPU_READ_NEVER")]
|
||||||
|
const CPU_READ_NEVER = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_CPU_READ_NEVER.0;
|
||||||
|
/// The buffer will sometimes be locked for direct CPU reads using the
|
||||||
|
/// [`HardwareBuffer::lock()`] function. Note that reading the buffer using OpenGL or Vulkan
|
||||||
|
/// functions or memory mappings does not require the presence of this flag.
|
||||||
|
#[doc(alias = "AHARDWAREBUFFER_USAGE_CPU_READ_RARELY")]
|
||||||
|
const CPU_READ_RARELY = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_CPU_READ_RARELY.0;
|
||||||
|
/// The buffer will often be locked for direct CPU reads using the
|
||||||
|
/// [`HardwareBuffer::lock()`] function. Note that reading the buffer using OpenGL or Vulkan
|
||||||
|
/// functions or memory mappings does not require the presence of this flag.
|
||||||
|
#[doc(alias = "AHARDWAREBUFFER_USAGE_CPU_READ_OFTEN")]
|
||||||
|
const CPU_READ_OFTEN = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_CPU_READ_OFTEN.0;
|
||||||
|
/// CPU read value mask.
|
||||||
|
#[doc(alias = "AHARDWAREBUFFER_USAGE_CPU_READ_MASK")]
|
||||||
|
const CPU_READ_MASK = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_CPU_READ_MASK.0;
|
||||||
|
|
||||||
|
/// The buffer will never be locked for direct CPU writes using the
|
||||||
|
/// [`HardwareBuffer::lock()`] function. Note that writing the buffer using OpenGL or Vulkan
|
||||||
|
/// functions or memory mappings is still allowed.
|
||||||
|
#[doc(alias = "AHARDWAREBUFFER_USAGE_CPU_WRITE_NEVER")]
|
||||||
|
const CPU_WRITE_NEVER = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_CPU_WRITE_NEVER.0;
|
||||||
|
/// The buffer will sometimes be locked for direct CPU writes using the
|
||||||
|
/// [`HardwareBuffer::lock()`] function. Note that writing the buffer using OpenGL or Vulkan
|
||||||
|
/// functions or memory mappings does not require the presence of this flag.
|
||||||
|
#[doc(alias = "AHARDWAREBUFFER_USAGE_CPU_WRITE_RARELY")]
|
||||||
|
const CPU_WRITE_RARELY = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_CPU_WRITE_RARELY.0;
|
||||||
|
/// The buffer will often be locked for direct CPU writes using the
|
||||||
|
/// [`HardwareBuffer::lock()`] function. Note that writing the buffer using OpenGL or Vulkan
|
||||||
|
/// functions or memory mappings does not require the presence of this flag.
|
||||||
|
#[doc(alias = "AHARDWAREBUFFER_USAGE_CPU_WRITE_OFTEN")]
|
||||||
|
const CPU_WRITE_OFTEN = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_CPU_WRITE_OFTEN.0;
|
||||||
|
/// CPU write value mask.
|
||||||
|
#[doc(alias = "AHARDWAREBUFFER_USAGE_CPU_WRITE_MASK")]
|
||||||
|
const CPU_WRITE_MASK = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_CPU_WRITE_MASK.0;
|
||||||
|
|
||||||
|
/// The buffer will be read from by the GPU as a texture.
|
||||||
|
#[doc(alias = "AHARDWAREBUFFER_USAGE_GPU_SAMPLED_IMAGE")]
|
||||||
|
const GPU_SAMPLED_IMAGE = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_GPU_SAMPLED_IMAGE.0;
|
||||||
|
/// The buffer will be written to by the GPU as a framebuffer attachment.
|
||||||
|
#[doc(alias = "AHARDWAREBUFFER_USAGE_GPU_FRAMEBUFFER")]
|
||||||
|
const GPU_FRAMEBUFFER = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_GPU_FRAMEBUFFER.0;
|
||||||
|
/// The buffer will be written to by the GPU as a framebuffer attachment.
|
||||||
|
///
|
||||||
|
/// Note that the name of this flag is somewhat misleading: it does not imply that the
|
||||||
|
/// buffer contains a color format. A buffer with depth or stencil format that will be
|
||||||
|
/// used as a framebuffer attachment should also have this flag. Use the equivalent flag
|
||||||
|
/// [`HardwareBufferusage::GPU_FRAMEBUFFER`] to avoid this confusion.
|
||||||
|
#[doc(alias = "AHARDWAREBUFFER_USAGE_GPU_COLOR_OUTPUT")]
|
||||||
|
const GPU_COLOR_OUTPUT = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_GPU_COLOR_OUTPUT.0;
|
||||||
|
/// The buffer will be used as a composer HAL overlay layer.
|
||||||
|
///
|
||||||
|
/// This flag is currently only needed when using [`SurfaceTransaction::set_buffer()`] to
|
||||||
|
/// set a buffer. In all other cases, the framework adds this flag internally to buffers
|
||||||
|
/// that could be presented in a composer overlay. [`SurfaceTransaction::set_buffer()`]
|
||||||
|
/// is special because it uses buffers allocated directly through
|
||||||
|
/// [`HardwareBuffer::allocate()`] instead of buffers allocated by the framework.
|
||||||
|
#[doc(alias = "AHARDWAREBUFFER_USAGE_COMPOSER_OVERLAY")]
|
||||||
|
const COMPOSER_OVERLAY = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_COMPOSER_OVERLAY.0;
|
||||||
|
/// The buffer is protected from direct CPU access or being read by non-secure hardware,
|
||||||
|
/// such as video encoders.
|
||||||
|
///
|
||||||
|
/// This flag is incompatible with CPU read and write flags. It is mainly used when handling
|
||||||
|
/// DRM video. Refer to the EGL extension [`EGL_EXT_protected_content`] and GL extension
|
||||||
|
/// [`GL_EXT_protected_textures`] for more information on how these buffers are expected
|
||||||
|
/// to behave.
|
||||||
|
///
|
||||||
|
/// [`EGL_EXT_protected_content`]: https://registry.khronos.org/EGL/extensions/EXT/EGL_EXT_protected_content.txt
|
||||||
|
/// [`GL_EXT_protected_textures`]: https://registry.khronos.org/OpenGL/extensions/EXT/EXT_protected_textures.txt
|
||||||
|
#[doc(alias = "AHARDWAREBUFFER_USAGE_PROTECTED_CONTENT")]
|
||||||
|
const PROTECTED_CONTENT = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_PROTECTED_CONTENT.0;
|
||||||
|
/// The buffer will be read by a hardware video encoder.
|
||||||
|
#[doc(alias = "AHARDWAREBUFFER_USAGE_VIDEO_ENCODE")]
|
||||||
|
const VIDEO_ENCODE = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_VIDEO_ENCODE.0;
|
||||||
|
/// The buffer will be used for direct writes from sensors. When this flag is present, the
|
||||||
|
/// format must be [`HardwareBufferFormat::Blob`].
|
||||||
|
#[doc(alias = "AHARDWAREBUFFER_USAGE_SENSOR_DIRECT_DATA")]
|
||||||
|
const SENSOR_DIRECT_DATA = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_SENSOR_DIRECT_DATA.0;
|
||||||
|
/// The buffer will be used as a shader storage or uniform buffer object. When this flag is
|
||||||
|
/// present, the format must be [`HardwareBufferFormat::Blob`].
|
||||||
|
#[doc(alias = "AHARDWAREBUFFER_USAGE_GPU_DATA_BUFFER")]
|
||||||
|
const GPU_DATA_BUFFER = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_GPU_DATA_BUFFER.0;
|
||||||
|
/// The buffer will be used as a cube map texture. When this flag is present, the buffer
|
||||||
|
/// must have a layer count that is a multiple of 6. Note that buffers with this flag must
|
||||||
|
/// be bound to OpenGL textures using the extension [`GL_EXT_EGL_image_storage`] instead
|
||||||
|
/// of [`GL_KHR_EGL_image`].
|
||||||
|
///
|
||||||
|
/// [`GL_EXT_EGL_image_storage`]: https://registry.khronos.org/OpenGL/extensions/EXT/EXT_EGL_image_storage.txt
|
||||||
|
// TODO: This extension only exists for VG. Reported at https://issuetracker.google.com/issues/300602767#comment16
|
||||||
|
/// [`GL_KHR_EGL_image`]: https://registry.khronos.org/OpenVG/extensions/KHR/VG_KHR_EGL_image.txt
|
||||||
|
#[doc(alias = "AHARDWAREBUFFER_USAGE_GPU_CUBE_MAP")]
|
||||||
|
const GPU_CUBE_MAP = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_GPU_CUBE_MAP.0;
|
||||||
|
/// The buffer contains a complete mipmap hierarchy. Note that buffers with this flag must
|
||||||
|
/// be bound to OpenGL textures using the extension [`GL_EXT_EGL_image_storage`] instead
|
||||||
|
/// of [`GL_KHR_EGL_image`].
|
||||||
|
///
|
||||||
|
/// [`GL_EXT_EGL_image_storage`]: https://registry.khronos.org/OpenGL/extensions/EXT/EXT_EGL_image_storage.txt
|
||||||
|
// TODO: This extension only exists for VG. Reported at https://issuetracker.google.com/issues/300602767#comment16
|
||||||
|
/// [`GL_KHR_EGL_image`]: https://registry.khronos.org/OpenVG/extensions/KHR/VG_KHR_EGL_image.txt
|
||||||
|
#[doc(alias = "AHARDWAREBUFFER_USAGE_GPU_MIPMAP_COMPLETE")]
|
||||||
|
const GPU_MIPMAP_COMPLETE = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_GPU_MIPMAP_COMPLETE.0;
|
||||||
|
|
||||||
|
// TODO: Only available in a newer NDK
|
||||||
|
// /// Usage: The buffer is used for front-buffer rendering. When front-buffering rendering
|
||||||
|
// /// is specified, different usages may adjust their behavior as a result. For example, when
|
||||||
|
// /// used as [`HardwareBufferFormat::GPU_COLOR_OUTPUT`] the buffer will behave similar to a
|
||||||
|
// /// single-buffered window. When used with [`HardwareBufferFormat::COMPOSER_OVERLAY`], the
|
||||||
|
// /// system will try to prioritize the buffer receiving an overlay plane & avoid caching it
|
||||||
|
// /// in intermediate composition buffers.
|
||||||
|
// #[doc(alias = "AHARDWAREBUFFER_USAGE_FRONT_BUFFER")]
|
||||||
|
// const USAGE_FRONT_BUFFER = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_FRONT_BUFFER.0;
|
||||||
|
|
||||||
|
#[doc(alias = "AHARDWAREBUFFER_USAGE_VENDOR_0")]
|
||||||
|
const VENDOR_0 = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_VENDOR_0.0;
|
||||||
|
#[doc(alias = "AHARDWAREBUFFER_USAGE_VENDOR_1")]
|
||||||
|
const VENDOR_1 = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_VENDOR_1.0;
|
||||||
|
#[doc(alias = "AHARDWAREBUFFER_USAGE_VENDOR_2")]
|
||||||
|
const VENDOR_2 = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_VENDOR_2.0;
|
||||||
|
#[doc(alias = "AHARDWAREBUFFER_USAGE_VENDOR_3")]
|
||||||
|
const VENDOR_3 = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_VENDOR_3.0;
|
||||||
|
#[doc(alias = "AHARDWAREBUFFER_USAGE_VENDOR_4")]
|
||||||
|
const VENDOR_4 = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_VENDOR_4.0;
|
||||||
|
#[doc(alias = "AHARDWAREBUFFER_USAGE_VENDOR_5")]
|
||||||
|
const VENDOR_5 = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_VENDOR_5.0;
|
||||||
|
#[doc(alias = "AHARDWAREBUFFER_USAGE_VENDOR_6")]
|
||||||
|
const VENDOR_6 = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_VENDOR_6.0;
|
||||||
|
#[doc(alias = "AHARDWAREBUFFER_USAGE_VENDOR_7")]
|
||||||
|
const VENDOR_7 = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_VENDOR_7.0;
|
||||||
|
#[doc(alias = "AHARDWAREBUFFER_USAGE_VENDOR_8")]
|
||||||
|
const VENDOR_8 = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_VENDOR_8.0;
|
||||||
|
#[doc(alias = "AHARDWAREBUFFER_USAGE_VENDOR_9")]
|
||||||
|
const VENDOR_9 = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_VENDOR_9.0;
|
||||||
|
#[doc(alias = "AHARDWAREBUFFER_USAGE_VENDOR_10")]
|
||||||
|
const VENDOR_10 = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_VENDOR_10.0;
|
||||||
|
#[doc(alias = "AHARDWAREBUFFER_USAGE_VENDOR_11")]
|
||||||
|
const VENDOR_11 = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_VENDOR_11.0;
|
||||||
|
#[doc(alias = "AHARDWAREBUFFER_USAGE_VENDOR_12")]
|
||||||
|
const VENDOR_12 = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_VENDOR_12.0;
|
||||||
|
#[doc(alias = "AHARDWAREBUFFER_USAGE_VENDOR_13")]
|
||||||
|
const VENDOR_13 = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_VENDOR_13.0;
|
||||||
|
#[doc(alias = "AHARDWAREBUFFER_USAGE_VENDOR_14")]
|
||||||
|
const VENDOR_14 = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_VENDOR_14.0;
|
||||||
|
#[doc(alias = "AHARDWAREBUFFER_USAGE_VENDOR_15")]
|
||||||
|
const VENDOR_15 = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_VENDOR_15.0;
|
||||||
|
#[doc(alias = "AHARDWAREBUFFER_USAGE_VENDOR_16")]
|
||||||
|
const VENDOR_16 = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_VENDOR_16.0;
|
||||||
|
#[doc(alias = "AHARDWAREBUFFER_USAGE_VENDOR_17")]
|
||||||
|
const VENDOR_17 = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_VENDOR_17.0;
|
||||||
|
#[doc(alias = "AHARDWAREBUFFER_USAGE_VENDOR_18")]
|
||||||
|
const VENDOR_18 = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_VENDOR_18.0;
|
||||||
|
#[doc(alias = "AHARDWAREBUFFER_USAGE_VENDOR_19")]
|
||||||
|
const VENDOR_19 = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_VENDOR_19.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HardwareBufferUsage {
|
||||||
|
/// Helper to read [`HardwareBufferUsage::CPU_READ_MASK`] values.
|
||||||
|
#[doc(alias = "AHARDWAREBUFFER_USAGE_CPU_READ_MASK")]
|
||||||
|
pub fn cpu_read(self) -> HardwareBufferUsage {
|
||||||
|
self.intersection(Self::CPU_READ_MASK)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper to read [`HardwareBufferUsage::CPU_WRITE_MASK`] values.
|
||||||
|
#[doc(alias = "AHARDWAREBUFFER_USAGE_CPU_WRITE_MASK")]
|
||||||
|
pub fn cpu_write(self) -> HardwareBufferUsage {
|
||||||
|
self.intersection(Self::CPU_WRITE_MASK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type Rect = ffi::ARect;
|
||||||
|
|
||||||
|
fn construct<T>(with_ptr: impl FnOnce(*mut T) -> i32) -> Result<T> {
|
||||||
|
let mut result = MaybeUninit::uninit();
|
||||||
|
let status = with_ptr(result.as_mut_ptr());
|
||||||
|
status_to_io_result(status).map(|()| unsafe { result.assume_init() })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A native [`AHardwareBuffer *`]
|
||||||
|
///
|
||||||
|
/// [`HardwareBuffer`] objects represent chunks of memory that can be accessed by various hardware
|
||||||
|
/// components in the system.
|
||||||
|
///
|
||||||
|
/// It can be easily converted to the Java counterpart [`android.hardware.HardwareBuffer`] and
|
||||||
|
/// passed between processes using Binder. All operations involving [`HardwareBuffer`] and
|
||||||
|
/// [`android.hardware.HardwareBuffer`] are zero-copy, i.e., passing [`HardwareBuffer`] to another
|
||||||
|
/// process creates a shared view of the same region of memory.
|
||||||
|
///
|
||||||
|
/// [`HardwareBuffer`] can be bound to EGL/OpenGL and Vulkan primitives. For EGL, use the extension
|
||||||
|
/// function [`eglGetNativeClientBufferANDROID`] to obtain an `EGLClientBuffer` and pass it
|
||||||
|
/// directly to [`eglCreateImageKHR`]. Refer to the EGL extensions
|
||||||
|
/// [`EGL_ANDROID_get_native_client_buffer`] and [`EGL_ANDROID_image_native_buffer`] for more
|
||||||
|
/// information. In Vulkan, the contents of the [`HardwareBuffer`] can be accessed as [external
|
||||||
|
/// memory]. See the [`VK_ANDROID_external_memory_android_hardware_buffer`] extension for details.
|
||||||
|
///
|
||||||
|
/// [`AHardwareBuffer *`]: https://developer.android.com/ndk/reference/group/a-hardware-buffer#ahardwarebuffer
|
||||||
|
/// [`android.hardware.HardwareBuffer`]: https://developer.android.com/reference/android/hardware/HardwareBuffer
|
||||||
|
/// [`eglGetNativeClientBufferANDROID`]: https://www.khronos.org/registry/EGL/extensions/ANDROID/EGL_ANDROID_get_native_client_buffer.txt
|
||||||
|
/// [`eglCreateImageKHR`]: https://www.khronos.org/registry/EGL/extensions/KHR/EGL_KHR_image_base.txt
|
||||||
|
/// [`EGL_ANDROID_get_native_client_buffer`]: https://www.khronos.org/registry/EGL/extensions/ANDROID/EGL_ANDROID_get_native_client_buffer.txt
|
||||||
|
/// [`EGL_ANDROID_image_native_buffer`]: https://www.khronos.org/registry/EGL/extensions/ANDROID/EGL_ANDROID_image_native_buffer.txt
|
||||||
|
/// [external memory]: https://www.khronos.org/registry/vulkan/specs/1.3-extensions/man/html/VK_KHR_external_memory.html
|
||||||
|
/// [`VK_ANDROID_external_memory_android_hardware_buffer`]: https://www.khronos.org/registry/vulkan/specs/1.3-extensions/man/html/VK_ANDROID_external_memory_android_hardware_buffer.html
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct HardwareBuffer {
|
||||||
|
inner: NonNull<ffi::AHardwareBuffer>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HardwareBuffer {
|
||||||
|
/// Create an _unowned_ [`HardwareBuffer`] from a native pointer
|
||||||
|
///
|
||||||
|
/// To wrap a strong reference (that is `release`d on [`Drop`]), call
|
||||||
|
/// [`HardwareBufferRef::from_ptr()`] instead.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// By calling this function, you assert that it is a valid pointer to an NDK
|
||||||
|
/// [`ffi::AHardwareBuffer`] that is kept alive externally, or retrieve a strong reference
|
||||||
|
/// using [`HardwareBuffer::acquire()`].
|
||||||
|
pub unsafe fn from_ptr(ptr: NonNull<ffi::AHardwareBuffer>) -> Self {
|
||||||
|
Self { inner: ptr }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the underlying [`ffi::AHardwareBuffer`] pointer
|
||||||
|
///
|
||||||
|
/// See the top-level [`HardwareBuffer`] struct documentation for (graphics) APIs that accept
|
||||||
|
/// this pointer.
|
||||||
|
pub fn as_ptr(&self) -> *mut ffi::AHardwareBuffer {
|
||||||
|
self.inner.as_ptr()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Allocates a buffer that matches the passed [`HardwareBufferDesc`].
|
||||||
|
///
|
||||||
|
/// If allocation succeeds, the buffer can be used according to the usage flags specified in
|
||||||
|
/// its description. If a buffer is used in ways not compatible with its usage flags, the
|
||||||
|
/// results are undefined and may include program termination.
|
||||||
|
pub fn allocate(desc: HardwareBufferDesc) -> Result<HardwareBufferRef> {
|
||||||
|
unsafe {
|
||||||
|
let ptr = construct(|res| ffi::AHardwareBuffer_allocate(&desc.into_native(), res))?;
|
||||||
|
|
||||||
|
Ok(HardwareBufferRef::from_ptr(NonNull::new_unchecked(ptr)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a [`HardwareBuffer`] from JNI pointers
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// By calling this function, you assert that these are valid pointers to JNI objects.
|
||||||
|
///
|
||||||
|
/// This method does not acquire any additional reference to the AHardwareBuffer that is
|
||||||
|
/// returned. To keep the [`HardwareBuffer`] alive after the [Java `HardwareBuffer`] object
|
||||||
|
/// is closed, explicitly or by the garbage collector, be sure to retrieve a strong reference
|
||||||
|
/// using [`HardwareBuffer::acquire()`].
|
||||||
|
///
|
||||||
|
/// [Java `HardwareBuffer`]: https://developer.android.com/reference/android/hardware/HardwareBuffer
|
||||||
|
pub unsafe fn from_jni(env: *mut JNIEnv, hardware_buffer: jobject) -> Self {
|
||||||
|
let ptr = ffi::AHardwareBuffer_fromHardwareBuffer(env, hardware_buffer);
|
||||||
|
|
||||||
|
Self::from_ptr(NonNull::new_unchecked(ptr))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Safety
|
||||||
|
/// By calling this function, you assert that `env` is a valid pointer to a [`JNIEnv`].
|
||||||
|
pub unsafe fn to_jni(&self, env: *mut JNIEnv) -> jobject {
|
||||||
|
ffi::AHardwareBuffer_toHardwareBuffer(env, self.as_ptr())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return a description of the [`HardwareBuffer`] in the passed [`HardwareBufferDesc`] struct.
|
||||||
|
pub fn describe(&self) -> HardwareBufferDesc {
|
||||||
|
let desc = unsafe {
|
||||||
|
let mut result = MaybeUninit::uninit();
|
||||||
|
ffi::AHardwareBuffer_describe(self.as_ptr(), result.as_mut_ptr());
|
||||||
|
result.assume_init()
|
||||||
|
};
|
||||||
|
|
||||||
|
HardwareBufferDesc {
|
||||||
|
width: desc.width,
|
||||||
|
height: desc.height,
|
||||||
|
layers: desc.layers,
|
||||||
|
format: i32::try_from(desc.format)
|
||||||
|
.expect("i32->u32 overflow in HardwareBuffer::describe()")
|
||||||
|
.into(),
|
||||||
|
usage: HardwareBufferUsage::from_bits_retain(desc.usage),
|
||||||
|
stride: desc.stride,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test whether the given format and usage flag combination is allocatable.
|
||||||
|
///
|
||||||
|
/// If this function returns [`true`], it means that a buffer with the given description can
|
||||||
|
/// be allocated on this implementation, unless resource exhaustion occurs. If this function
|
||||||
|
/// returns [`false`], it means that the allocation of the given description will never
|
||||||
|
/// succeed.
|
||||||
|
///
|
||||||
|
/// The return value of this function may depend on all fields in the description, except
|
||||||
|
/// [`HardwareBufferDesc::stride`], which is always ignored. For example, some implementations
|
||||||
|
/// have implementation-defined limits on texture size and layer count.
|
||||||
|
#[cfg(feature = "api-level-29")]
|
||||||
|
pub fn is_supported(desc: HardwareBufferDesc) -> bool {
|
||||||
|
let res = unsafe { ffi::AHardwareBuffer_isSupported(&desc.into_native()) };
|
||||||
|
res == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the system-wide unique id for this [`HardwareBuffer`].
|
||||||
|
#[cfg(feature = "api-level-31")]
|
||||||
|
#[doc(alias = "AHardwareBuffer_getId")]
|
||||||
|
pub fn id(&self) -> Result<u64> {
|
||||||
|
construct(|res| unsafe { ffi::AHardwareBuffer_getId(self.as_ptr(), res) })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lock the [`HardwareBuffer`] for direct CPU access.
|
||||||
|
///
|
||||||
|
/// This function can lock the buffer for either reading or writing. It may block if the
|
||||||
|
/// hardware needs to finish rendering, if CPU caches need to be synchronized, or possibly for
|
||||||
|
/// other implementation-specific reasons.
|
||||||
|
///
|
||||||
|
/// The [`HardwareBuffer`] must have one layer, otherwise the call will fail.
|
||||||
|
///
|
||||||
|
/// If `fence` is not [`None`], it specifies a fence file descriptor on which to wait before
|
||||||
|
/// locking the buffer. If it's [`None`], the caller is responsible for ensuring that writes
|
||||||
|
/// to the buffer have completed before calling this function. Using this parameter is more
|
||||||
|
/// efficient than waiting on the fence and then calling this function.
|
||||||
|
///
|
||||||
|
/// The `usage` parameter may only specify `HardwareBufferUsage::CPU_*`. If set, then the
|
||||||
|
/// address of the buffer in virtual memory is returned. The flags must also be compatible with
|
||||||
|
/// usage flags specified at buffer creation: if a read flag is passed, the buffer must have
|
||||||
|
/// been created with [`HardwareBufferUsage::CPU_READ_RARELY`] or
|
||||||
|
/// [`HardwareBufferUsage::CPU_READ_OFTEN`]. If a write flag is passed, it must have been
|
||||||
|
/// created with [`HardwareBufferUsage::CPU_WRITE_RARELY`] or
|
||||||
|
/// [`HardwareBufferUsage::CPU_WRITE_OFTEN`].
|
||||||
|
///
|
||||||
|
/// If `rect` is not [`None`], the caller promises to modify only data in the area specified by
|
||||||
|
/// `rect`. If rect is [`None`], the caller may modify the contents of the entire buffer. The
|
||||||
|
/// content of the buffer outside of the specified rect is NOT modified by this call.
|
||||||
|
///
|
||||||
|
/// It is legal for several different threads to lock a buffer for read access; none of the
|
||||||
|
/// threads are blocked.
|
||||||
|
///
|
||||||
|
/// Locking a buffer simultaneously for write or read/write is undefined, but will neither
|
||||||
|
/// terminate the process nor block the caller. This function may return an error or leave the
|
||||||
|
/// buffer's content in an indeterminate state.
|
||||||
|
///
|
||||||
|
/// If the buffer has [`HardwareBufferFormat::BLOB`], it is legal lock it for reading and
|
||||||
|
/// writing in multiple threads and/or processes simultaneously, and the contents of the buffer
|
||||||
|
/// behave like shared memory.
|
||||||
|
pub fn lock(
|
||||||
|
&self,
|
||||||
|
usage: HardwareBufferUsage,
|
||||||
|
fence: Option<OwnedFd>,
|
||||||
|
rect: Option<Rect>,
|
||||||
|
) -> Result<*mut c_void> {
|
||||||
|
let fence = fence.map_or(-1, IntoRawFd::into_raw_fd);
|
||||||
|
let rect = match rect {
|
||||||
|
Some(rect) => &rect,
|
||||||
|
None => std::ptr::null(),
|
||||||
|
};
|
||||||
|
construct(|res| unsafe {
|
||||||
|
ffi::AHardwareBuffer_lock(self.as_ptr(), usage.bits(), fence, rect, res)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lock a [`HardwareBuffer`] for direct CPU access.
|
||||||
|
///
|
||||||
|
/// This function is the same as the above [`lock()`][Self::lock()] function, but passes back
|
||||||
|
/// additional information about the bytes per pixel and the bytes per stride of the locked
|
||||||
|
/// buffer. If the bytes per pixel or bytes per stride are unknown or variable, or if the
|
||||||
|
/// underlying mapper implementation does not support returning additional information, then
|
||||||
|
/// this call will fail with [`std::io::Error::kind()`] = [`std::io::ErrorKind::Unsupported`].
|
||||||
|
#[cfg(feature = "api-level-29")]
|
||||||
|
pub fn lock_and_get_info(
|
||||||
|
&self,
|
||||||
|
usage: HardwareBufferUsage,
|
||||||
|
fence: Option<OwnedFd>,
|
||||||
|
rect: Option<Rect>,
|
||||||
|
) -> Result<LockedPlaneInfo> {
|
||||||
|
let fence = fence.map_or(-1, IntoRawFd::into_raw_fd);
|
||||||
|
let rect = match rect {
|
||||||
|
Some(rect) => &rect,
|
||||||
|
None => std::ptr::null(),
|
||||||
|
};
|
||||||
|
let mut virtual_address = MaybeUninit::uninit();
|
||||||
|
let mut bytes_per_pixel = MaybeUninit::uninit();
|
||||||
|
let mut bytes_per_stride = MaybeUninit::uninit();
|
||||||
|
let status = unsafe {
|
||||||
|
ffi::AHardwareBuffer_lockAndGetInfo(
|
||||||
|
self.as_ptr(),
|
||||||
|
usage.bits(),
|
||||||
|
fence,
|
||||||
|
rect,
|
||||||
|
virtual_address.as_mut_ptr(),
|
||||||
|
bytes_per_pixel.as_mut_ptr(),
|
||||||
|
bytes_per_stride.as_mut_ptr(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
status_to_io_result(status).map(|()| unsafe {
|
||||||
|
LockedPlaneInfo {
|
||||||
|
virtual_address: virtual_address.assume_init(),
|
||||||
|
bytes_per_pixel: bytes_per_pixel.assume_init() as u32,
|
||||||
|
bytes_per_stride: bytes_per_stride.assume_init() as u32,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lock a potentially multi-planar [`HardwareBuffer`] for direct CPU access.
|
||||||
|
///
|
||||||
|
/// This function is similar to [`lock()`][Self::lock()], but can lock multi-planar formats.
|
||||||
|
/// Note, that multi-planar should not be confused with multi-layer images, which this locking
|
||||||
|
/// function does not support.
|
||||||
|
///
|
||||||
|
/// YUV formats are always represented by three separate planes of data, one for each color
|
||||||
|
/// plane. The order of planes in the array is guaranteed such that plane #0 is always `Y`,
|
||||||
|
/// plane #1 is always `U` (`Cb`), and plane #2 is always `V` (`Cr`). All other formats are
|
||||||
|
/// represented by a single plane.
|
||||||
|
///
|
||||||
|
/// Additional information always accompanies the buffers, describing the row stride and the
|
||||||
|
/// pixel stride for each plane.
|
||||||
|
///
|
||||||
|
/// In case the buffer cannot be locked, this will return zero planes.
|
||||||
|
///
|
||||||
|
/// See the [`lock()`][Self::lock()] documentation for all other locking semantics.
|
||||||
|
#[cfg(feature = "api-level-29")]
|
||||||
|
pub fn lock_planes(
|
||||||
|
&self,
|
||||||
|
usage: HardwareBufferUsage,
|
||||||
|
fence: Option<OwnedFd>,
|
||||||
|
rect: Option<Rect>,
|
||||||
|
) -> Result<HardwareBufferPlanes> {
|
||||||
|
let fence = fence.map_or(-1, IntoRawFd::into_raw_fd);
|
||||||
|
let rect = match rect {
|
||||||
|
Some(rect) => &rect,
|
||||||
|
None => std::ptr::null(),
|
||||||
|
};
|
||||||
|
let planes = construct(|res| unsafe {
|
||||||
|
ffi::AHardwareBuffer_lockPlanes(self.as_ptr(), usage.bits(), fence, rect, res)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(HardwareBufferPlanes {
|
||||||
|
inner: planes,
|
||||||
|
index: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unlock the [`HardwareBuffer`] from direct CPU access.
|
||||||
|
///
|
||||||
|
/// Must be called after all changes to the buffer are completed by the caller. The function
|
||||||
|
/// will block until all work is completed. See [`unlock_async()`][Self::unlock_async()] for
|
||||||
|
/// a non-blocking variant that returns a file descriptor to be signaled on unlocking instead.
|
||||||
|
pub fn unlock(&self) -> Result<()> {
|
||||||
|
let status = unsafe { ffi::AHardwareBuffer_unlock(self.as_ptr(), std::ptr::null_mut()) };
|
||||||
|
status_to_io_result(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unlock the [`HardwareBuffer`] from direct CPU access.
|
||||||
|
///
|
||||||
|
/// Returns a fence file descriptor that will become signaled when unlocking is completed, or
|
||||||
|
/// [`None`] if unlocking is already finished. The caller is responsible for closing the file
|
||||||
|
/// descriptor once it's no longer needed. See [`unlock()`][Self::unlock()] for a variant that
|
||||||
|
/// blocks instead.
|
||||||
|
pub fn unlock_async(&self) -> Result<Option<OwnedFd>> {
|
||||||
|
let fence = construct(|res| unsafe { ffi::AHardwareBuffer_unlock(self.as_ptr(), res) })?;
|
||||||
|
Ok(match fence {
|
||||||
|
-1 => None,
|
||||||
|
fence => Some(unsafe { OwnedFd::from_raw_fd(fence) }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Receive a [`HardwareBuffer`] from an `AF_UNIX` socket.
|
||||||
|
///
|
||||||
|
/// `AF_UNIX` sockets are wrapped by [`std::os::unix::net::UnixListener`] and
|
||||||
|
/// [`std::os::unix::net::UnixStream`] in Rust and have a corresponding
|
||||||
|
/// [`std::os::unix::io::AsFd::as_fd()`] implementation.
|
||||||
|
pub fn recv_handle_from_unix_socket(socket_fd: BorrowedFd<'_>) -> Result<Self> {
|
||||||
|
unsafe {
|
||||||
|
let ptr = construct(|res| {
|
||||||
|
ffi::AHardwareBuffer_recvHandleFromUnixSocket(socket_fd.as_raw_fd(), res)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Self::from_ptr(NonNull::new_unchecked(ptr)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send the [`HardwareBuffer`] to an `AF_UNIX` socket.
|
||||||
|
///
|
||||||
|
/// `AF_UNIX` sockets are wrapped by [`std::os::unix::net::UnixListener`] and
|
||||||
|
/// [`std::os::unix::net::UnixStream`] in Rust and have a corresponding
|
||||||
|
/// [`std::os::unix::io::AsFd::as_fd()`] implementation.
|
||||||
|
pub fn send_handle_to_unix_socket(&self, socket_fd: BorrowedFd<'_>) -> Result<()> {
|
||||||
|
let status = unsafe {
|
||||||
|
ffi::AHardwareBuffer_sendHandleToUnixSocket(self.as_ptr(), socket_fd.as_raw_fd())
|
||||||
|
};
|
||||||
|
status_to_io_result(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Acquire a reference on the given [`HardwareBuffer`] object.
|
||||||
|
///
|
||||||
|
/// This prevents the object from being deleted until the last strong reference, represented
|
||||||
|
/// by [`HardwareBufferRef`], is [`drop()`]ped.
|
||||||
|
pub fn acquire(&self) -> HardwareBufferRef {
|
||||||
|
unsafe {
|
||||||
|
ffi::AHardwareBuffer_acquire(self.as_ptr());
|
||||||
|
HardwareBufferRef::from_ptr(self.inner)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A [`HardwareBuffer`] with an owned reference, that is released when dropped.
|
||||||
|
/// It behaves much like a strong [`std::rc::Rc`] reference.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct HardwareBufferRef {
|
||||||
|
inner: HardwareBuffer,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HardwareBufferRef {
|
||||||
|
/// Create an _owned_ [`HardwareBuffer`] from a native pointer
|
||||||
|
///
|
||||||
|
/// To wrap a weak reference (that is **not** `release`d on [`Drop`]), call
|
||||||
|
/// [`HardwareBuffer::from_ptr()`] instead.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// By calling this function, you assert that it is a valid pointer to an NDK
|
||||||
|
/// [`ffi::AHardwareBuffer`].
|
||||||
|
pub unsafe fn from_ptr(ptr: NonNull<ffi::AHardwareBuffer>) -> Self {
|
||||||
|
Self {
|
||||||
|
inner: HardwareBuffer { inner: ptr },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Deref for HardwareBufferRef {
|
||||||
|
type Target = HardwareBuffer;
|
||||||
|
|
||||||
|
fn deref(&self) -> &Self::Target {
|
||||||
|
&self.inner
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for HardwareBufferRef {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
unsafe { ffi::AHardwareBuffer_release(self.inner.as_ptr()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Clone for HardwareBufferRef {
|
||||||
|
fn clone(&self) -> Self {
|
||||||
|
self.acquire()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Buffer description.
|
||||||
|
///
|
||||||
|
/// Used for allocating new buffers and querying parameters of existing ones.
|
||||||
|
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct HardwareBufferDesc {
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
pub layers: u32,
|
||||||
|
pub format: HardwareBufferFormat,
|
||||||
|
pub usage: HardwareBufferUsage,
|
||||||
|
pub stride: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HardwareBufferDesc {
|
||||||
|
fn into_native(self) -> ffi::AHardwareBuffer_Desc {
|
||||||
|
ffi::AHardwareBuffer_Desc {
|
||||||
|
width: self.width,
|
||||||
|
height: self.height,
|
||||||
|
layers: self.layers,
|
||||||
|
format: i32::from(self.format)
|
||||||
|
.try_into()
|
||||||
|
.expect("i32->u32 overflow in HardwareBufferDesc::into_native()"),
|
||||||
|
usage: self.usage.bits(),
|
||||||
|
stride: self.stride,
|
||||||
|
rfu0: 0,
|
||||||
|
rfu1: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A native [`AHardwareBuffer_Plane`]
|
||||||
|
///
|
||||||
|
/// Contains the same fields as [`ffi::AHardwareBuffer_Plane`].
|
||||||
|
///
|
||||||
|
/// [`AHardwareBuffer_Plane`]: https://developer.android.com/ndk/reference/group/a-hardware-buffer#ahardwarebuffer_plane
|
||||||
|
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct LockedPlaneInfo {
|
||||||
|
pub virtual_address: *mut c_void,
|
||||||
|
pub bytes_per_pixel: u32,
|
||||||
|
pub bytes_per_stride: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Iterator over [`ffi::AHardwareBuffer_Planes`], containing a list of [`LockedPlaneInfo`].
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct HardwareBufferPlanes {
|
||||||
|
inner: ffi::AHardwareBuffer_Planes,
|
||||||
|
index: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Iterator for HardwareBufferPlanes {
|
||||||
|
type Item = LockedPlaneInfo;
|
||||||
|
|
||||||
|
fn next(&mut self) -> Option<LockedPlaneInfo> {
|
||||||
|
if self.index == self.inner.planeCount {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
let plane = self.inner.planes[self.index as usize];
|
||||||
|
self.index += 1;
|
||||||
|
Some(LockedPlaneInfo {
|
||||||
|
virtual_address: plane.data,
|
||||||
|
bytes_per_pixel: plane.pixelStride,
|
||||||
|
bytes_per_stride: plane.rowStride,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
//! Bindings for [`AHardwareBuffer_Format`]
|
||||||
|
//!
|
||||||
|
//! [`AHardwareBuffer_Format`]: https://developer.android.com/ndk/reference/group/a-hardware-buffer#ahardwarebuffer_format
|
||||||
|
|
||||||
|
use num_enum::{FromPrimitive, IntoPrimitive};
|
||||||
|
|
||||||
|
/// Buffer pixel formats.
|
||||||
|
#[repr(i32)]
|
||||||
|
#[derive(Copy, Clone, Debug, PartialEq, Eq, FromPrimitive, IntoPrimitive)]
|
||||||
|
#[allow(non_camel_case_types)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum HardwareBufferFormat {
|
||||||
|
/// Matches deprecated [`ffi::ANativeWindow_LegacyFormat::WINDOW_FORMAT_RGBA_8888`].0.
|
||||||
|
#[doc(alias = "AHARDWAREBUFFER_FORMAT_R8G8B8A8_UNORM")]
|
||||||
|
R8G8B8A8_UNORM = ffi::AHardwareBuffer_Format::AHARDWAREBUFFER_FORMAT_R8G8B8A8_UNORM.0 as i32,
|
||||||
|
/// Matches deprecated [`ffi::ANativeWindow_LegacyFormat::WINDOW_FORMAT_RGBX_8888`].0.
|
||||||
|
#[doc(alias = "AHARDWAREBUFFER_FORMAT_R8G8B8X8_UNORM")]
|
||||||
|
R8G8B8X8_UNORM = ffi::AHardwareBuffer_Format::AHARDWAREBUFFER_FORMAT_R8G8B8X8_UNORM.0 as i32,
|
||||||
|
#[cfg(feature = "api-level-26")]
|
||||||
|
#[doc(alias = "AHARDWAREBUFFER_FORMAT_R8G8B8_UNORM")]
|
||||||
|
R8G8B8_UNORM = ffi::AHardwareBuffer_Format::AHARDWAREBUFFER_FORMAT_R8G8B8_UNORM.0 as i32,
|
||||||
|
/// Matches deprecated [`ffi::ANativeWindow_LegacyFormat::WINDOW_FORMAT_RGB_565`].0.
|
||||||
|
#[doc(alias = "AHARDWAREBUFFER_FORMAT_R5G6B5_UNORM")]
|
||||||
|
R5G6B5_UNORM = ffi::AHardwareBuffer_Format::AHARDWAREBUFFER_FORMAT_R5G6B5_UNORM.0 as i32,
|
||||||
|
#[cfg(feature = "api-level-26")]
|
||||||
|
#[doc(alias = "AHARDWAREBUFFER_FORMAT_R16G16B16A16_FLOAT")]
|
||||||
|
R16G16B16A16_FLOAT =
|
||||||
|
ffi::AHardwareBuffer_Format::AHARDWAREBUFFER_FORMAT_R16G16B16A16_FLOAT.0 as i32,
|
||||||
|
#[cfg(feature = "api-level-26")]
|
||||||
|
#[doc(alias = "AHARDWAREBUFFER_FORMAT_R10G10B10A2_UNORM")]
|
||||||
|
R10G10B10A2_UNORM =
|
||||||
|
ffi::AHardwareBuffer_Format::AHARDWAREBUFFER_FORMAT_R10G10B10A2_UNORM.0 as i32,
|
||||||
|
#[cfg(feature = "api-level-26")]
|
||||||
|
#[doc(alias = "AHARDWAREBUFFER_FORMAT_BLOB")]
|
||||||
|
BLOB = ffi::AHardwareBuffer_Format::AHARDWAREBUFFER_FORMAT_BLOB.0 as i32,
|
||||||
|
#[cfg(feature = "api-level-26")]
|
||||||
|
#[doc(alias = "AHARDWAREBUFFER_FORMAT_D16_UNORM")]
|
||||||
|
D16_UNORM = ffi::AHardwareBuffer_Format::AHARDWAREBUFFER_FORMAT_D16_UNORM.0 as i32,
|
||||||
|
#[cfg(feature = "api-level-26")]
|
||||||
|
#[doc(alias = "AHARDWAREBUFFER_FORMAT_D24_UNORM")]
|
||||||
|
D24_UNORM = ffi::AHardwareBuffer_Format::AHARDWAREBUFFER_FORMAT_D24_UNORM.0 as i32,
|
||||||
|
#[cfg(feature = "api-level-26")]
|
||||||
|
#[doc(alias = "AHARDWAREBUFFER_FORMAT_D24_UNORM_S8_UINT")]
|
||||||
|
D24_UNORM_S8_UINT =
|
||||||
|
ffi::AHardwareBuffer_Format::AHARDWAREBUFFER_FORMAT_D24_UNORM_S8_UINT.0 as i32,
|
||||||
|
#[cfg(feature = "api-level-26")]
|
||||||
|
#[doc(alias = "AHARDWAREBUFFER_FORMAT_D32_FLOAT")]
|
||||||
|
D32_FLOAT = ffi::AHardwareBuffer_Format::AHARDWAREBUFFER_FORMAT_D32_FLOAT.0 as i32,
|
||||||
|
#[cfg(feature = "api-level-26")]
|
||||||
|
#[doc(alias = "AHARDWAREBUFFER_FORMAT_D32_FLOAT_S8_UINT")]
|
||||||
|
D32_FLOAT_S8_UINT =
|
||||||
|
ffi::AHardwareBuffer_Format::AHARDWAREBUFFER_FORMAT_D32_FLOAT_S8_UINT.0 as i32,
|
||||||
|
#[cfg(feature = "api-level-26")]
|
||||||
|
#[doc(alias = "AHARDWAREBUFFER_FORMAT_S8_UINT")]
|
||||||
|
S8_UINT = ffi::AHardwareBuffer_Format::AHARDWAREBUFFER_FORMAT_S8_UINT.0 as i32,
|
||||||
|
#[cfg(feature = "api-level-26")]
|
||||||
|
#[doc(alias = "AHARDWAREBUFFER_FORMAT_Y8Cb8Cr8_420")]
|
||||||
|
Y8Cb8Cr8_420 = ffi::AHardwareBuffer_Format::AHARDWAREBUFFER_FORMAT_Y8Cb8Cr8_420.0 as i32,
|
||||||
|
#[cfg(feature = "api-level-26")]
|
||||||
|
#[doc(alias = "AHARDWAREBUFFER_FORMAT_YCbCr_P010")]
|
||||||
|
YCbCr_P010 = ffi::AHardwareBuffer_Format::AHARDWAREBUFFER_FORMAT_YCbCr_P010.0 as i32,
|
||||||
|
#[cfg(feature = "api-level-26")]
|
||||||
|
R8_UNORM = ffi::AHardwareBuffer_Format::AHARDWAREBUFFER_FORMAT_R8_UNORM.0 as i32,
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
#[num_enum(catch_all)]
|
||||||
|
__Unknown(i32),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HardwareBufferFormat {
|
||||||
|
/// Returns [`None`] when there is no immediate byte size available for this format, for
|
||||||
|
/// example on planar buffer formats.
|
||||||
|
pub fn bytes_per_pixel(self) -> Option<usize> {
|
||||||
|
Some(match self {
|
||||||
|
Self::R8G8B8A8_UNORM | Self::R8G8B8X8_UNORM => 4,
|
||||||
|
#[cfg(feature = "api-level-26")]
|
||||||
|
Self::R8G8B8_UNORM => 3,
|
||||||
|
Self::R5G6B5_UNORM => 2,
|
||||||
|
#[cfg(feature = "api-level-26")]
|
||||||
|
Self::R16G16B16A16_FLOAT => 8,
|
||||||
|
#[cfg(feature = "api-level-26")]
|
||||||
|
Self::R10G10B10A2_UNORM => 4,
|
||||||
|
#[cfg(feature = "api-level-26")]
|
||||||
|
Self::BLOB => 1,
|
||||||
|
#[cfg(feature = "api-level-26")]
|
||||||
|
Self::D16_UNORM => 2,
|
||||||
|
#[cfg(feature = "api-level-26")]
|
||||||
|
Self::D24_UNORM => 3,
|
||||||
|
#[cfg(feature = "api-level-26")]
|
||||||
|
Self::D24_UNORM_S8_UINT => 4,
|
||||||
|
#[cfg(feature = "api-level-26")]
|
||||||
|
Self::D32_FLOAT => 4,
|
||||||
|
#[cfg(feature = "api-level-26")]
|
||||||
|
Self::D32_FLOAT_S8_UINT => 5,
|
||||||
|
#[cfg(feature = "api-level-26")]
|
||||||
|
Self::S8_UINT => 1,
|
||||||
|
#[cfg(feature = "api-level-26")]
|
||||||
|
Self::Y8Cb8Cr8_420 => 3,
|
||||||
|
#[cfg(feature = "api-level-26")]
|
||||||
|
Self::YCbCr_P010 => return None,
|
||||||
|
#[cfg(feature = "api-level-26")]
|
||||||
|
Self::R8_UNORM => 1,
|
||||||
|
Self::__Unknown(_) => return None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
//! Bindings for [`AInputQueue`]
|
||||||
|
//!
|
||||||
|
//! [`AInputQueue`]: https://developer.android.com/ndk/reference/group/input#ainputqueue
|
||||||
|
|
||||||
|
use std::io::Result;
|
||||||
|
use std::os::raw::c_int;
|
||||||
|
use std::ptr::{self, NonNull};
|
||||||
|
|
||||||
|
#[cfg(feature = "api-level-33")]
|
||||||
|
use jni_sys::{jobject, JNIEnv};
|
||||||
|
|
||||||
|
use crate::event::InputEvent;
|
||||||
|
#[cfg(doc)]
|
||||||
|
use crate::event::KeyEvent;
|
||||||
|
use crate::looper::ForeignLooper;
|
||||||
|
use crate::utils::status_to_io_result;
|
||||||
|
|
||||||
|
/// A native [`AInputQueue *`]
|
||||||
|
///
|
||||||
|
/// An input queue is the facility through which you retrieve input events.
|
||||||
|
///
|
||||||
|
/// [`AInputQueue *`]: https://developer.android.com/ndk/reference/group/input#ainputqueue
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct InputQueue {
|
||||||
|
ptr: NonNull<ffi::AInputQueue>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// It gets shared between threads in `ndk-glue`
|
||||||
|
unsafe impl Send for InputQueue {}
|
||||||
|
unsafe impl Sync for InputQueue {}
|
||||||
|
|
||||||
|
impl InputQueue {
|
||||||
|
/// Construct an [`InputQueue`] from the native pointer.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// By calling this function, you assert that the pointer is a valid pointer to an NDK [`ffi::AInputQueue`].
|
||||||
|
pub unsafe fn from_ptr(ptr: NonNull<ffi::AInputQueue>) -> Self {
|
||||||
|
Self { ptr }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the [`InputQueue`] object associated with the supplied
|
||||||
|
/// [Java `InputQueue`][`android.view.InputQueue`] object.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
///
|
||||||
|
/// This function should be called with a healthy JVM pointer and with a non-null
|
||||||
|
/// [`android.view.InputQueue`], which must be kept alive on the Java/Kotlin side.
|
||||||
|
///
|
||||||
|
/// The returned native object holds a weak reference to the Java object, and is only valid as
|
||||||
|
/// long as the Java object has not yet been disposed. You should ensure that there is a strong
|
||||||
|
/// reference to the Java object and that it has not been disposed before using the returned
|
||||||
|
/// object.
|
||||||
|
///
|
||||||
|
/// [`android.view.InputQueue`]: https://developer.android.com/reference/android/view/InputQueue
|
||||||
|
#[cfg(feature = "api-level-33")]
|
||||||
|
#[doc(alias = "AInputQueue_fromJava")]
|
||||||
|
pub unsafe fn from_java(env: *mut JNIEnv, input_queue: jobject) -> Option<Self> {
|
||||||
|
let ptr = unsafe { ffi::AInputQueue_fromJava(env, input_queue) };
|
||||||
|
Some(Self::from_ptr(NonNull::new(ptr)?))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ptr(&self) -> NonNull<ffi::AInputQueue> {
|
||||||
|
self.ptr
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the next available [`InputEvent`] from the queue.
|
||||||
|
///
|
||||||
|
/// Returns [`None`] if no event is available.
|
||||||
|
#[doc(alias = "AInputQueue_getEvent")]
|
||||||
|
pub fn event(&self) -> Result<Option<InputEvent>> {
|
||||||
|
let mut out_event = ptr::null_mut();
|
||||||
|
let status = unsafe { ffi::AInputQueue_getEvent(self.ptr.as_ptr(), &mut out_event) };
|
||||||
|
match status_to_io_result(status) {
|
||||||
|
Ok(()) => {
|
||||||
|
debug_assert!(!out_event.is_null());
|
||||||
|
Ok(Some(unsafe {
|
||||||
|
InputEvent::from_ptr(NonNull::new_unchecked(out_event))
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => Ok(None),
|
||||||
|
Err(e) => Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns [`true`] if there are one or more events available in the input queue.
|
||||||
|
#[doc(alias = "AInputQueue_hasEvents")]
|
||||||
|
pub fn has_events(&self) -> bool {
|
||||||
|
match unsafe { ffi::AInputQueue_hasEvents(self.ptr.as_ptr()) } {
|
||||||
|
0 => false,
|
||||||
|
1 => true,
|
||||||
|
r => unreachable!("AInputQueue_hasEvents returned non-boolean {}", r),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends the key for standard pre-dispatching that is, possibly deliver it to the current IME
|
||||||
|
/// to be consumed before the app.
|
||||||
|
///
|
||||||
|
/// Returns [`Some`] if it was not pre-dispatched, meaning you can process it right now. If
|
||||||
|
/// [`None`] is returned, you must abandon the current event processing and allow the event to
|
||||||
|
/// appear again in the event queue (if it does not get consumed during pre-dispatching).
|
||||||
|
///
|
||||||
|
/// Also returns [`None`] if `event` is not a [`KeyEvent`].
|
||||||
|
#[doc(alias = "AInputQueue_preDispatchEvent")]
|
||||||
|
pub fn pre_dispatch(&self, event: InputEvent) -> Option<InputEvent> {
|
||||||
|
match unsafe { ffi::AInputQueue_preDispatchEvent(self.ptr.as_ptr(), event.ptr().as_ptr()) }
|
||||||
|
{
|
||||||
|
0 => Some(event),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Report that dispatching has finished with the given [`InputEvent`].
|
||||||
|
///
|
||||||
|
/// This must be called after receiving an event with [`InputQueue::event()`].
|
||||||
|
#[doc(alias = "AInputQueue_finishEvent")]
|
||||||
|
pub fn finish_event(&self, event: InputEvent, handled: bool) {
|
||||||
|
unsafe {
|
||||||
|
ffi::AInputQueue_finishEvent(self.ptr.as_ptr(), event.ptr().as_ptr(), handled as c_int)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add this input queue to a [`ForeignLooper`] for processing.
|
||||||
|
///
|
||||||
|
/// See [`ForeignLooper::add_fd()`] for information on the `ident`, `callback`, and `data` params.
|
||||||
|
#[doc(alias = "AInputQueue_attachLooper")]
|
||||||
|
pub fn attach_looper(&self, looper: &ForeignLooper, id: i32) {
|
||||||
|
unsafe {
|
||||||
|
ffi::AInputQueue_attachLooper(
|
||||||
|
self.ptr.as_ptr(),
|
||||||
|
looper.ptr().as_ptr(),
|
||||||
|
id,
|
||||||
|
None,
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove this input queue from the [`ForeignLooper`] it is currently attached to.
|
||||||
|
#[doc(alias = "AInputQueue_detachLooper")]
|
||||||
|
pub fn detach_looper(&self) {
|
||||||
|
unsafe { ffi::AInputQueue_detachLooper(self.ptr.as_ptr()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
//! # Android NDK
|
||||||
|
//!
|
||||||
|
//! Bindings to the [Android NDK].
|
||||||
|
//!
|
||||||
|
//! [Android NDK]: https://developer.android.com/ndk/reference
|
||||||
|
#![warn(
|
||||||
|
missing_debug_implementations,
|
||||||
|
rust_2018_idioms,
|
||||||
|
trivial_casts,
|
||||||
|
unused_qualifications
|
||||||
|
)]
|
||||||
|
#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))]
|
||||||
|
|
||||||
|
pub mod asset;
|
||||||
|
pub mod audio;
|
||||||
|
pub mod bitmap;
|
||||||
|
pub mod configuration;
|
||||||
|
pub mod data_space;
|
||||||
|
pub mod event;
|
||||||
|
pub mod font;
|
||||||
|
pub mod hardware_buffer;
|
||||||
|
pub mod hardware_buffer_format;
|
||||||
|
pub mod input_queue;
|
||||||
|
pub mod looper;
|
||||||
|
pub mod media;
|
||||||
|
pub mod media_error;
|
||||||
|
pub mod native_activity;
|
||||||
|
pub mod native_window;
|
||||||
|
pub mod shared_memory;
|
||||||
|
pub mod surface_texture;
|
||||||
|
pub mod sync;
|
||||||
|
pub mod trace;
|
||||||
|
mod utils;
|
||||||
+459
@@ -0,0 +1,459 @@
|
|||||||
|
//! Bindings for [`ALooper`]
|
||||||
|
//!
|
||||||
|
//! In Android, [`ALooper`]s are inherently thread-local. Due to this, there are two different
|
||||||
|
//! [`ALooper`] interfaces exposed in this module:
|
||||||
|
//!
|
||||||
|
//! * [`ThreadLooper`], which has methods for the operations performable with a looper in one's own
|
||||||
|
//! thread; and
|
||||||
|
//! * [`ForeignLooper`], which has methods for the operations performable with any thread's looper.
|
||||||
|
//!
|
||||||
|
//! [`ALooper`]: https://developer.android.com/ndk/reference/group/looper#alooper
|
||||||
|
|
||||||
|
use std::mem::ManuallyDrop;
|
||||||
|
use std::os::{
|
||||||
|
fd::{AsRawFd, BorrowedFd, RawFd},
|
||||||
|
raw::c_void,
|
||||||
|
};
|
||||||
|
use std::ptr;
|
||||||
|
use std::time::Duration;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
use crate::utils::abort_on_panic;
|
||||||
|
|
||||||
|
/// A thread-local native [`ALooper *`]. This promises that there is a looper associated with the
|
||||||
|
/// current thread.
|
||||||
|
///
|
||||||
|
/// [`ALooper *`]: https://developer.android.com/ndk/reference/group/looper#alooper
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ThreadLooper {
|
||||||
|
_marker: std::marker::PhantomData<*mut ()>, // Not send or sync
|
||||||
|
foreign: ForeignLooper,
|
||||||
|
}
|
||||||
|
|
||||||
|
bitflags::bitflags! {
|
||||||
|
/// Flags for file descriptor events that a looper can monitor.
|
||||||
|
///
|
||||||
|
/// These flag bits can be combined to monitor multiple events at once.
|
||||||
|
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
pub struct FdEvent : u32 {
|
||||||
|
/// The file descriptor is available for read operations.
|
||||||
|
#[doc(alias = "ALOOPER_EVENT_INPUT")]
|
||||||
|
const INPUT = ffi::ALOOPER_EVENT_INPUT;
|
||||||
|
/// The file descriptor is available for write operations.
|
||||||
|
#[doc(alias = "ALOOPER_EVENT_OUTPUT")]
|
||||||
|
const OUTPUT = ffi::ALOOPER_EVENT_OUTPUT;
|
||||||
|
/// The file descriptor has encountered an error condition.
|
||||||
|
///
|
||||||
|
/// The looper always sends notifications about errors; it is not necessary to specify this
|
||||||
|
/// event flag in the requested event set.
|
||||||
|
#[doc(alias = "ALOOPER_EVENT_ERROR")]
|
||||||
|
const ERROR = ffi::ALOOPER_EVENT_ERROR;
|
||||||
|
/// The file descriptor was hung up.
|
||||||
|
///
|
||||||
|
/// For example, indicates that the remote end of a pipe or socket was closed.
|
||||||
|
///
|
||||||
|
/// The looper always sends notifications about hangups; it is not necessary to specify this
|
||||||
|
/// event flag in the requested event set.
|
||||||
|
#[doc(alias = "ALOOPER_EVENT_HANGUP")]
|
||||||
|
const HANGUP = ffi::ALOOPER_EVENT_HANGUP;
|
||||||
|
/// The file descriptor is invalid.
|
||||||
|
///
|
||||||
|
/// For example, the file descriptor was closed prematurely.
|
||||||
|
///
|
||||||
|
/// The looper always sends notifications about invalid file descriptors; it is not
|
||||||
|
/// necessary to specify this event flag in the requested event set.
|
||||||
|
#[doc(alias = "ALOOPER_EVENT_INVALID")]
|
||||||
|
const INVALID = ffi::ALOOPER_EVENT_INVALID;
|
||||||
|
|
||||||
|
// https://docs.rs/bitflags/latest/bitflags/#externally-defined-flags
|
||||||
|
const _ = !0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The poll result from a [`ThreadLooper`].
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum Poll<'fd> {
|
||||||
|
/// This looper was woken using [`ForeignLooper::wake()`]
|
||||||
|
Wake,
|
||||||
|
/// For [`ThreadLooper::poll_once*()`][ThreadLooper::poll_once()], an event was received and processed using a callback.
|
||||||
|
Callback,
|
||||||
|
/// For [`ThreadLooper::poll_*_timeout()`][ThreadLooper::poll_once_timeout()], the requested timeout was reached before any events.
|
||||||
|
Timeout,
|
||||||
|
/// An event was received
|
||||||
|
Event {
|
||||||
|
ident: i32,
|
||||||
|
/// # Safety
|
||||||
|
/// The caller should guarantee that this file descriptor remains open after it was added
|
||||||
|
/// via [`ForeignLooper::add_fd()`] or [`ForeignLooper::add_fd_with_callback()`].
|
||||||
|
fd: BorrowedFd<'fd>,
|
||||||
|
events: FdEvent,
|
||||||
|
data: *mut c_void,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Copy, Clone, Error)]
|
||||||
|
#[error("Android Looper error")]
|
||||||
|
pub struct LooperError;
|
||||||
|
|
||||||
|
impl ThreadLooper {
|
||||||
|
/// Prepares a looper for the current thread and returns it
|
||||||
|
pub fn prepare() -> Self {
|
||||||
|
unsafe {
|
||||||
|
let ptr = ffi::ALooper_prepare(ffi::ALOOPER_PREPARE_ALLOW_NON_CALLBACKS as _);
|
||||||
|
let foreign = ForeignLooper::from_ptr(ptr::NonNull::new(ptr).expect("looper non null"));
|
||||||
|
Self {
|
||||||
|
_marker: std::marker::PhantomData,
|
||||||
|
foreign,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the looper associated with the current thread, if any.
|
||||||
|
pub fn for_thread() -> Option<Self> {
|
||||||
|
Some(Self {
|
||||||
|
_marker: std::marker::PhantomData,
|
||||||
|
foreign: ForeignLooper::for_thread()?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Polls the looper, blocking on processing an event, but with a timeout in milliseconds.
|
||||||
|
/// Give a timeout of `0` to make this non-blocking.
|
||||||
|
fn poll_once_ms(&self, ms: i32) -> Result<Poll<'_>, LooperError> {
|
||||||
|
let mut fd = -1;
|
||||||
|
let mut events = -1;
|
||||||
|
let mut data: *mut c_void = ptr::null_mut();
|
||||||
|
match unsafe { ffi::ALooper_pollOnce(ms, &mut fd, &mut events, &mut data) } {
|
||||||
|
ffi::ALOOPER_POLL_WAKE => Ok(Poll::Wake),
|
||||||
|
ffi::ALOOPER_POLL_CALLBACK => Ok(Poll::Callback),
|
||||||
|
ffi::ALOOPER_POLL_TIMEOUT => Ok(Poll::Timeout),
|
||||||
|
ffi::ALOOPER_POLL_ERROR => Err(LooperError),
|
||||||
|
ident if ident >= 0 => Ok(Poll::Event {
|
||||||
|
ident,
|
||||||
|
// SAFETY: Even though this FD at least shouldn't outlive self, a user could have
|
||||||
|
// closed it after calling add_fd or add_fd_with_callback.
|
||||||
|
fd: unsafe { BorrowedFd::borrow_raw(fd) },
|
||||||
|
events: FdEvent::from_bits(events as u32)
|
||||||
|
.expect("poll event contains unknown bits"),
|
||||||
|
data,
|
||||||
|
}),
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Polls the looper, blocking on processing an event.
|
||||||
|
#[inline]
|
||||||
|
pub fn poll_once(&self) -> Result<Poll<'_>, LooperError> {
|
||||||
|
self.poll_once_ms(-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Polls the looper, blocking on processing an event, but with a timeout. Give a timeout of
|
||||||
|
/// [`Duration::ZERO`] to make this non-blocking.
|
||||||
|
///
|
||||||
|
/// It panics if the timeout is larger than expressible as an [`i32`] of milliseconds (roughly 25
|
||||||
|
/// days).
|
||||||
|
#[inline]
|
||||||
|
pub fn poll_once_timeout(&self, timeout: Duration) -> Result<Poll<'_>, LooperError> {
|
||||||
|
self.poll_once_ms(
|
||||||
|
timeout
|
||||||
|
.as_millis()
|
||||||
|
.try_into()
|
||||||
|
.expect("Supplied timeout is too large"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Repeatedly polls the looper, blocking on processing an event, but with a timeout in
|
||||||
|
/// milliseconds. Give a timeout of `0` to make this non-blocking.
|
||||||
|
///
|
||||||
|
/// This function will never return [`Poll::Callback`].
|
||||||
|
fn poll_all_ms(&self, ms: i32) -> Result<Poll<'_>, LooperError> {
|
||||||
|
let mut fd = -1;
|
||||||
|
let mut events = -1;
|
||||||
|
let mut data: *mut c_void = ptr::null_mut();
|
||||||
|
match unsafe { ffi::ALooper_pollAll(ms, &mut fd, &mut events, &mut data) } {
|
||||||
|
ffi::ALOOPER_POLL_WAKE => Ok(Poll::Wake),
|
||||||
|
ffi::ALOOPER_POLL_TIMEOUT => Ok(Poll::Timeout),
|
||||||
|
ffi::ALOOPER_POLL_ERROR => Err(LooperError),
|
||||||
|
ident if ident >= 0 => Ok(Poll::Event {
|
||||||
|
ident,
|
||||||
|
// SAFETY: Even though this FD at least shouldn't outlive self, a user could have
|
||||||
|
// closed it after calling add_fd or add_fd_with_callback.
|
||||||
|
fd: unsafe { BorrowedFd::borrow_raw(fd) },
|
||||||
|
events: FdEvent::from_bits(events as u32)
|
||||||
|
.expect("poll event contains unknown bits"),
|
||||||
|
data,
|
||||||
|
}),
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Repeatedly polls the looper, blocking on processing an event.
|
||||||
|
///
|
||||||
|
/// This function will never return [`Poll::Callback`].
|
||||||
|
#[inline]
|
||||||
|
pub fn poll_all(&self) -> Result<Poll<'_>, LooperError> {
|
||||||
|
self.poll_all_ms(-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Repeatedly polls the looper, blocking on processing an event, but with a timeout. Give a
|
||||||
|
/// timeout of [`Duration::ZERO`] to make this non-blocking.
|
||||||
|
///
|
||||||
|
/// This function will never return [`Poll::Callback`].
|
||||||
|
///
|
||||||
|
/// It panics if the timeout is larger than expressible as an [`i32`] of milliseconds (roughly 25
|
||||||
|
/// days).
|
||||||
|
#[inline]
|
||||||
|
pub fn poll_all_timeout(&self, timeout: Duration) -> Result<Poll<'_>, LooperError> {
|
||||||
|
self.poll_all_ms(
|
||||||
|
timeout
|
||||||
|
.as_millis()
|
||||||
|
.try_into()
|
||||||
|
.expect("Supplied timeout is too large"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a file descriptor to be polled, with a callback that is invoked when any of the
|
||||||
|
/// [`FdEvent`]s described in `events` is triggered.
|
||||||
|
///
|
||||||
|
/// The callback receives the file descriptor it is associated with and a bitmask of the poll
|
||||||
|
/// events that were triggered (typically [`FdEvent::INPUT`]). It should return [`true`] to
|
||||||
|
/// continue receiving callbacks, or [`false`] to have the callback unregistered.
|
||||||
|
///
|
||||||
|
/// See also [the NDK
|
||||||
|
/// docs](https://developer.android.com/ndk/reference/group/looper.html#alooper_addfd).
|
||||||
|
///
|
||||||
|
/// Note that this will leak a [`Box`] unless the callback returns [`false`] to unregister
|
||||||
|
/// itself.
|
||||||
|
///
|
||||||
|
/// # Threading
|
||||||
|
/// This function will be called on the current thread when this [`ThreadLooper`] is
|
||||||
|
/// polled. A callback can also be registered from other threads via the equivalent
|
||||||
|
/// [`ForeignLooper::add_fd_with_callback()`] function, which requires a [`Send`] bound.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// The caller should guarantee that this file descriptor stays open until it is removed via
|
||||||
|
/// [`remove_fd()`][ForeignLooper::remove_fd()] or by returning [`false`] from the callback,
|
||||||
|
/// and for however long the caller wishes to use this file descriptor inside and after the
|
||||||
|
/// callback.
|
||||||
|
#[doc(alias = "ALooper_addFd")]
|
||||||
|
pub fn add_fd_with_callback<F: FnMut(BorrowedFd<'_>, FdEvent) -> bool>(
|
||||||
|
&self,
|
||||||
|
fd: BorrowedFd<'_>,
|
||||||
|
events: FdEvent,
|
||||||
|
callback: F,
|
||||||
|
) -> Result<(), LooperError> {
|
||||||
|
unsafe {
|
||||||
|
self.foreign
|
||||||
|
.add_fd_with_callback_assume_send(fd, events, callback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a reference to the [`ForeignLooper`] that is associated with the current thread.
|
||||||
|
pub fn as_foreign(&self) -> &ForeignLooper {
|
||||||
|
&self.foreign
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn into_foreign(self) -> ForeignLooper {
|
||||||
|
self.foreign
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A native [`ALooper *`], not necessarily allocated with the current thread.
|
||||||
|
///
|
||||||
|
/// [`ALooper *`]: https://developer.android.com/ndk/reference/group/looper#alooper
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ForeignLooper {
|
||||||
|
ptr: ptr::NonNull<ffi::ALooper>,
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe impl Send for ForeignLooper {}
|
||||||
|
unsafe impl Sync for ForeignLooper {}
|
||||||
|
|
||||||
|
impl Drop for ForeignLooper {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
unsafe { ffi::ALooper_release(self.ptr.as_ptr()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Clone for ForeignLooper {
|
||||||
|
fn clone(&self) -> Self {
|
||||||
|
unsafe {
|
||||||
|
ffi::ALooper_acquire(self.ptr.as_ptr());
|
||||||
|
Self { ptr: self.ptr }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ForeignLooper {
|
||||||
|
/// Returns the looper associated with the current thread, if any.
|
||||||
|
#[inline]
|
||||||
|
pub fn for_thread() -> Option<Self> {
|
||||||
|
ptr::NonNull::new(unsafe { ffi::ALooper_forThread() })
|
||||||
|
.map(|ptr| unsafe { Self::from_ptr(ptr) })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Construct a [`ForeignLooper`] object from the given pointer.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// By calling this function, you guarantee that the pointer is a valid, non-null pointer to an
|
||||||
|
/// NDK [`ffi::ALooper`].
|
||||||
|
#[inline]
|
||||||
|
pub unsafe fn from_ptr(ptr: ptr::NonNull<ffi::ALooper>) -> Self {
|
||||||
|
ffi::ALooper_acquire(ptr.as_ptr());
|
||||||
|
Self { ptr }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a pointer to the NDK `ALooper` object.
|
||||||
|
#[inline]
|
||||||
|
pub fn ptr(&self) -> ptr::NonNull<ffi::ALooper> {
|
||||||
|
self.ptr
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wakes the looper. An event of [`Poll::Wake`] will be sent.
|
||||||
|
pub fn wake(&self) {
|
||||||
|
unsafe { ffi::ALooper_wake(self.ptr.as_ptr()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a file descriptor to be polled, without a callback.
|
||||||
|
///
|
||||||
|
/// See also [the NDK
|
||||||
|
/// docs](https://developer.android.com/ndk/reference/group/looper.html#alooper_addfd).
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// The caller should guarantee that this file descriptor stays open until it is removed via
|
||||||
|
/// [`remove_fd()`][Self::remove_fd()], and for however long the caller wishes to use this file
|
||||||
|
/// descriptor when it is returned in [`Poll::Event::fd`].
|
||||||
|
|
||||||
|
// `ALooper_addFd` won't dereference `data`; it will only pass it on to the event.
|
||||||
|
// Optionally dereferencing it there already enforces `unsafe` context.
|
||||||
|
#[allow(clippy::not_unsafe_ptr_arg_deref)]
|
||||||
|
pub fn add_fd(
|
||||||
|
&self,
|
||||||
|
fd: BorrowedFd<'_>,
|
||||||
|
ident: i32,
|
||||||
|
events: FdEvent,
|
||||||
|
data: *mut c_void,
|
||||||
|
) -> Result<(), LooperError> {
|
||||||
|
match unsafe {
|
||||||
|
ffi::ALooper_addFd(
|
||||||
|
self.ptr.as_ptr(),
|
||||||
|
fd.as_raw_fd(),
|
||||||
|
ident,
|
||||||
|
events.bits() as i32,
|
||||||
|
None,
|
||||||
|
data,
|
||||||
|
)
|
||||||
|
} {
|
||||||
|
1 => Ok(()),
|
||||||
|
-1 => Err(LooperError),
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a file descriptor to be polled, with a callback that is invoked when any of the
|
||||||
|
/// [`FdEvent`]s described in `events` is triggered.
|
||||||
|
///
|
||||||
|
/// The callback receives the file descriptor it is associated with and a bitmask of the poll
|
||||||
|
/// events that were triggered (typically [`FdEvent::INPUT`]). It should return [`true`] to
|
||||||
|
/// continue receiving callbacks, or [`false`] to have the callback unregistered.
|
||||||
|
///
|
||||||
|
/// See also [the NDK
|
||||||
|
/// docs](https://developer.android.com/ndk/reference/group/looper.html#alooper_addfd).
|
||||||
|
///
|
||||||
|
/// Note that this will leak a [`Box`] unless the callback returns [`false`] to unregister
|
||||||
|
/// itself.
|
||||||
|
///
|
||||||
|
/// # Threading
|
||||||
|
/// This function will be called on the looper thread where and when it is polled.
|
||||||
|
/// For registering callbacks without [`Send`] requirement, call the equivalent
|
||||||
|
/// [`ThreadLooper::add_fd_with_callback()`] function on the Looper thread.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// The caller should guarantee that this file descriptor stays open until it is removed via
|
||||||
|
/// [`remove_fd()`][Self::remove_fd()] or by returning [`false`] from the callback, and for
|
||||||
|
/// however long the caller wishes to use this file descriptor inside and after the callback.
|
||||||
|
#[doc(alias = "ALooper_addFd")]
|
||||||
|
pub fn add_fd_with_callback<F: FnMut(BorrowedFd<'_>, FdEvent) -> bool + Send>(
|
||||||
|
&self,
|
||||||
|
fd: BorrowedFd<'_>,
|
||||||
|
events: FdEvent,
|
||||||
|
callback: F,
|
||||||
|
) -> Result<(), LooperError> {
|
||||||
|
unsafe { self.add_fd_with_callback_assume_send(fd, events, callback) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Private helper to deduplicate/commonize the implementation behind
|
||||||
|
/// [`ForeignLooper::add_fd_with_callback()`] and [`ThreadLooper::add_fd_with_callback()`],
|
||||||
|
/// as both have their own way of guaranteeing thread-safety. The former, [`ForeignLooper`],
|
||||||
|
/// requires the closure to be [`Send`]. The latter, [`ThreadLooper`], can only exist on the
|
||||||
|
/// thread where polling happens and where the closure will end up being invoked, and does not
|
||||||
|
/// require [`Send`].
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// The caller must guarantee that `F` is [`Send`] or that `F` will only run on the current
|
||||||
|
/// thread. See the explanation above about why this function exists.
|
||||||
|
unsafe fn add_fd_with_callback_assume_send<F: FnMut(BorrowedFd<'_>, FdEvent) -> bool>(
|
||||||
|
&self,
|
||||||
|
fd: BorrowedFd<'_>,
|
||||||
|
events: FdEvent,
|
||||||
|
callback: F,
|
||||||
|
) -> Result<(), LooperError> {
|
||||||
|
extern "C" fn cb_handler<F: FnMut(BorrowedFd<'_>, FdEvent) -> bool>(
|
||||||
|
fd: RawFd,
|
||||||
|
events: i32,
|
||||||
|
data: *mut c_void,
|
||||||
|
) -> i32 {
|
||||||
|
abort_on_panic(|| unsafe {
|
||||||
|
let mut cb = ManuallyDrop::new(Box::<F>::from_raw(data as *mut _));
|
||||||
|
let events = FdEvent::from_bits_retain(
|
||||||
|
events.try_into().expect("Unexpected sign bit in `events`"),
|
||||||
|
);
|
||||||
|
let keep_registered = cb(BorrowedFd::borrow_raw(fd), events);
|
||||||
|
if !keep_registered {
|
||||||
|
ManuallyDrop::into_inner(cb);
|
||||||
|
}
|
||||||
|
keep_registered as i32
|
||||||
|
})
|
||||||
|
}
|
||||||
|
let data = Box::into_raw(Box::new(callback)) as *mut _;
|
||||||
|
match unsafe {
|
||||||
|
ffi::ALooper_addFd(
|
||||||
|
self.ptr.as_ptr(),
|
||||||
|
fd.as_raw_fd(),
|
||||||
|
ffi::ALOOPER_POLL_CALLBACK,
|
||||||
|
events.bits() as i32,
|
||||||
|
Some(cb_handler::<F>),
|
||||||
|
data,
|
||||||
|
)
|
||||||
|
} {
|
||||||
|
1 => Ok(()),
|
||||||
|
-1 => Err(LooperError),
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes a previously added file descriptor from the looper.
|
||||||
|
///
|
||||||
|
/// Returns [`true`] if the file descriptor was removed, [`false`] if it was not previously
|
||||||
|
/// registered.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// When this method returns, it is safe to close the file descriptor since the looper will no
|
||||||
|
/// longer have a reference to it. However, it is possible for the callback to already be
|
||||||
|
/// running or for it to run one last time if the file descriptor was already signalled.
|
||||||
|
/// Calling code is responsible for ensuring that this case is safely handled. For example, if
|
||||||
|
/// the callback takes care of removing itself during its own execution either by returning `0`
|
||||||
|
/// or by calling this method, then it can be guaranteed to not be invoked again at any later
|
||||||
|
/// time unless registered anew.
|
||||||
|
///
|
||||||
|
/// Note that unregistering a file descriptor with callback will leak a [`Box`] created in
|
||||||
|
/// [`add_fd_with_callback()`][Self::add_fd_with_callback()]. Consider returning [`false`]
|
||||||
|
/// from the callback instead to drop it.
|
||||||
|
pub fn remove_fd(&self, fd: BorrowedFd<'_>) -> Result<bool, LooperError> {
|
||||||
|
match unsafe { ffi::ALooper_removeFd(self.ptr.as_ptr(), fd.as_raw_fd()) } {
|
||||||
|
1 => Ok(true),
|
||||||
|
0 => Ok(false),
|
||||||
|
-1 => Err(LooperError),
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,459 @@
|
|||||||
|
//! Bindings for [`AImageReader`] and [`AImage`]
|
||||||
|
//!
|
||||||
|
//! [`AImageReader`]: https://developer.android.com/ndk/reference/group/media#aimagereader
|
||||||
|
//! [`AImage`]: https://developer.android.com/ndk/reference/group/media#aimage
|
||||||
|
#![cfg(feature = "api-level-24")]
|
||||||
|
|
||||||
|
use crate::media_error::{construct, construct_never_null, MediaError, Result};
|
||||||
|
use crate::native_window::NativeWindow;
|
||||||
|
use crate::utils::abort_on_panic;
|
||||||
|
use num_enum::{FromPrimitive, IntoPrimitive};
|
||||||
|
use std::{
|
||||||
|
ffi::c_void,
|
||||||
|
fmt::{self, Debug, Formatter},
|
||||||
|
mem::MaybeUninit,
|
||||||
|
ptr::NonNull,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(feature = "api-level-26")]
|
||||||
|
use std::os::fd::{FromRawFd, IntoRawFd, OwnedFd};
|
||||||
|
|
||||||
|
#[cfg(feature = "api-level-26")]
|
||||||
|
use crate::hardware_buffer::{HardwareBuffer, HardwareBufferUsage};
|
||||||
|
|
||||||
|
#[repr(i32)]
|
||||||
|
#[derive(Copy, Clone, Debug, PartialEq, Eq, FromPrimitive, IntoPrimitive)]
|
||||||
|
#[allow(non_camel_case_types)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum ImageFormat {
|
||||||
|
RGBA_8888 = ffi::AIMAGE_FORMATS::AIMAGE_FORMAT_RGBA_8888.0 as i32,
|
||||||
|
RGBX_8888 = ffi::AIMAGE_FORMATS::AIMAGE_FORMAT_RGBX_8888.0 as i32,
|
||||||
|
RGB_888 = ffi::AIMAGE_FORMATS::AIMAGE_FORMAT_RGB_888.0 as i32,
|
||||||
|
RGB_565 = ffi::AIMAGE_FORMATS::AIMAGE_FORMAT_RGB_565.0 as i32,
|
||||||
|
RGBA_FP16 = ffi::AIMAGE_FORMATS::AIMAGE_FORMAT_RGBA_FP16.0 as i32,
|
||||||
|
YUV_420_888 = ffi::AIMAGE_FORMATS::AIMAGE_FORMAT_YUV_420_888.0 as i32,
|
||||||
|
JPEG = ffi::AIMAGE_FORMATS::AIMAGE_FORMAT_JPEG.0 as i32,
|
||||||
|
RAW16 = ffi::AIMAGE_FORMATS::AIMAGE_FORMAT_RAW16.0 as i32,
|
||||||
|
RAW_PRIVATE = ffi::AIMAGE_FORMATS::AIMAGE_FORMAT_RAW_PRIVATE.0 as i32,
|
||||||
|
RAW10 = ffi::AIMAGE_FORMATS::AIMAGE_FORMAT_RAW10.0 as i32,
|
||||||
|
RAW12 = ffi::AIMAGE_FORMATS::AIMAGE_FORMAT_RAW12.0 as i32,
|
||||||
|
DEPTH16 = ffi::AIMAGE_FORMATS::AIMAGE_FORMAT_DEPTH16.0 as i32,
|
||||||
|
DEPTH_POINT_CLOUD = ffi::AIMAGE_FORMATS::AIMAGE_FORMAT_DEPTH_POINT_CLOUD.0 as i32,
|
||||||
|
PRIVATE = ffi::AIMAGE_FORMATS::AIMAGE_FORMAT_PRIVATE.0 as i32,
|
||||||
|
Y8 = ffi::AIMAGE_FORMATS::AIMAGE_FORMAT_Y8.0 as i32,
|
||||||
|
HEIC = ffi::AIMAGE_FORMATS::AIMAGE_FORMAT_HEIC.0 as i32,
|
||||||
|
DEPTH_JPEG = ffi::AIMAGE_FORMATS::AIMAGE_FORMAT_DEPTH_JPEG.0 as i32,
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
#[num_enum(catch_all)]
|
||||||
|
__Unknown(i32),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type ImageListener = Box<dyn FnMut(&ImageReader) + Send>;
|
||||||
|
|
||||||
|
#[cfg(feature = "api-level-26")]
|
||||||
|
pub type BufferRemovedListener = Box<dyn FnMut(&ImageReader, &HardwareBuffer) + Send>;
|
||||||
|
|
||||||
|
/// Result returned by:
|
||||||
|
/// - [`ImageReader::acquire_next_image()`]`
|
||||||
|
/// - [`ImageReader::acquire_next_image_async()`]`
|
||||||
|
/// - [`ImageReader::acquire_latest_image()`]`
|
||||||
|
/// - [`ImageReader::acquire_latest_image_async()`]`
|
||||||
|
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub enum AcquireResult<T> {
|
||||||
|
/// Returned if there is no buffers currently available in the reader queue.
|
||||||
|
#[doc(alias = "AMEDIA_IMGREADER_NO_BUFFER_AVAILABLE")]
|
||||||
|
NoBufferAvailable,
|
||||||
|
/// Returned if the number of concurrently acquired images has reached the limit.
|
||||||
|
#[doc(alias = "AMEDIA_IMGREADER_MAX_IMAGES_ACQUIRED")]
|
||||||
|
MaxImagesAcquired,
|
||||||
|
|
||||||
|
/// Returned if an [`Image`] (optionally with fence) was successfully acquired.
|
||||||
|
Image(T),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> AcquireResult<T> {
|
||||||
|
fn map<U>(self, f: impl FnOnce(T) -> U) -> AcquireResult<U> {
|
||||||
|
match self {
|
||||||
|
AcquireResult::Image(img) => AcquireResult::Image(f(img)),
|
||||||
|
AcquireResult::NoBufferAvailable => AcquireResult::NoBufferAvailable,
|
||||||
|
AcquireResult::MaxImagesAcquired => AcquireResult::MaxImagesAcquired,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AcquireResult<Image> {
|
||||||
|
/// Inlined version of [`construct_never_null()`] with IMGREADER-specific result mapping.
|
||||||
|
fn construct_never_null(
|
||||||
|
with_ptr: impl FnOnce(*mut *mut ffi::AImage) -> ffi::media_status_t,
|
||||||
|
) -> Result<Self> {
|
||||||
|
let mut result = MaybeUninit::uninit();
|
||||||
|
let status = with_ptr(result.as_mut_ptr());
|
||||||
|
match status {
|
||||||
|
ffi::media_status_t::AMEDIA_IMGREADER_NO_BUFFER_AVAILABLE => {
|
||||||
|
Ok(Self::NoBufferAvailable)
|
||||||
|
}
|
||||||
|
ffi::media_status_t::AMEDIA_IMGREADER_MAX_IMAGES_ACQUIRED => {
|
||||||
|
Ok(Self::MaxImagesAcquired)
|
||||||
|
}
|
||||||
|
status => MediaError::from_status(status).map(|()| {
|
||||||
|
let result = unsafe { result.assume_init() };
|
||||||
|
Self::Image(Image {
|
||||||
|
inner: if cfg!(debug_assertions) {
|
||||||
|
NonNull::new(result).expect("result should never be null")
|
||||||
|
} else {
|
||||||
|
unsafe { NonNull::new_unchecked(result) }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A native [`AImageReader *`]
|
||||||
|
///
|
||||||
|
/// [`AImageReader *`]: https://developer.android.com/ndk/reference/group/media#aimagereader
|
||||||
|
pub struct ImageReader {
|
||||||
|
inner: NonNull<ffi::AImageReader>,
|
||||||
|
image_cb: Option<Box<ImageListener>>,
|
||||||
|
#[cfg(feature = "api-level-26")]
|
||||||
|
buffer_removed_cb: Option<Box<BufferRemovedListener>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Debug for ImageReader {
|
||||||
|
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
|
||||||
|
f.debug_struct("ImageReader")
|
||||||
|
.field("inner", &self.inner)
|
||||||
|
.field(
|
||||||
|
"image_cb",
|
||||||
|
match &self.image_cb {
|
||||||
|
Some(_) => &"Some(_)",
|
||||||
|
None => &"None",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ImageReader {
|
||||||
|
fn from_ptr(inner: NonNull<ffi::AImageReader>) -> Self {
|
||||||
|
Self {
|
||||||
|
inner,
|
||||||
|
image_cb: None,
|
||||||
|
#[cfg(feature = "api-level-26")]
|
||||||
|
buffer_removed_cb: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_ptr(&self) -> *mut ffi::AImageReader {
|
||||||
|
self.inner.as_ptr()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new(width: i32, height: i32, format: ImageFormat, max_images: i32) -> Result<Self> {
|
||||||
|
let inner = construct_never_null(|res| unsafe {
|
||||||
|
ffi::AImageReader_new(width, height, format.into(), max_images, res)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Self::from_ptr(inner))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "api-level-26")]
|
||||||
|
pub fn new_with_usage(
|
||||||
|
width: i32,
|
||||||
|
height: i32,
|
||||||
|
format: ImageFormat,
|
||||||
|
usage: HardwareBufferUsage,
|
||||||
|
max_images: i32,
|
||||||
|
) -> Result<Self> {
|
||||||
|
let inner = construct_never_null(|res| unsafe {
|
||||||
|
ffi::AImageReader_newWithUsage(
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
format.into(),
|
||||||
|
usage.bits(),
|
||||||
|
max_images,
|
||||||
|
res,
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Self::from_ptr(inner))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[doc(alias = "AImageReader_setImageListener")]
|
||||||
|
pub fn set_image_listener(&mut self, listener: ImageListener) -> Result<()> {
|
||||||
|
let mut boxed = Box::new(listener);
|
||||||
|
let ptr: *mut ImageListener = &mut *boxed;
|
||||||
|
|
||||||
|
unsafe extern "C" fn on_image_available(
|
||||||
|
context: *mut c_void,
|
||||||
|
reader: *mut ffi::AImageReader,
|
||||||
|
) {
|
||||||
|
abort_on_panic(|| {
|
||||||
|
let reader = ImageReader::from_ptr(NonNull::new_unchecked(reader));
|
||||||
|
let listener: *mut ImageListener = context.cast();
|
||||||
|
(*listener)(&reader);
|
||||||
|
std::mem::forget(reader);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut listener = ffi::AImageReader_ImageListener {
|
||||||
|
context: ptr as _,
|
||||||
|
onImageAvailable: Some(on_image_available),
|
||||||
|
};
|
||||||
|
let status = unsafe { ffi::AImageReader_setImageListener(self.as_ptr(), &mut listener) };
|
||||||
|
|
||||||
|
// keep listener alive until Drop or new listener is assigned
|
||||||
|
self.image_cb = Some(boxed);
|
||||||
|
|
||||||
|
MediaError::from_status(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "api-level-26")]
|
||||||
|
#[doc(alias = "AImageReader_setBufferRemovedListener")]
|
||||||
|
pub fn set_buffer_removed_listener(&mut self, listener: BufferRemovedListener) -> Result<()> {
|
||||||
|
let mut boxed = Box::new(listener);
|
||||||
|
let ptr: *mut BufferRemovedListener = &mut *boxed;
|
||||||
|
|
||||||
|
unsafe extern "C" fn on_buffer_removed(
|
||||||
|
context: *mut c_void,
|
||||||
|
reader: *mut ffi::AImageReader,
|
||||||
|
buffer: *mut ffi::AHardwareBuffer,
|
||||||
|
) {
|
||||||
|
abort_on_panic(|| {
|
||||||
|
let reader = ImageReader::from_ptr(NonNull::new_unchecked(reader));
|
||||||
|
let buffer = HardwareBuffer::from_ptr(NonNull::new_unchecked(buffer));
|
||||||
|
let listener: *mut BufferRemovedListener = context.cast();
|
||||||
|
(*listener)(&reader, &buffer);
|
||||||
|
std::mem::forget(reader);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut listener = ffi::AImageReader_BufferRemovedListener {
|
||||||
|
context: ptr as _,
|
||||||
|
onBufferRemoved: Some(on_buffer_removed),
|
||||||
|
};
|
||||||
|
let status =
|
||||||
|
unsafe { ffi::AImageReader_setBufferRemovedListener(self.as_ptr(), &mut listener) };
|
||||||
|
|
||||||
|
// keep listener alive until Drop or new listener is assigned
|
||||||
|
self.buffer_removed_cb = Some(boxed);
|
||||||
|
|
||||||
|
MediaError::from_status(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a [`NativeWindow`] that can be used to produce [`Image`]s for this [`ImageReader`].
|
||||||
|
///
|
||||||
|
/// <https://developer.android.com/ndk/reference/group/media#aimagereader_getwindow>
|
||||||
|
#[doc(alias = "AImageReader_getWindow")]
|
||||||
|
pub fn window(&self) -> Result<NativeWindow> {
|
||||||
|
unsafe {
|
||||||
|
let ptr = construct_never_null(|res| ffi::AImageReader_getWindow(self.as_ptr(), res))?;
|
||||||
|
Ok(NativeWindow::clone_from_ptr(ptr))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[doc(alias = "AImageReader_getWidth")]
|
||||||
|
pub fn width(&self) -> Result<i32> {
|
||||||
|
construct(|res| unsafe { ffi::AImageReader_getWidth(self.as_ptr(), res) })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[doc(alias = "AImageReader_getHeight")]
|
||||||
|
pub fn height(&self) -> Result<i32> {
|
||||||
|
construct(|res| unsafe { ffi::AImageReader_getHeight(self.as_ptr(), res) })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[doc(alias = "AImageReader_getFormat")]
|
||||||
|
pub fn format(&self) -> Result<ImageFormat> {
|
||||||
|
let format = construct(|res| unsafe { ffi::AImageReader_getFormat(self.as_ptr(), res) })?;
|
||||||
|
Ok(format.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[doc(alias = "AImageReader_getMaxImages")]
|
||||||
|
pub fn max_images(&self) -> Result<i32> {
|
||||||
|
construct(|res| unsafe { ffi::AImageReader_getMaxImages(self.as_ptr(), res) })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[doc(alias = "AImageReader_acquireNextImage")]
|
||||||
|
pub fn acquire_next_image(&self) -> Result<AcquireResult<Image>> {
|
||||||
|
AcquireResult::construct_never_null(|res| unsafe {
|
||||||
|
ffi::AImageReader_acquireNextImage(self.as_ptr(), res)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Acquire the next [`Image`] from the image reader's queue asynchronously.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// If the returned file descriptor is not [`None`], it must be awaited before attempting to
|
||||||
|
/// access the [`Image`] returned.
|
||||||
|
///
|
||||||
|
/// <https://developer.android.com/ndk/reference/group/media#aimagereader_acquirenextimageasync>
|
||||||
|
#[cfg(feature = "api-level-26")]
|
||||||
|
#[doc(alias = "AImageReader_acquireNextImageAsync")]
|
||||||
|
pub unsafe fn acquire_next_image_async(
|
||||||
|
&self,
|
||||||
|
) -> Result<AcquireResult<(Image, Option<OwnedFd>)>> {
|
||||||
|
let mut fence = MaybeUninit::uninit();
|
||||||
|
AcquireResult::construct_never_null(|res| {
|
||||||
|
ffi::AImageReader_acquireNextImageAsync(self.as_ptr(), res, fence.as_mut_ptr())
|
||||||
|
})
|
||||||
|
.map(|result| {
|
||||||
|
result.map(|image| match fence.assume_init() {
|
||||||
|
-1 => (image, None),
|
||||||
|
fence => (image, Some(unsafe { OwnedFd::from_raw_fd(fence) })),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[doc(alias = "AImageReader_acquireLatestImage")]
|
||||||
|
pub fn acquire_latest_image(&self) -> Result<AcquireResult<Image>> {
|
||||||
|
AcquireResult::construct_never_null(|res| unsafe {
|
||||||
|
ffi::AImageReader_acquireLatestImage(self.as_ptr(), res)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Acquire the latest [`Image`] from the image reader's queue asynchronously, dropping older images.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// If the returned file descriptor is not [`None`], it must be awaited before attempting to
|
||||||
|
/// access the [`Image`] returned.
|
||||||
|
///
|
||||||
|
/// <https://developer.android.com/ndk/reference/group/media#aimagereader_acquirelatestimageasync>
|
||||||
|
#[cfg(feature = "api-level-26")]
|
||||||
|
#[doc(alias = "AImageReader_acquireLatestImageAsync")]
|
||||||
|
pub unsafe fn acquire_latest_image_async(
|
||||||
|
&self,
|
||||||
|
) -> Result<AcquireResult<(Image, Option<OwnedFd>)>> {
|
||||||
|
let mut fence = MaybeUninit::uninit();
|
||||||
|
AcquireResult::construct_never_null(|res| {
|
||||||
|
ffi::AImageReader_acquireLatestImageAsync(self.as_ptr(), res, fence.as_mut_ptr())
|
||||||
|
})
|
||||||
|
.map(|result| {
|
||||||
|
result.map(|image| match fence.assume_init() {
|
||||||
|
-1 => (image, None),
|
||||||
|
fence => (image, Some(unsafe { OwnedFd::from_raw_fd(fence) })),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for ImageReader {
|
||||||
|
#[doc(alias = "AImageReader_delete")]
|
||||||
|
fn drop(&mut self) {
|
||||||
|
unsafe { ffi::AImageReader_delete(self.as_ptr()) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A native [`AImage *`]
|
||||||
|
///
|
||||||
|
/// [`AImage *`]: https://developer.android.com/ndk/reference/group/media#aimage
|
||||||
|
#[derive(Debug)]
|
||||||
|
#[doc(alias = "AImage")]
|
||||||
|
pub struct Image {
|
||||||
|
inner: NonNull<ffi::AImage>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[doc(alias = "AImageCropRect")]
|
||||||
|
pub type CropRect = ffi::AImageCropRect;
|
||||||
|
|
||||||
|
impl Image {
|
||||||
|
fn as_ptr(&self) -> *mut ffi::AImage {
|
||||||
|
self.inner.as_ptr()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[doc(alias = "AImage_getPlaneData")]
|
||||||
|
pub fn plane_data(&self, plane_idx: i32) -> Result<&[u8]> {
|
||||||
|
let mut result_ptr = MaybeUninit::uninit();
|
||||||
|
let mut result_len = MaybeUninit::uninit();
|
||||||
|
let status = unsafe {
|
||||||
|
ffi::AImage_getPlaneData(
|
||||||
|
self.as_ptr(),
|
||||||
|
plane_idx,
|
||||||
|
result_ptr.as_mut_ptr(),
|
||||||
|
result_len.as_mut_ptr(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
MediaError::from_status(status).map(|()| unsafe {
|
||||||
|
std::slice::from_raw_parts(result_ptr.assume_init(), result_len.assume_init() as _)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[doc(alias = "AImage_getPlanePixelStride")]
|
||||||
|
pub fn plane_pixel_stride(&self, plane_idx: i32) -> Result<i32> {
|
||||||
|
construct(|res| unsafe { ffi::AImage_getPlanePixelStride(self.as_ptr(), plane_idx, res) })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[doc(alias = "AImage_getPlaneRowStride")]
|
||||||
|
pub fn plane_row_stride(&self, plane_idx: i32) -> Result<i32> {
|
||||||
|
construct(|res| unsafe { ffi::AImage_getPlaneRowStride(self.as_ptr(), plane_idx, res) })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[doc(alias = "AImage_getCropRect")]
|
||||||
|
pub fn crop_rect(&self) -> Result<CropRect> {
|
||||||
|
construct(|res| unsafe { ffi::AImage_getCropRect(self.as_ptr(), res) })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[doc(alias = "AImage_getWidth")]
|
||||||
|
pub fn width(&self) -> Result<i32> {
|
||||||
|
construct(|res| unsafe { ffi::AImage_getWidth(self.as_ptr(), res) })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[doc(alias = "AImage_getHeight")]
|
||||||
|
pub fn height(&self) -> Result<i32> {
|
||||||
|
construct(|res| unsafe { ffi::AImage_getHeight(self.as_ptr(), res) })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[doc(alias = "AImage_getFormat")]
|
||||||
|
pub fn format(&self) -> Result<ImageFormat> {
|
||||||
|
let format = construct(|res| unsafe { ffi::AImage_getFormat(self.as_ptr(), res) })?;
|
||||||
|
Ok(format.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[doc(alias = "AImage_getTimestamp")]
|
||||||
|
pub fn timestamp(&self) -> Result<i64> {
|
||||||
|
construct(|res| unsafe { ffi::AImage_getTimestamp(self.as_ptr(), res) })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[doc(alias = "AImage_getNumberOfPlanes")]
|
||||||
|
pub fn number_of_planes(&self) -> Result<i32> {
|
||||||
|
construct(|res| unsafe { ffi::AImage_getNumberOfPlanes(self.as_ptr(), res) })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the hardware buffer handle of the input image intended for GPU and/or hardware access.
|
||||||
|
///
|
||||||
|
/// Note that no reference on the returned [`HardwareBuffer`] handle is acquired automatically.
|
||||||
|
/// Once the [`Image`] or the parent [`ImageReader`] is deleted, the [`HardwareBuffer`] handle
|
||||||
|
/// from previous [`Image::hardware_buffer()`] becomes invalid.
|
||||||
|
///
|
||||||
|
/// If the caller ever needs to hold on a reference to the [`HardwareBuffer`] handle after the
|
||||||
|
/// [`Image`] or the parent [`ImageReader`] is deleted, it must call
|
||||||
|
/// [`HardwareBuffer::acquire()`] to acquire an extra reference, and [`drop()`] it when
|
||||||
|
/// finished using it in order to properly deallocate the underlying memory managed by
|
||||||
|
/// [`HardwareBuffer`]. If the caller has acquired an extra reference on a [`HardwareBuffer`]
|
||||||
|
/// returned from this function, it must also register a listener using
|
||||||
|
/// [`ImageReader::set_buffer_removed_listener()`] to be notified when the buffer is no longer
|
||||||
|
/// used by [`ImageReader`].
|
||||||
|
#[cfg(feature = "api-level-26")]
|
||||||
|
#[doc(alias = "AImage_getHardwareBuffer")]
|
||||||
|
pub fn hardware_buffer(&self) -> Result<HardwareBuffer> {
|
||||||
|
unsafe {
|
||||||
|
let ptr =
|
||||||
|
construct_never_null(|res| ffi::AImage_getHardwareBuffer(self.as_ptr(), res))?;
|
||||||
|
Ok(HardwareBuffer::from_ptr(ptr))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "api-level-26")]
|
||||||
|
#[doc(alias = "AImage_deleteAsync")]
|
||||||
|
pub fn delete_async(self, release_fence_fd: OwnedFd) {
|
||||||
|
unsafe { ffi::AImage_deleteAsync(self.as_ptr(), release_fence_fd.into_raw_fd()) };
|
||||||
|
std::mem::forget(self);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Image {
|
||||||
|
#[doc(alias = "AImage_delete")]
|
||||||
|
fn drop(&mut self) {
|
||||||
|
unsafe { ffi::AImage_delete(self.as_ptr()) };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,634 @@
|
|||||||
|
//! Bindings for [`AMediaCodec`]
|
||||||
|
//!
|
||||||
|
//! [`AMediaCodec`]: https://developer.android.com/ndk/reference/group/media#amediacodec
|
||||||
|
|
||||||
|
#[deprecated = "MediaFormat should be referenced directly from the media_format module"]
|
||||||
|
pub use super::media_format::MediaFormat;
|
||||||
|
use crate::media_error::{MediaError, Result};
|
||||||
|
use crate::native_window::NativeWindow;
|
||||||
|
use crate::utils::abort_on_panic;
|
||||||
|
use std::{
|
||||||
|
ffi::{c_char, c_void, CStr, CString},
|
||||||
|
fmt,
|
||||||
|
mem::MaybeUninit,
|
||||||
|
pin::Pin,
|
||||||
|
ptr::{self, NonNull},
|
||||||
|
slice,
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
pub enum MediaCodecDirection {
|
||||||
|
Decoder,
|
||||||
|
Encoder,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A native [`AMediaCodec *`]
|
||||||
|
///
|
||||||
|
/// [`AMediaCodec *`]: https://developer.android.com/ndk/reference/group/media#amediacodec
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct MediaCodec {
|
||||||
|
inner: NonNull<ffi::AMediaCodec>,
|
||||||
|
async_notify_callback: Option<Pin<Box<AsyncNotifyCallback>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct AsyncNotifyCallback {
|
||||||
|
/// Called when an input buffer becomes available.
|
||||||
|
///
|
||||||
|
/// The specified index is the index of the available input buffer.
|
||||||
|
pub on_input_available: Option<InputAvailableCallback>,
|
||||||
|
|
||||||
|
/// Called when an output buffer becomes available.
|
||||||
|
///
|
||||||
|
/// The specified index is the index of the available output buffer. The specified
|
||||||
|
/// [`BufferInfo`] contains information regarding the available output buffer.
|
||||||
|
pub on_output_available: Option<OutputAvailableCallback>,
|
||||||
|
|
||||||
|
/// Called when the output format has changed.
|
||||||
|
///
|
||||||
|
/// The specified format contains the new output format.
|
||||||
|
pub on_format_changed: Option<FormatChangedCallback>,
|
||||||
|
|
||||||
|
/// Called when the [`MediaCodec`] encountered an error.
|
||||||
|
///
|
||||||
|
/// The specified [`ActionCode`] indicates the possible actions that client can take, and it can
|
||||||
|
/// be checked by calling [`ActionCode::is_recoverable`] or [`ActionCode::is_transient`]. If
|
||||||
|
/// both [`ActionCode::is_recoverable`] and [`ActionCode::is_transient`] return [`false`], then
|
||||||
|
/// the codec error is fatal and the codec must be deleted. The specified detail string may
|
||||||
|
/// contain more detailed messages about this error.
|
||||||
|
pub on_error: Option<ErrorCallback>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for AsyncNotifyCallback {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.debug_struct("AsyncNotifyCallback")
|
||||||
|
.field(
|
||||||
|
"on_input_available",
|
||||||
|
match &self.on_input_available {
|
||||||
|
Some(_) => &"Some(_)",
|
||||||
|
None => &"None",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.field(
|
||||||
|
"on_output_available",
|
||||||
|
match &self.on_output_available {
|
||||||
|
Some(_) => &"Some(_)",
|
||||||
|
None => &"None",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.field(
|
||||||
|
"on_format_changed",
|
||||||
|
match &self.on_format_changed {
|
||||||
|
Some(_) => &"Some(_)",
|
||||||
|
None => &"None",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.field(
|
||||||
|
"on_error",
|
||||||
|
match &self.on_error {
|
||||||
|
Some(_) => &"Some(_)",
|
||||||
|
None => &"None",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type InputAvailableCallback = Box<dyn FnMut(usize) + Send>;
|
||||||
|
pub type OutputAvailableCallback = Box<dyn FnMut(usize, &BufferInfo) + Send>;
|
||||||
|
pub type FormatChangedCallback = Box<dyn FnMut(&MediaFormat) + Send>;
|
||||||
|
pub type ErrorCallback = Box<dyn FnMut(MediaError, ActionCode, &CStr) + Send>;
|
||||||
|
|
||||||
|
impl MediaCodec {
|
||||||
|
/// [punktfunk vendored patch — the ONLY change to this crate] Public so callers can bind
|
||||||
|
/// `AMediaCodec_*` entry points the wrapper doesn't expose yet (here:
|
||||||
|
/// `AMediaCodec_setOnFrameRenderedCallback` for the HUD's display stage). The pointer is valid
|
||||||
|
/// for `&self`'s lifetime; callers must not delete or re-configure the codec through it.
|
||||||
|
pub fn as_ptr(&self) -> *mut ffi::AMediaCodec {
|
||||||
|
self.inner.as_ptr()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_codec_name(name: &str) -> Option<Self> {
|
||||||
|
let c_string = CString::new(name).unwrap();
|
||||||
|
Some(Self {
|
||||||
|
inner: NonNull::new(unsafe { ffi::AMediaCodec_createCodecByName(c_string.as_ptr()) })?,
|
||||||
|
async_notify_callback: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_decoder_type(mime_type: &str) -> Option<Self> {
|
||||||
|
let c_string = CString::new(mime_type).unwrap();
|
||||||
|
Some(Self {
|
||||||
|
inner: NonNull::new(unsafe {
|
||||||
|
ffi::AMediaCodec_createDecoderByType(c_string.as_ptr())
|
||||||
|
})?,
|
||||||
|
async_notify_callback: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_encoder_type(mime_type: &str) -> Option<Self> {
|
||||||
|
let c_string = CString::new(mime_type).unwrap();
|
||||||
|
Some(Self {
|
||||||
|
inner: NonNull::new(unsafe {
|
||||||
|
ffi::AMediaCodec_createEncoderByType(c_string.as_ptr())
|
||||||
|
})?,
|
||||||
|
async_notify_callback: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set an asynchronous callback for actionable [`MediaCodec`] events.
|
||||||
|
///
|
||||||
|
/// When asynchronous callback is enabled, it is an error for the client to call
|
||||||
|
/// [`MediaCodec::dequeue_input_buffer()`] or [`MediaCodec::dequeue_output_buffer()`].
|
||||||
|
///
|
||||||
|
/// [`MediaCodec::flush()`] behaves differently in asynchronous mode. After calling
|
||||||
|
/// [`MediaCodec::flush()`], the client must call [`MediaCodec::start()`] to "resume" receiving
|
||||||
|
/// input buffers. Even if the client does not receive
|
||||||
|
/// [`AsyncNotifyCallback::on_input_available`] callbacks from video encoders configured with an
|
||||||
|
/// input surface, the client still needs to call [`MediaCodec::start()`] to resume the input
|
||||||
|
/// surface to send buffers to the encoders.
|
||||||
|
///
|
||||||
|
/// When called with [`None`] callback, this method unregisters any previously set callback.
|
||||||
|
///
|
||||||
|
/// Refer to the definition of [`AsyncNotifyCallback`] on how each callback function is called
|
||||||
|
/// and what are specified.
|
||||||
|
///
|
||||||
|
/// Once the callback is unregistered or the codec is reset / released, the previously
|
||||||
|
/// registered callback will not be called.
|
||||||
|
///
|
||||||
|
/// All callbacks are fired on one NDK internal thread.
|
||||||
|
/// [`MediaCodec::set_async_notify_callback()`] should not be called on the callback thread. No
|
||||||
|
/// heavy duty task should be performed on callback thread.
|
||||||
|
#[cfg(feature = "api-level-28")]
|
||||||
|
pub fn set_async_notify_callback(
|
||||||
|
&mut self,
|
||||||
|
callback: Option<AsyncNotifyCallback>,
|
||||||
|
) -> Result<()> {
|
||||||
|
unsafe extern "C" fn ffi_on_input_available(
|
||||||
|
_codec: *mut ffi::AMediaCodec,
|
||||||
|
user_data: *mut c_void,
|
||||||
|
index: i32,
|
||||||
|
) {
|
||||||
|
abort_on_panic(|| {
|
||||||
|
let callback = &mut *(user_data as *mut AsyncNotifyCallback);
|
||||||
|
if let Some(f) = callback.on_input_available.as_mut() {
|
||||||
|
f(index as usize);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe extern "C" fn ffi_on_output_available(
|
||||||
|
_codec: *mut ffi::AMediaCodec,
|
||||||
|
user_data: *mut c_void,
|
||||||
|
index: i32,
|
||||||
|
buffer_info: *mut ffi::AMediaCodecBufferInfo,
|
||||||
|
) {
|
||||||
|
abort_on_panic(|| {
|
||||||
|
let callback = &mut *(user_data as *mut AsyncNotifyCallback);
|
||||||
|
if let Some(f) = callback.on_output_available.as_mut() {
|
||||||
|
let buffer_info = BufferInfo {
|
||||||
|
inner: *buffer_info,
|
||||||
|
};
|
||||||
|
f(index as usize, &buffer_info);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe extern "C" fn ffi_on_format_changed(
|
||||||
|
_codec: *mut ffi::AMediaCodec,
|
||||||
|
user_data: *mut c_void,
|
||||||
|
format: *mut ffi::AMediaFormat,
|
||||||
|
) {
|
||||||
|
abort_on_panic(|| {
|
||||||
|
// Ownership of the format is not documented, but the implementation allocates a new instance and does
|
||||||
|
// not free it, so assume it is ok for us to do so
|
||||||
|
// https://cs.android.com/android/platform/superproject/main/+/refs/heads/main:frameworks/av/media/ndk/NdkMediaCodec.cpp;l=248-254;drc=5e15c3e22f3fa32d64e57302201123ce41589adf
|
||||||
|
let format = MediaFormat::from_ptr(NonNull::new_unchecked(format));
|
||||||
|
|
||||||
|
let callback = &mut *(user_data as *mut AsyncNotifyCallback);
|
||||||
|
if let Some(f) = callback.on_format_changed.as_mut() {
|
||||||
|
f(&format);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe extern "C" fn ffi_on_error(
|
||||||
|
_codec: *mut ffi::AMediaCodec,
|
||||||
|
user_data: *mut c_void,
|
||||||
|
error: ffi::media_status_t,
|
||||||
|
action_code: i32,
|
||||||
|
detail: *const c_char,
|
||||||
|
) {
|
||||||
|
abort_on_panic(|| {
|
||||||
|
let callback = &mut *(user_data as *mut AsyncNotifyCallback);
|
||||||
|
if let Some(f) = callback.on_error.as_mut() {
|
||||||
|
f(
|
||||||
|
MediaError::from_status(error).unwrap_err(),
|
||||||
|
ActionCode(action_code),
|
||||||
|
CStr::from_ptr(detail),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let (callback, ffi_callback, user_data) = if let Some(callback) = callback {
|
||||||
|
// On Android 12 and earlier, due to faulty null checks, if a callback is not set, but at least one other
|
||||||
|
// callback *is* set, then it will segfault in when trying to invoke the unset callback. See for example:
|
||||||
|
// https://cs.android.com/android/platform/superproject/+/android-12.0.0_r34:frameworks/av/media/ndk/NdkMediaCodec.cpp;l=161-162;drc=ef058464777739e2d9ffad5f00d0e57b186d9a13
|
||||||
|
// To work around this we just enable all callbacks and do nothing if the corresponding callback is not set
|
||||||
|
// in AsyncNotifyCallback
|
||||||
|
let ffi_callback = ffi::AMediaCodecOnAsyncNotifyCallback {
|
||||||
|
onAsyncInputAvailable: Some(ffi_on_input_available),
|
||||||
|
onAsyncOutputAvailable: Some(ffi_on_output_available),
|
||||||
|
onAsyncFormatChanged: Some(ffi_on_format_changed),
|
||||||
|
onAsyncError: Some(ffi_on_error),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut boxed = Box::pin(callback);
|
||||||
|
let ptr: *mut AsyncNotifyCallback = &mut *boxed;
|
||||||
|
|
||||||
|
(Some(boxed), ffi_callback, ptr as *mut c_void)
|
||||||
|
} else {
|
||||||
|
let ffi_callback = ffi::AMediaCodecOnAsyncNotifyCallback {
|
||||||
|
onAsyncInputAvailable: None,
|
||||||
|
onAsyncOutputAvailable: None,
|
||||||
|
onAsyncFormatChanged: None,
|
||||||
|
onAsyncError: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
(None, ffi_callback, ptr::null_mut())
|
||||||
|
};
|
||||||
|
|
||||||
|
let status = unsafe {
|
||||||
|
ffi::AMediaCodec_setAsyncNotifyCallback(self.as_ptr(), ffi_callback, user_data)
|
||||||
|
};
|
||||||
|
let result = MediaError::from_status(status);
|
||||||
|
|
||||||
|
// This behavior is not documented, but the implementation always clears the callback on failure, so we must
|
||||||
|
// clear any callback that may have been previously registered
|
||||||
|
// https://cs.android.com/android/platform/superproject/main/+/main:frameworks/av/media/ndk/NdkMediaCodec.cpp;l=581-584;drc=8c4e619c7461ac1a8c20c55364643662e9185e4d
|
||||||
|
if result.is_ok() {
|
||||||
|
self.async_notify_callback = callback;
|
||||||
|
} else {
|
||||||
|
self.async_notify_callback = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn configure(
|
||||||
|
&self,
|
||||||
|
format: &MediaFormat,
|
||||||
|
surface: Option<&NativeWindow>,
|
||||||
|
direction: MediaCodecDirection,
|
||||||
|
) -> Result<()> {
|
||||||
|
let status = unsafe {
|
||||||
|
ffi::AMediaCodec_configure(
|
||||||
|
self.as_ptr(),
|
||||||
|
format.as_ptr(),
|
||||||
|
surface.map_or(ptr::null_mut(), |s| s.ptr().as_ptr()),
|
||||||
|
ptr::null_mut(),
|
||||||
|
if direction == MediaCodecDirection::Encoder {
|
||||||
|
ffi::AMEDIACODEC_CONFIGURE_FLAG_ENCODE as u32
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
},
|
||||||
|
)
|
||||||
|
};
|
||||||
|
MediaError::from_status(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "api-level-26")]
|
||||||
|
pub fn create_input_surface(&self) -> Result<NativeWindow> {
|
||||||
|
use crate::media_error::construct_never_null;
|
||||||
|
unsafe {
|
||||||
|
let ptr = construct_never_null(|res| {
|
||||||
|
ffi::AMediaCodec_createInputSurface(self.as_ptr(), res)
|
||||||
|
})?;
|
||||||
|
Ok(NativeWindow::from_ptr(ptr))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "api-level-26")]
|
||||||
|
pub fn create_persistent_input_surface() -> Result<NativeWindow> {
|
||||||
|
use crate::media_error::construct_never_null;
|
||||||
|
unsafe {
|
||||||
|
let ptr =
|
||||||
|
construct_never_null(|res| ffi::AMediaCodec_createPersistentInputSurface(res))?;
|
||||||
|
Ok(NativeWindow::from_ptr(ptr))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn dequeue_input_buffer(&self, timeout: Duration) -> Result<DequeuedInputBufferResult<'_>> {
|
||||||
|
let result = unsafe {
|
||||||
|
ffi::AMediaCodec_dequeueInputBuffer(
|
||||||
|
self.as_ptr(),
|
||||||
|
timeout
|
||||||
|
.as_micros()
|
||||||
|
.try_into()
|
||||||
|
.expect("Supplied timeout is too large"),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
if result == ffi::AMEDIACODEC_INFO_TRY_AGAIN_LATER as isize {
|
||||||
|
Ok(DequeuedInputBufferResult::TryAgainLater)
|
||||||
|
} else {
|
||||||
|
let index = MediaError::from_status_if_negative(result)? as usize;
|
||||||
|
Ok(DequeuedInputBufferResult::Buffer(InputBuffer {
|
||||||
|
codec: self,
|
||||||
|
index,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn dequeue_output_buffer(
|
||||||
|
&self,
|
||||||
|
timeout: Duration,
|
||||||
|
) -> Result<DequeuedOutputBufferInfoResult<'_>> {
|
||||||
|
let mut info = MaybeUninit::uninit();
|
||||||
|
|
||||||
|
let result = unsafe {
|
||||||
|
ffi::AMediaCodec_dequeueOutputBuffer(
|
||||||
|
self.as_ptr(),
|
||||||
|
info.as_mut_ptr(),
|
||||||
|
timeout
|
||||||
|
.as_micros()
|
||||||
|
.try_into()
|
||||||
|
.expect("Supplied timeout is too large"),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
if result == ffi::AMEDIACODEC_INFO_TRY_AGAIN_LATER as isize {
|
||||||
|
Ok(DequeuedOutputBufferInfoResult::TryAgainLater)
|
||||||
|
} else if result == ffi::AMEDIACODEC_INFO_OUTPUT_FORMAT_CHANGED as isize {
|
||||||
|
Ok(DequeuedOutputBufferInfoResult::OutputFormatChanged)
|
||||||
|
} else if result == ffi::AMEDIACODEC_INFO_OUTPUT_BUFFERS_CHANGED as isize {
|
||||||
|
Ok(DequeuedOutputBufferInfoResult::OutputBuffersChanged)
|
||||||
|
} else {
|
||||||
|
let index = MediaError::from_status_if_negative(result)? as usize;
|
||||||
|
Ok(DequeuedOutputBufferInfoResult::Buffer(OutputBuffer {
|
||||||
|
codec: self,
|
||||||
|
index,
|
||||||
|
info: BufferInfo {
|
||||||
|
inner: unsafe { info.assume_init() },
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn flush(&self) -> Result<()> {
|
||||||
|
let status = unsafe { ffi::AMediaCodec_flush(self.as_ptr()) };
|
||||||
|
MediaError::from_status(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn input_buffer(&self, index: usize) -> Option<&mut [MaybeUninit<u8>]> {
|
||||||
|
unsafe {
|
||||||
|
let mut out_size = 0;
|
||||||
|
let buffer_ptr = ffi::AMediaCodec_getInputBuffer(self.as_ptr(), index, &mut out_size);
|
||||||
|
if buffer_ptr.is_null() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(slice::from_raw_parts_mut(buffer_ptr.cast(), out_size))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn output_buffer(&self, index: usize) -> Option<&[u8]> {
|
||||||
|
unsafe {
|
||||||
|
let mut out_size = 0;
|
||||||
|
let buffer_ptr = ffi::AMediaCodec_getOutputBuffer(self.as_ptr(), index, &mut out_size);
|
||||||
|
if buffer_ptr.is_null() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(slice::from_raw_parts(buffer_ptr, out_size))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "api-level-28")]
|
||||||
|
pub fn input_format(&self) -> MediaFormat {
|
||||||
|
let inner = NonNull::new(unsafe { ffi::AMediaCodec_getInputFormat(self.as_ptr()) })
|
||||||
|
.expect("AMediaCodec_getInputFormat returned NULL");
|
||||||
|
unsafe { MediaFormat::from_ptr(inner) }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn output_format(&self) -> MediaFormat {
|
||||||
|
let inner = NonNull::new(unsafe { ffi::AMediaCodec_getOutputFormat(self.as_ptr()) })
|
||||||
|
.expect("AMediaCodec_getOutputFormat returned NULL");
|
||||||
|
unsafe { MediaFormat::from_ptr(inner) }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "api-level-28")]
|
||||||
|
pub fn name(&self) -> Result<String> {
|
||||||
|
use crate::media_error::construct;
|
||||||
|
unsafe {
|
||||||
|
let name_ptr = construct(|name| ffi::AMediaCodec_getName(self.as_ptr(), name))?;
|
||||||
|
let name = CStr::from_ptr(name_ptr).to_str().unwrap().to_owned();
|
||||||
|
ffi::AMediaCodec_releaseName(self.as_ptr(), name_ptr);
|
||||||
|
|
||||||
|
Ok(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn queue_input_buffer(
|
||||||
|
&self,
|
||||||
|
buffer: InputBuffer<'_>,
|
||||||
|
offset: usize,
|
||||||
|
size: usize,
|
||||||
|
time: u64,
|
||||||
|
flags: u32,
|
||||||
|
) -> Result<()> {
|
||||||
|
debug_assert!(ptr::eq(self, buffer.codec));
|
||||||
|
self.queue_input_buffer_by_index(buffer.index, offset, size, time, flags)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn queue_input_buffer_by_index(
|
||||||
|
&self,
|
||||||
|
buffer_index: usize,
|
||||||
|
offset: usize,
|
||||||
|
size: usize,
|
||||||
|
time: u64,
|
||||||
|
flags: u32,
|
||||||
|
) -> Result<()> {
|
||||||
|
let status = unsafe {
|
||||||
|
ffi::AMediaCodec_queueInputBuffer(
|
||||||
|
self.as_ptr(),
|
||||||
|
buffer_index,
|
||||||
|
offset as ffi::off_t,
|
||||||
|
size,
|
||||||
|
time,
|
||||||
|
flags,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
MediaError::from_status(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn release_output_buffer(&self, buffer: OutputBuffer<'_>, render: bool) -> Result<()> {
|
||||||
|
debug_assert!(ptr::eq(self, buffer.codec));
|
||||||
|
self.release_output_buffer_by_index(buffer.index, render)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn release_output_buffer_by_index(&self, buffer_index: usize, render: bool) -> Result<()> {
|
||||||
|
let status =
|
||||||
|
unsafe { ffi::AMediaCodec_releaseOutputBuffer(self.as_ptr(), buffer_index, render) };
|
||||||
|
MediaError::from_status(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn release_output_buffer_at_time(
|
||||||
|
&self,
|
||||||
|
buffer: OutputBuffer<'_>,
|
||||||
|
timestamp_ns: i64,
|
||||||
|
) -> Result<()> {
|
||||||
|
debug_assert!(ptr::eq(self, buffer.codec));
|
||||||
|
self.release_output_buffer_at_time_by_index(buffer.index, timestamp_ns)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn release_output_buffer_at_time_by_index(
|
||||||
|
&self,
|
||||||
|
buffer_index: usize,
|
||||||
|
timestamp_ns: i64,
|
||||||
|
) -> Result<()> {
|
||||||
|
let status = unsafe {
|
||||||
|
ffi::AMediaCodec_releaseOutputBufferAtTime(self.as_ptr(), buffer_index, timestamp_ns)
|
||||||
|
};
|
||||||
|
MediaError::from_status(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "api-level-26")]
|
||||||
|
pub fn set_input_surface(&self, surface: &NativeWindow) -> Result<()> {
|
||||||
|
let status =
|
||||||
|
unsafe { ffi::AMediaCodec_setInputSurface(self.as_ptr(), surface.ptr().as_ptr()) };
|
||||||
|
MediaError::from_status(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_output_surface(&self, surface: &NativeWindow) -> Result<()> {
|
||||||
|
let status =
|
||||||
|
unsafe { ffi::AMediaCodec_setOutputSurface(self.as_ptr(), surface.ptr().as_ptr()) };
|
||||||
|
MediaError::from_status(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "api-level-26")]
|
||||||
|
pub fn set_parameters(&self, params: MediaFormat) -> Result<()> {
|
||||||
|
let status = unsafe { ffi::AMediaCodec_setParameters(self.as_ptr(), params.as_ptr()) };
|
||||||
|
MediaError::from_status(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "api-level-26")]
|
||||||
|
pub fn set_signal_end_of_input_stream(&self) -> Result<()> {
|
||||||
|
let status = unsafe { ffi::AMediaCodec_signalEndOfInputStream(self.as_ptr()) };
|
||||||
|
MediaError::from_status(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start(&self) -> Result<()> {
|
||||||
|
let status = unsafe { ffi::AMediaCodec_start(self.as_ptr()) };
|
||||||
|
MediaError::from_status(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stop(&self) -> Result<()> {
|
||||||
|
let status = unsafe { ffi::AMediaCodec_stop(self.as_ptr()) };
|
||||||
|
MediaError::from_status(status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for MediaCodec {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let status = unsafe { ffi::AMediaCodec_delete(self.as_ptr()) };
|
||||||
|
MediaError::from_status(status).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct InputBuffer<'a> {
|
||||||
|
codec: &'a MediaCodec,
|
||||||
|
index: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InputBuffer<'_> {
|
||||||
|
pub fn buffer_mut(&mut self) -> &mut [MaybeUninit<u8>] {
|
||||||
|
self.codec.input_buffer(self.index).unwrap_or_else(|| {
|
||||||
|
panic!(
|
||||||
|
"AMediaCodec_getInputBuffer returned NULL for index {}",
|
||||||
|
self.index
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum DequeuedInputBufferResult<'a> {
|
||||||
|
Buffer(InputBuffer<'a>),
|
||||||
|
TryAgainLater,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct OutputBuffer<'a> {
|
||||||
|
codec: &'a MediaCodec,
|
||||||
|
index: usize,
|
||||||
|
info: BufferInfo,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OutputBuffer<'_> {
|
||||||
|
pub fn buffer(&self) -> &[u8] {
|
||||||
|
self.codec.output_buffer(self.index).unwrap_or_else(|| {
|
||||||
|
panic!(
|
||||||
|
"AMediaCodec_getOutputBuffer returned NULL for index {}",
|
||||||
|
self.index
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "api-level-28")]
|
||||||
|
pub fn format(&self) -> MediaFormat {
|
||||||
|
let inner = NonNull::new(unsafe {
|
||||||
|
ffi::AMediaCodec_getBufferFormat(self.codec.as_ptr(), self.index)
|
||||||
|
})
|
||||||
|
.expect("AMediaCodec_getBufferFormat returned NULL");
|
||||||
|
unsafe { MediaFormat::from_ptr(inner) }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn info(&self) -> &BufferInfo {
|
||||||
|
&self.info
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum DequeuedOutputBufferInfoResult<'a> {
|
||||||
|
Buffer(OutputBuffer<'a>),
|
||||||
|
TryAgainLater,
|
||||||
|
OutputFormatChanged,
|
||||||
|
OutputBuffersChanged,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug)]
|
||||||
|
pub struct BufferInfo {
|
||||||
|
inner: ffi::AMediaCodecBufferInfo,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BufferInfo {
|
||||||
|
pub fn offset(&self) -> i32 {
|
||||||
|
self.inner.offset
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn size(&self) -> i32 {
|
||||||
|
self.inner.size
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn presentation_time_us(&self) -> i64 {
|
||||||
|
self.inner.presentationTimeUs
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn flags(&self) -> u32 {
|
||||||
|
self.inner.flags
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug)]
|
||||||
|
pub struct ActionCode(pub i32);
|
||||||
|
|
||||||
|
impl ActionCode {
|
||||||
|
pub fn is_recoverable(self) -> bool {
|
||||||
|
unsafe { ffi::AMediaCodecActionCode_isRecoverable(self.0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_transient(self) -> bool {
|
||||||
|
unsafe { ffi::AMediaCodecActionCode_isTransient(self.0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
//! Bindings for [`AMediaFormat`]
|
||||||
|
//!
|
||||||
|
//! [`AMediaFormat`]: https://developer.android.com/ndk/reference/group/media#amediaformat
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
ffi::{CStr, CString},
|
||||||
|
fmt,
|
||||||
|
ptr::{self, NonNull},
|
||||||
|
slice,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::media_error::{MediaError, Result};
|
||||||
|
|
||||||
|
/// A native [`AMediaFormat *`]
|
||||||
|
///
|
||||||
|
/// [`AMediaFormat *`]: https://developer.android.com/ndk/reference/group/media#amediaformat
|
||||||
|
#[doc(alias = "AMediaFormat")]
|
||||||
|
pub struct MediaFormat {
|
||||||
|
inner: NonNull<ffi::AMediaFormat>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for MediaFormat {
|
||||||
|
/// Human readable representation of the format.
|
||||||
|
#[doc(alias = "AMediaFormat_toString")]
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
let c_str = unsafe { CStr::from_ptr(ffi::AMediaFormat_toString(self.as_ptr())) };
|
||||||
|
f.write_str(c_str.to_str().unwrap())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for MediaFormat {
|
||||||
|
#[doc(alias = "AMediaFormat_toString")]
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "MediaFormat({:?}: {})", self.inner, self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for MediaFormat {
|
||||||
|
#[doc(alias = "AMediaFormat_new")]
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MediaFormat {
|
||||||
|
/// Assumes ownership of `ptr`
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `ptr` must be a valid pointer to an Android [`ffi::AMediaFormat`].
|
||||||
|
pub unsafe fn from_ptr(ptr: NonNull<ffi::AMediaFormat>) -> Self {
|
||||||
|
Self { inner: ptr }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_ptr(&self) -> *mut ffi::AMediaFormat {
|
||||||
|
self.inner.as_ptr()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[doc(alias = "AMediaFormat_new")]
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
inner: NonNull::new(unsafe { ffi::AMediaFormat_new() }).unwrap(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[doc(alias = "AMediaFormat_getInt32")]
|
||||||
|
pub fn i32(&self, key: &str) -> Option<i32> {
|
||||||
|
let name = CString::new(key).unwrap();
|
||||||
|
let mut out = 0;
|
||||||
|
if unsafe { ffi::AMediaFormat_getInt32(self.as_ptr(), name.as_ptr(), &mut out) } {
|
||||||
|
Some(out)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[doc(alias = "AMediaFormat_getInt64")]
|
||||||
|
pub fn i64(&self, key: &str) -> Option<i64> {
|
||||||
|
let name = CString::new(key).unwrap();
|
||||||
|
let mut out = 0;
|
||||||
|
if unsafe { ffi::AMediaFormat_getInt64(self.as_ptr(), name.as_ptr(), &mut out) } {
|
||||||
|
Some(out)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[doc(alias = "AMediaFormat_getFloat")]
|
||||||
|
pub fn f32(&self, key: &str) -> Option<f32> {
|
||||||
|
let name = CString::new(key).unwrap();
|
||||||
|
let mut out = 0.0;
|
||||||
|
if unsafe { ffi::AMediaFormat_getFloat(self.as_ptr(), name.as_ptr(), &mut out) } {
|
||||||
|
Some(out)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[doc(alias = "AMediaFormat_getSize")]
|
||||||
|
pub fn usize(&self, key: &str) -> Option<usize> {
|
||||||
|
let name = CString::new(key).unwrap();
|
||||||
|
let mut out = 0;
|
||||||
|
if unsafe { ffi::AMediaFormat_getSize(self.as_ptr(), name.as_ptr(), &mut out) } {
|
||||||
|
Some(out)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[doc(alias = "AMediaFormat_getBuffer")]
|
||||||
|
pub fn buffer(&self, key: &str) -> Option<&[u8]> {
|
||||||
|
let name = CString::new(key).unwrap();
|
||||||
|
let mut out_buffer = ptr::null_mut();
|
||||||
|
let mut out_size = 0;
|
||||||
|
unsafe {
|
||||||
|
ffi::AMediaFormat_getBuffer(
|
||||||
|
self.as_ptr(),
|
||||||
|
name.as_ptr(),
|
||||||
|
&mut out_buffer,
|
||||||
|
&mut out_size,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.then(|| unsafe { slice::from_raw_parts(out_buffer.cast(), out_size) })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The returned `&str` borrow is only valid until the next call to [`MediaFormat::str()`] for
|
||||||
|
/// the same key.
|
||||||
|
#[doc(alias = "AMediaFormat_getString")]
|
||||||
|
pub fn str(&mut self, key: &str) -> Option<&str> {
|
||||||
|
let name = CString::new(key).unwrap();
|
||||||
|
let mut out = ptr::null();
|
||||||
|
unsafe { ffi::AMediaFormat_getString(self.as_ptr(), name.as_ptr(), &mut out) }
|
||||||
|
.then(|| unsafe { CStr::from_ptr(out) }.to_str().unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[doc(alias = "AMediaFormat_setInt32")]
|
||||||
|
pub fn set_i32(&mut self, key: &str, value: i32) {
|
||||||
|
let name = CString::new(key).unwrap();
|
||||||
|
unsafe { ffi::AMediaFormat_setInt32(self.as_ptr(), name.as_ptr(), value) }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[doc(alias = "AMediaFormat_setInt64")]
|
||||||
|
pub fn set_i64(&mut self, key: &str, value: i64) {
|
||||||
|
let name = CString::new(key).unwrap();
|
||||||
|
unsafe { ffi::AMediaFormat_setInt64(self.as_ptr(), name.as_ptr(), value) }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[doc(alias = "AMediaFormat_setFloat")]
|
||||||
|
pub fn set_f32(&mut self, key: &str, value: f32) {
|
||||||
|
let name = CString::new(key).unwrap();
|
||||||
|
unsafe { ffi::AMediaFormat_setFloat(self.as_ptr(), name.as_ptr(), value) }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[doc(alias = "AMediaFormat_setString")]
|
||||||
|
pub fn set_str(&mut self, key: &str, value: &str) {
|
||||||
|
let name = CString::new(key).unwrap();
|
||||||
|
let c_string = CString::new(value).unwrap();
|
||||||
|
unsafe { ffi::AMediaFormat_setString(self.as_ptr(), name.as_ptr(), c_string.as_ptr()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[doc(alias = "AMediaFormat_setBuffer")]
|
||||||
|
pub fn set_buffer(&mut self, key: &str, value: &[u8]) {
|
||||||
|
let name = CString::new(key).unwrap();
|
||||||
|
unsafe {
|
||||||
|
ffi::AMediaFormat_setBuffer(
|
||||||
|
self.as_ptr(),
|
||||||
|
name.as_ptr(),
|
||||||
|
value.as_ptr().cast(),
|
||||||
|
value.len(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "api-level-28")]
|
||||||
|
#[doc(alias = "AMediaFormat_getDouble")]
|
||||||
|
pub fn f64(&self, key: &str) -> Option<f64> {
|
||||||
|
let name = CString::new(key).unwrap();
|
||||||
|
let mut out = 0.0;
|
||||||
|
if unsafe { ffi::AMediaFormat_getDouble(self.as_ptr(), name.as_ptr(), &mut out) } {
|
||||||
|
Some(out)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns (left, top, right, bottom)
|
||||||
|
#[cfg(feature = "api-level-28")]
|
||||||
|
#[doc(alias = "AMediaFormat_getRect")]
|
||||||
|
pub fn rect(&self, key: &str) -> Option<(i32, i32, i32, i32)> {
|
||||||
|
let name = CString::new(key).unwrap();
|
||||||
|
let mut left = 0;
|
||||||
|
let mut top = 0;
|
||||||
|
let mut right = 0;
|
||||||
|
let mut bottom = 0;
|
||||||
|
if unsafe {
|
||||||
|
ffi::AMediaFormat_getRect(
|
||||||
|
self.as_ptr(),
|
||||||
|
name.as_ptr(),
|
||||||
|
&mut left,
|
||||||
|
&mut top,
|
||||||
|
&mut right,
|
||||||
|
&mut bottom,
|
||||||
|
)
|
||||||
|
} {
|
||||||
|
Some((left, top, right, bottom))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "api-level-28")]
|
||||||
|
#[doc(alias = "AMediaFormat_setDouble")]
|
||||||
|
pub fn set_f64(&mut self, key: &str, value: f64) {
|
||||||
|
let name = CString::new(key).unwrap();
|
||||||
|
unsafe { ffi::AMediaFormat_setDouble(self.as_ptr(), name.as_ptr(), value) }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "api-level-28")]
|
||||||
|
#[doc(alias = "AMediaFormat_setRect")]
|
||||||
|
pub fn set_rect(&mut self, key: &str, left: i32, top: i32, right: i32, bottom: i32) {
|
||||||
|
let name = CString::new(key).unwrap();
|
||||||
|
unsafe { ffi::AMediaFormat_setRect(self.as_ptr(), name.as_ptr(), left, top, right, bottom) }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "api-level-28")]
|
||||||
|
#[doc(alias = "AMediaFormat_setSize")]
|
||||||
|
pub fn set_usize(&mut self, key: &str, value: usize) {
|
||||||
|
let name = CString::new(key).unwrap();
|
||||||
|
unsafe { ffi::AMediaFormat_setSize(self.as_ptr(), name.as_ptr(), value) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy one [`MediaFormat`] to another.
|
||||||
|
#[cfg(feature = "api-level-29")]
|
||||||
|
#[doc(alias = "AMediaFormat_copy")]
|
||||||
|
pub fn copy(&self, to: &mut Self) -> Result<()> {
|
||||||
|
let status = unsafe { ffi::AMediaFormat_copy(to.as_ptr(), self.as_ptr()) };
|
||||||
|
MediaError::from_status(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clones this [`MediaFormat`] into a [`MediaFormat::new()`] object using
|
||||||
|
/// [`MediaFormat::copy()`].
|
||||||
|
#[cfg(feature = "api-level-29")]
|
||||||
|
#[doc(alias = "AMediaFormat_new")]
|
||||||
|
#[doc(alias = "AMediaFormat_copy")]
|
||||||
|
pub fn try_clone(&self) -> Result<Self> {
|
||||||
|
let mut copy = Self::new();
|
||||||
|
self.copy(&mut copy)?;
|
||||||
|
Ok(copy)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove all key/value pairs from this [`MediaFormat`].
|
||||||
|
#[cfg(feature = "api-level-29")]
|
||||||
|
#[doc(alias = "AMediaFormat_clear")]
|
||||||
|
pub fn clear(&mut self) {
|
||||||
|
unsafe { ffi::AMediaFormat_clear(self.as_ptr()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for MediaFormat {
|
||||||
|
#[doc(alias = "AMediaFormat_delete")]
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let status = unsafe { ffi::AMediaFormat_delete(self.as_ptr()) };
|
||||||
|
MediaError::from_status(status).unwrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
//! Bindings for the NDK media classes.
|
||||||
|
//!
|
||||||
|
//! See also [the NDK docs](https://developer.android.com/ndk/reference/group/media)
|
||||||
|
#![cfg(feature = "media")]
|
||||||
|
|
||||||
|
pub mod image_reader;
|
||||||
|
pub mod media_codec;
|
||||||
|
pub mod media_format;
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
//! Bindings for NDK media status codes.
|
||||||
|
//!
|
||||||
|
//! Also used outside of `libmediandk.so` in `libamidi.so` for example.
|
||||||
|
#![cfg(feature = "media")]
|
||||||
|
// The cfg(feature) bounds for some pub(crate) fn uses are non-trivial and will become even more
|
||||||
|
// complex going forward. Allow them to be unused when compiling with certain feature combinations.
|
||||||
|
#![allow(dead_code)]
|
||||||
|
|
||||||
|
use std::{fmt, mem::MaybeUninit, ptr::NonNull};
|
||||||
|
|
||||||
|
use num_enum::{FromPrimitive, IntoPrimitive};
|
||||||
|
|
||||||
|
pub type Result<T, E = MediaError> = std::result::Result<T, E>;
|
||||||
|
|
||||||
|
/// Media Status codes for [`media_status_t`](https://developer.android.com/ndk/reference/group/media#group___media_1ga009a49041fe39f7bdc6d8b5cddbe760c)
|
||||||
|
#[repr(i32)]
|
||||||
|
#[derive(Copy, Clone, Debug, PartialEq, Eq, FromPrimitive, IntoPrimitive)]
|
||||||
|
#[doc(alias = "media_status_t")]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum MediaError {
|
||||||
|
#[doc(alias = "AMEDIACODEC_ERROR_INSUFFICIENT_RESOURCE")]
|
||||||
|
CodecErrorInsufficientResource = ffi::media_status_t::AMEDIACODEC_ERROR_INSUFFICIENT_RESOURCE.0,
|
||||||
|
#[doc(alias = "AMEDIACODEC_ERROR_RECLAIMED")]
|
||||||
|
CodecErrorReclaimed = ffi::media_status_t::AMEDIACODEC_ERROR_RECLAIMED.0,
|
||||||
|
#[doc(alias = "AMEDIA_ERROR_UNKNOWN")]
|
||||||
|
ErrorUnknown = ffi::media_status_t::AMEDIA_ERROR_UNKNOWN.0,
|
||||||
|
#[doc(alias = "AMEDIA_ERROR_MALFORMED")]
|
||||||
|
ErrorMalformed = ffi::media_status_t::AMEDIA_ERROR_MALFORMED.0,
|
||||||
|
#[doc(alias = "AMEDIA_ERROR_UNSUPPORTED")]
|
||||||
|
ErrorUnsupported = ffi::media_status_t::AMEDIA_ERROR_UNSUPPORTED.0,
|
||||||
|
#[doc(alias = "AMEDIA_ERROR_INVALID_OBJECT")]
|
||||||
|
ErrorInvalidObject = ffi::media_status_t::AMEDIA_ERROR_INVALID_OBJECT.0,
|
||||||
|
#[doc(alias = "AMEDIA_ERROR_INVALID_PARAMETER")]
|
||||||
|
ErrorInvalidParameter = ffi::media_status_t::AMEDIA_ERROR_INVALID_PARAMETER.0,
|
||||||
|
#[doc(alias = "AMEDIA_ERROR_INVALID_OPERATION")]
|
||||||
|
ErrorInvalidOperation = ffi::media_status_t::AMEDIA_ERROR_INVALID_OPERATION.0,
|
||||||
|
#[doc(alias = "AMEDIA_ERROR_END_OF_STREAM")]
|
||||||
|
ErrorEndOfStream = ffi::media_status_t::AMEDIA_ERROR_END_OF_STREAM.0,
|
||||||
|
#[doc(alias = "AMEDIA_ERROR_IO")]
|
||||||
|
ErrorIo = ffi::media_status_t::AMEDIA_ERROR_IO.0,
|
||||||
|
#[doc(alias = "AMEDIA_ERROR_WOULD_BLOCK")]
|
||||||
|
ErrorWouldBlock = ffi::media_status_t::AMEDIA_ERROR_WOULD_BLOCK.0,
|
||||||
|
#[doc(alias = "AMEDIA_DRM_ERROR_BASE")]
|
||||||
|
DrmErrorBase = ffi::media_status_t::AMEDIA_DRM_ERROR_BASE.0,
|
||||||
|
#[doc(alias = "AMEDIA_DRM_NOT_PROVISIONED")]
|
||||||
|
DrmNotProvisioned = ffi::media_status_t::AMEDIA_DRM_NOT_PROVISIONED.0,
|
||||||
|
#[doc(alias = "AMEDIA_DRM_RESOURCE_BUSY")]
|
||||||
|
DrmResourceBusy = ffi::media_status_t::AMEDIA_DRM_RESOURCE_BUSY.0,
|
||||||
|
#[doc(alias = "AMEDIA_DRM_DEVICE_REVOKED")]
|
||||||
|
DrmDeviceRevoked = ffi::media_status_t::AMEDIA_DRM_DEVICE_REVOKED.0,
|
||||||
|
#[doc(alias = "AMEDIA_DRM_SHORT_BUFFER")]
|
||||||
|
DrmShortBuffer = ffi::media_status_t::AMEDIA_DRM_SHORT_BUFFER.0,
|
||||||
|
#[doc(alias = "AMEDIA_DRM_SESSION_NOT_OPENED")]
|
||||||
|
DrmSessionNotOpened = ffi::media_status_t::AMEDIA_DRM_SESSION_NOT_OPENED.0,
|
||||||
|
#[doc(alias = "AMEDIA_DRM_TAMPER_DETECTED")]
|
||||||
|
DrmTamperDetected = ffi::media_status_t::AMEDIA_DRM_TAMPER_DETECTED.0,
|
||||||
|
#[doc(alias = "AMEDIA_DRM_VERIFY_FAILED")]
|
||||||
|
DrmVerifyFailed = ffi::media_status_t::AMEDIA_DRM_VERIFY_FAILED.0,
|
||||||
|
#[doc(alias = "AMEDIA_DRM_NEED_KEY")]
|
||||||
|
DrmNeedKey = ffi::media_status_t::AMEDIA_DRM_NEED_KEY.0,
|
||||||
|
#[doc(alias = "AMEDIA_DRM_LICENSE_EXPIRED")]
|
||||||
|
DrmLicenseExpired = ffi::media_status_t::AMEDIA_DRM_LICENSE_EXPIRED.0,
|
||||||
|
#[doc(alias = "AMEDIA_IMGREADER_ERROR_BASE")]
|
||||||
|
ImgreaderErrorBase = ffi::media_status_t::AMEDIA_IMGREADER_ERROR_BASE.0,
|
||||||
|
#[doc(alias = "AMEDIA_IMGREADER_CANNOT_LOCK_IMAGE")]
|
||||||
|
ImgreaderCannotLockImage = ffi::media_status_t::AMEDIA_IMGREADER_CANNOT_LOCK_IMAGE.0,
|
||||||
|
#[doc(alias = "AMEDIA_IMGREADER_CANNOT_UNLOCK_IMAGE")]
|
||||||
|
ImgreaderCannotUnlockImage = ffi::media_status_t::AMEDIA_IMGREADER_CANNOT_UNLOCK_IMAGE.0,
|
||||||
|
#[doc(alias = "AMEDIA_IMGREADER_IMAGE_NOT_LOCKED")]
|
||||||
|
ImgreaderImageNotLocked = ffi::media_status_t::AMEDIA_IMGREADER_IMAGE_NOT_LOCKED.0,
|
||||||
|
|
||||||
|
/// This error code is unknown to the [`ndk`][crate] crate. Please report an issue if you
|
||||||
|
/// believe this code needs to be added to our mapping.
|
||||||
|
// Use the OK discriminant, as no-one will be able to call `as i32` and only has access to the
|
||||||
|
// constants via `From` provided by `IntoPrimitive` which reads the contained value.
|
||||||
|
// An autogenerated `<previous variant> + 1` discriminant is normally fine, except that the
|
||||||
|
// previous variant is negative and `+1` would match the variant before that.
|
||||||
|
#[doc(hidden)]
|
||||||
|
#[num_enum(catch_all)]
|
||||||
|
__Unknown(i32) = ffi::media_status_t::AMEDIA_OK.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for MediaError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "{:?}", self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for MediaError {}
|
||||||
|
|
||||||
|
impl MediaError {
|
||||||
|
/// Returns [`Ok`] on [`ffi::media_status_t::AMEDIA_OK`], [`Err`] otherwise (including positive
|
||||||
|
/// values).
|
||||||
|
///
|
||||||
|
/// Note that some known error codes (currently only for `AMediaCodec`) are positive.
|
||||||
|
pub(crate) fn from_status(status: ffi::media_status_t) -> Result<()> {
|
||||||
|
match status {
|
||||||
|
ffi::media_status_t::AMEDIA_OK => Ok(()),
|
||||||
|
x => Err(Self::from(x.0)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the original value in [`Ok`] if it is not negative, [`Err`] otherwise.
|
||||||
|
///
|
||||||
|
/// Note that some [`ffi::media_status_t`] codes are positive but will never be returned as
|
||||||
|
/// [`Err`] from this function. As of writing these codes are specific to the `AMediaCodec` API
|
||||||
|
/// and should not be handled generically.
|
||||||
|
pub(crate) fn from_status_if_negative<T: Into<isize> + Copy>(value: T) -> Result<T> {
|
||||||
|
let v = value.into();
|
||||||
|
if v >= 0 {
|
||||||
|
Ok(value)
|
||||||
|
} else {
|
||||||
|
Err(Self::from(
|
||||||
|
i32::try_from(v).expect("Error code out of bounds"),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calls the `with_ptr` construction function with a pointer to uninitialized stack memory,
|
||||||
|
/// expecting `with_ptr` to initialize it or otherwise return an error code.
|
||||||
|
pub(crate) fn construct<T>(with_ptr: impl FnOnce(*mut T) -> ffi::media_status_t) -> Result<T> {
|
||||||
|
let mut result = MaybeUninit::uninit();
|
||||||
|
let status = with_ptr(result.as_mut_ptr());
|
||||||
|
MediaError::from_status(status).map(|()| unsafe { result.assume_init() })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calls the `with_ptr` construction function with a pointer to a pointer, and expects `with_ptr`
|
||||||
|
/// to initialize the second pointer to a valid address. That address is returned in the form of a
|
||||||
|
/// [`NonNull`] object.
|
||||||
|
pub(crate) fn construct_never_null<T>(
|
||||||
|
with_ptr: impl FnOnce(*mut *mut T) -> ffi::media_status_t,
|
||||||
|
) -> Result<NonNull<T>> {
|
||||||
|
let result = construct(with_ptr)?;
|
||||||
|
Ok(if cfg!(debug_assertions) {
|
||||||
|
NonNull::new(result).expect("result should never be null")
|
||||||
|
} else {
|
||||||
|
unsafe { NonNull::new_unchecked(result) }
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
//! Bindings for [`ANativeActivity`]
|
||||||
|
//!
|
||||||
|
//! [`ANativeActivity`]: https://developer.android.com/ndk/reference/group/native-activity#anativeactivity
|
||||||
|
|
||||||
|
use super::hardware_buffer_format::HardwareBufferFormat;
|
||||||
|
use std::{
|
||||||
|
ffi::{CStr, OsStr},
|
||||||
|
os::{raw::c_void, unix::prelude::OsStrExt},
|
||||||
|
path::Path,
|
||||||
|
ptr::NonNull,
|
||||||
|
};
|
||||||
|
|
||||||
|
bitflags::bitflags! {
|
||||||
|
/// Window flags, as per the Java API at [`android.view.WindowManager.LayoutParams`].
|
||||||
|
///
|
||||||
|
/// <https://developer.android.com/ndk/reference/group/native-activity#group___native_activity_1ga2f1398dba5e4a5616b83437528bdb28e>
|
||||||
|
///
|
||||||
|
/// [`android.view.WindowManager.LayoutParams`]: https://developer.android.com/reference/android/view/WindowManager.LayoutParams
|
||||||
|
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
pub struct WindowFlags : u32 {
|
||||||
|
const ALLOW_LOCK_WHILE_SCREEN_ON = ffi::AWINDOW_FLAG_ALLOW_LOCK_WHILE_SCREEN_ON;
|
||||||
|
const DIM_BEHIND = ffi::AWINDOW_FLAG_DIM_BEHIND;
|
||||||
|
#[deprecated = "Deprecated. Blurring is no longer supported."]
|
||||||
|
const BLUR_BEHIND = ffi::AWINDOW_FLAG_BLUR_BEHIND;
|
||||||
|
const NOT_FOCUSABLE = ffi::AWINDOW_FLAG_NOT_FOCUSABLE;
|
||||||
|
const NOT_TOUCHABLE = ffi::AWINDOW_FLAG_NOT_TOUCHABLE;
|
||||||
|
const NOT_TOUCH_MODAL = ffi::AWINDOW_FLAG_NOT_TOUCH_MODAL;
|
||||||
|
#[deprecated = "This constant was deprecated in API level 20. This flag has no effect."]
|
||||||
|
const TOUCHABLE_WHEN_WAKING = ffi::AWINDOW_FLAG_TOUCHABLE_WHEN_WAKING;
|
||||||
|
const KEEP_SCREEN_ON = ffi::AWINDOW_FLAG_KEEP_SCREEN_ON;
|
||||||
|
const LAYOUT_IN_SCREEN = ffi::AWINDOW_FLAG_LAYOUT_IN_SCREEN;
|
||||||
|
const LAYOUT_NO_LIMITS = ffi::AWINDOW_FLAG_LAYOUT_NO_LIMITS;
|
||||||
|
const FULLSCREEN = ffi::AWINDOW_FLAG_FULLSCREEN;
|
||||||
|
#[cfg_attr(feature = "api-level-30", deprecated = "This constant was deprecated in API level 30. This value became API \"by accident\", and shouldn't be used by 3rd party applications.")]
|
||||||
|
const FORCE_NOT_FULLSCREEN = ffi::AWINDOW_FLAG_FORCE_NOT_FULLSCREEN;
|
||||||
|
#[deprecated = "This constant was deprecated in API level 17. This flag is no longer used."]
|
||||||
|
const DITHER = ffi::AWINDOW_FLAG_DITHER;
|
||||||
|
const SECURE = ffi::AWINDOW_FLAG_SECURE;
|
||||||
|
const SCALED = ffi::AWINDOW_FLAG_SCALED;
|
||||||
|
const IGNORE_CHEEK_PRESSES = ffi::AWINDOW_FLAG_IGNORE_CHEEK_PRESSES;
|
||||||
|
const LAYOUT_INSET_DECOR = ffi::AWINDOW_FLAG_LAYOUT_INSET_DECOR;
|
||||||
|
const ALT_FOCUSABLE_IM = ffi::AWINDOW_FLAG_ALT_FOCUSABLE_IM;
|
||||||
|
const WATCH_OUTSIDE_TOUCH = ffi::AWINDOW_FLAG_WATCH_OUTSIDE_TOUCH;
|
||||||
|
const SHOW_WHEN_LOCKED = ffi::AWINDOW_FLAG_SHOW_WHEN_LOCKED;
|
||||||
|
const SHOW_WALLPAPER = ffi::AWINDOW_FLAG_SHOW_WALLPAPER;
|
||||||
|
const TURN_SCREEN_ON = ffi::AWINDOW_FLAG_TURN_SCREEN_ON;
|
||||||
|
#[cfg_attr(feature = "api-level-26", deprecated = "This constant was deprecated in API level 26. Use `SHOW_WHEN_LOCKED` instead.")]
|
||||||
|
const DISMISS_KEYGUARD = ffi::AWINDOW_FLAG_DISMISS_KEYGUARD;
|
||||||
|
const ATTACHED_IN_DECOR = 0x40000000;
|
||||||
|
|
||||||
|
// https://docs.rs/bitflags/latest/bitflags/#externally-defined-flags
|
||||||
|
const _ = !0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A native [`ANativeActivity *`]
|
||||||
|
///
|
||||||
|
/// This is either provided in [`ffi::ANativeActivity_onCreate()`], or accessible through
|
||||||
|
/// `ndk_glue::native_activity()`.
|
||||||
|
///
|
||||||
|
/// [`ANativeActivity *`]: https://developer.android.com/ndk/reference/struct/a-native-activity
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct NativeActivity {
|
||||||
|
ptr: NonNull<ffi::ANativeActivity>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// It gets shared between threads in `ndk-glue`
|
||||||
|
unsafe impl Send for NativeActivity {}
|
||||||
|
unsafe impl Sync for NativeActivity {}
|
||||||
|
|
||||||
|
impl NativeActivity {
|
||||||
|
/// Create a [`NativeActivity`] from a pointer
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// By calling this function, you assert that it is a valid pointer to a native
|
||||||
|
/// [`ffi::ANativeActivity`].
|
||||||
|
pub unsafe fn from_ptr(ptr: NonNull<ffi::ANativeActivity>) -> Self {
|
||||||
|
Self { ptr }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pointer to the native `ANativeActivity`
|
||||||
|
pub fn ptr(&self) -> NonNull<ffi::ANativeActivity> {
|
||||||
|
self.ptr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Methods that relate to fields of the struct itself
|
||||||
|
///
|
||||||
|
/// The relevant NDK docs can be found
|
||||||
|
/// [here](https://developer.android.com/ndk/reference/struct/a-native-activity).
|
||||||
|
impl NativeActivity {
|
||||||
|
/// The platform's SDK version code
|
||||||
|
pub fn sdk_version(&self) -> i32 {
|
||||||
|
unsafe { self.ptr.as_ref().sdkVersion }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Path to this application's internal data directory
|
||||||
|
pub fn internal_data_path(&self) -> &Path {
|
||||||
|
OsStr::from_bytes(unsafe { CStr::from_ptr(self.ptr.as_ref().internalDataPath) }.to_bytes())
|
||||||
|
.as_ref()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Path to this application's external (removable, mountable) data directory
|
||||||
|
pub fn external_data_path(&self) -> &Path {
|
||||||
|
OsStr::from_bytes(unsafe { CStr::from_ptr(self.ptr.as_ref().externalDataPath) }.to_bytes())
|
||||||
|
.as_ref()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This app's asset manager, which can be used to access assets from the `.apk` file.
|
||||||
|
pub fn asset_manager(&self) -> crate::asset::AssetManager {
|
||||||
|
unsafe {
|
||||||
|
crate::asset::AssetManager::from_ptr(
|
||||||
|
NonNull::new(self.ptr.as_ref().assetManager).unwrap(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Instance data associated with the activity
|
||||||
|
pub fn instance(&self) -> *mut c_void {
|
||||||
|
unsafe { self.ptr.as_ref().instance }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the instance data associated with the activity
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// This can invalidate assumptions held by `ndk-glue`, as well as cause data
|
||||||
|
/// races with concurrent access to the instance data.
|
||||||
|
pub unsafe fn set_instance(&mut self, data: *mut c_void) {
|
||||||
|
// FIXME Does this create undefined behavior by creating a mutable reference to what could
|
||||||
|
// also be accessed immutably at the same time?
|
||||||
|
//
|
||||||
|
// I think that as long as we warn the users to avoid concurrent access, and we pass along
|
||||||
|
// the `unsafe` burden, it's OK.
|
||||||
|
self.ptr.as_mut().instance = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This process's `JavaVM` object.
|
||||||
|
///
|
||||||
|
/// Usage with [__jni__](https://crates.io/crates/jni) crate:
|
||||||
|
/// ```no_run
|
||||||
|
/// # use ndk::native_activity::NativeActivity;
|
||||||
|
/// # let native_activity: NativeActivity = unimplemented!();
|
||||||
|
/// let vm_ptr = native_activity.vm();
|
||||||
|
/// let vm = unsafe { jni::JavaVM::from_raw(vm_ptr) }.unwrap();
|
||||||
|
/// let env = vm.attach_current_thread();
|
||||||
|
/// // Do JNI with env ...
|
||||||
|
/// ```
|
||||||
|
pub fn vm(&self) -> *mut jni_sys::JavaVM {
|
||||||
|
unsafe { self.ptr.as_ref() }.vm
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The [`android.app.NativeActivity`] instance
|
||||||
|
///
|
||||||
|
/// In the JNI, this is named `clazz`; however, as the docs say, "it should really be named
|
||||||
|
/// 'activity' instead of 'clazz', since it's a reference to the NativeActivity instance".
|
||||||
|
///
|
||||||
|
/// [`android.app.NativeActivity`]: https://developer.android.com/reference/android/app/NativeActivity
|
||||||
|
pub fn activity(&self) -> jni_sys::jobject {
|
||||||
|
unsafe { self.ptr.as_ref() }.clazz
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Path to the directory with the application's OBB files.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// Only available as of Honeycomb (Android 3.0+, API level 11+)
|
||||||
|
pub unsafe fn obb_path(&self) -> &Path {
|
||||||
|
OsStr::from_bytes(CStr::from_ptr(self.ptr.as_ref().obbPath).to_bytes()).as_ref()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Methods that relate to `ANativeActivity_*` functions.
|
||||||
|
///
|
||||||
|
/// The relevant NDK docs can be found
|
||||||
|
/// [here](https://developer.android.com/ndk/reference/group/native-activity).
|
||||||
|
impl NativeActivity {
|
||||||
|
/// Sends a destroy event to the activity and stops it.
|
||||||
|
pub fn finish(&self) {
|
||||||
|
unsafe { ffi::ANativeActivity_finish(self.ptr.as_ptr()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shows the IME (the on-screen keyboard).
|
||||||
|
///
|
||||||
|
/// If `force` is true, the `SHOW_FORCED` flag is used; otherwise, the `SHOW_IMPLICIT` flag is
|
||||||
|
/// used. Depending on the value of this flag, the `hide_soft_input` method with behave
|
||||||
|
/// differently. See [the relevant
|
||||||
|
/// javadoc](https://developer.android.com/reference/android/view/inputmethod/InputMethodManager#constants_2)
|
||||||
|
/// for more information.
|
||||||
|
pub fn show_soft_input(&self, force: bool) {
|
||||||
|
let flag = if force {
|
||||||
|
ffi::ANATIVEACTIVITY_SHOW_SOFT_INPUT_FORCED
|
||||||
|
} else {
|
||||||
|
ffi::ANATIVEACTIVITY_SHOW_SOFT_INPUT_IMPLICIT
|
||||||
|
};
|
||||||
|
unsafe { ffi::ANativeActivity_showSoftInput(self.ptr.as_ptr(), flag) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hides the IME (the on-screen keyboard).
|
||||||
|
///
|
||||||
|
/// If `not_always` is true, the `HIDE_NOT_ALWAYS` flag is used; otherwise, the
|
||||||
|
/// `HIDE_IMPLICIT_ONLY` flag is used. Depending on the value of this flag and the way the IME
|
||||||
|
/// was shown, it may or may not be hidden. See [the relevant
|
||||||
|
/// javadoc](https://developer.android.com/reference/android/view/inputmethod/InputMethodManager#constants_2)
|
||||||
|
/// for more information.
|
||||||
|
pub fn hide_soft_input(&self, not_always: bool) {
|
||||||
|
let flag = if not_always {
|
||||||
|
ffi::ANATIVEACTIVITY_HIDE_SOFT_INPUT_NOT_ALWAYS
|
||||||
|
} else {
|
||||||
|
ffi::ANATIVEACTIVITY_HIDE_SOFT_INPUT_IMPLICIT_ONLY
|
||||||
|
};
|
||||||
|
unsafe { ffi::ANativeActivity_hideSoftInput(self.ptr.as_ptr(), flag) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Change the window format of the given activity.
|
||||||
|
///
|
||||||
|
/// Calls [`getWindow().setFormat()`] of the given activity. Note that this method can be
|
||||||
|
/// called from any thread; it will send a message to the main thread of the process where the
|
||||||
|
/// Java finish call will take place.
|
||||||
|
///
|
||||||
|
/// [`getWindow().setFormat()`]: https://developer.android.com/reference/android/view/Window#setFormat(int)
|
||||||
|
pub fn set_window_format(&self, format: HardwareBufferFormat) {
|
||||||
|
unsafe { ffi::ANativeActivity_setWindowFormat(self.ptr.as_ptr(), format.into()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Change the window flags of the given activity.
|
||||||
|
///
|
||||||
|
/// Calls [`getWindow().setFlags()`] of the given activity.
|
||||||
|
///
|
||||||
|
/// Note that this method can be called from any thread; it will send a message to the main
|
||||||
|
/// thread of the process where the Java finish call will take place.
|
||||||
|
///
|
||||||
|
/// [`getWindow().setFlags()`]: https://developer.android.com/reference/android/view/Window#setFlags(int,%20int)
|
||||||
|
pub fn set_window_flags(&self, add_flags: WindowFlags, remove_flags: WindowFlags) {
|
||||||
|
unsafe {
|
||||||
|
ffi::ANativeActivity_setWindowFlags(
|
||||||
|
self.ptr.as_ptr(),
|
||||||
|
add_flags.bits(),
|
||||||
|
remove_flags.bits(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,459 @@
|
|||||||
|
//! Bindings for [`ANativeWindow`]
|
||||||
|
//!
|
||||||
|
//! [`ANativeWindow`]: https://developer.android.com/ndk/reference/group/a-native-window#anativewindow
|
||||||
|
|
||||||
|
use std::{ffi::c_void, io, mem::MaybeUninit, ptr::NonNull};
|
||||||
|
|
||||||
|
use jni_sys::{jobject, JNIEnv};
|
||||||
|
|
||||||
|
use super::{hardware_buffer_format::HardwareBufferFormat, utils::status_to_io_result};
|
||||||
|
#[cfg(all(feature = "nativewindow", feature = "api-level-28"))]
|
||||||
|
use crate::data_space::DataSpace;
|
||||||
|
|
||||||
|
pub type Rect = ffi::ARect;
|
||||||
|
|
||||||
|
// [`NativeWindow`] represents the producer end of an image queue
|
||||||
|
///
|
||||||
|
/// It is the C counterpart of the [`android.view.Surface`] object in Java, and can be converted
|
||||||
|
/// both ways. Depending on the consumer, images submitted to [`NativeWindow`] can be shown on the
|
||||||
|
/// display or sent to other consumers, such as video encoders.
|
||||||
|
///
|
||||||
|
/// [`android.view.Surface`]: https://developer.android.com/reference/android/view/Surface
|
||||||
|
#[derive(Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||||
|
pub struct NativeWindow {
|
||||||
|
ptr: NonNull<ffi::ANativeWindow>,
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe impl Send for NativeWindow {}
|
||||||
|
unsafe impl Sync for NativeWindow {}
|
||||||
|
|
||||||
|
impl Drop for NativeWindow {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
unsafe { ffi::ANativeWindow_release(self.ptr.as_ptr()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Clone for NativeWindow {
|
||||||
|
fn clone(&self) -> Self {
|
||||||
|
unsafe { ffi::ANativeWindow_acquire(self.ptr.as_ptr()) }
|
||||||
|
Self { ptr: self.ptr }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "rwh_04")]
|
||||||
|
unsafe impl rwh_04::HasRawWindowHandle for NativeWindow {
|
||||||
|
fn raw_window_handle(&self) -> rwh_04::RawWindowHandle {
|
||||||
|
let mut handle = rwh_04::AndroidNdkHandle::empty();
|
||||||
|
handle.a_native_window = self.ptr.as_ptr().cast();
|
||||||
|
rwh_04::RawWindowHandle::AndroidNdk(handle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "rwh_05")]
|
||||||
|
unsafe impl rwh_05::HasRawWindowHandle for NativeWindow {
|
||||||
|
fn raw_window_handle(&self) -> rwh_05::RawWindowHandle {
|
||||||
|
let mut handle = rwh_05::AndroidNdkWindowHandle::empty();
|
||||||
|
handle.a_native_window = self.ptr.as_ptr().cast();
|
||||||
|
rwh_05::RawWindowHandle::AndroidNdk(handle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "rwh_06")]
|
||||||
|
impl rwh_06::HasWindowHandle for NativeWindow {
|
||||||
|
fn window_handle(&self) -> Result<rwh_06::WindowHandle<'_>, rwh_06::HandleError> {
|
||||||
|
let handle = rwh_06::AndroidNdkWindowHandle::new(self.ptr.cast());
|
||||||
|
let handle = rwh_06::RawWindowHandle::AndroidNdk(handle);
|
||||||
|
// SAFETY: All fields of the "raw" `AndroidNdkWindowHandle` struct are filled out. The
|
||||||
|
// returned pointer is also kept valid by `NativeWindow` (until `Drop`), which is lifetime-
|
||||||
|
// borrowed in the returned `WindowHandle<'_>` and cannot be outlived. Its value won't
|
||||||
|
// change throughout the lifetime of this `NativeWindow`.
|
||||||
|
Ok(unsafe { rwh_06::WindowHandle::borrow_raw(handle) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NativeWindow {
|
||||||
|
/// Assumes ownership of `ptr`
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `ptr` must be a valid pointer to an Android [`ffi::ANativeWindow`].
|
||||||
|
pub unsafe fn from_ptr(ptr: NonNull<ffi::ANativeWindow>) -> Self {
|
||||||
|
Self { ptr }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Acquires ownership of `ptr`
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `ptr` must be a valid pointer to an Android [`ffi::ANativeWindow`].
|
||||||
|
pub unsafe fn clone_from_ptr(ptr: NonNull<ffi::ANativeWindow>) -> Self {
|
||||||
|
ffi::ANativeWindow_acquire(ptr.as_ptr());
|
||||||
|
Self::from_ptr(ptr)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ptr(&self) -> NonNull<ffi::ANativeWindow> {
|
||||||
|
self.ptr
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn height(&self) -> i32 {
|
||||||
|
unsafe { ffi::ANativeWindow_getHeight(self.ptr.as_ptr()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn width(&self) -> i32 {
|
||||||
|
unsafe { ffi::ANativeWindow_getWidth(self.ptr.as_ptr()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the current pixel format ([`HardwareBufferFormat`]) of the window surface.
|
||||||
|
pub fn format(&self) -> HardwareBufferFormat {
|
||||||
|
let value = unsafe { ffi::ANativeWindow_getFormat(self.ptr.as_ptr()) };
|
||||||
|
value.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Change the format and size of the window buffers.
|
||||||
|
///
|
||||||
|
/// The width and height control the number of pixels in the buffers, not the dimensions of the
|
||||||
|
/// window on screen. If these are different than the window's physical size, then its buffer
|
||||||
|
/// will be scaled to match that size when compositing it to the screen. The width and height
|
||||||
|
/// must be either both zero or both non-zero.
|
||||||
|
///
|
||||||
|
/// For all of these parameters, if `0` or [`None`] is supplied then the window's base value
|
||||||
|
/// will come back in force.
|
||||||
|
pub fn set_buffers_geometry(
|
||||||
|
&self,
|
||||||
|
width: i32,
|
||||||
|
height: i32,
|
||||||
|
format: Option<HardwareBufferFormat>,
|
||||||
|
) -> io::Result<()> {
|
||||||
|
let format = format.map_or(0i32, |f| f.into());
|
||||||
|
let status = unsafe {
|
||||||
|
ffi::ANativeWindow_setBuffersGeometry(self.ptr.as_ptr(), width, height, format)
|
||||||
|
};
|
||||||
|
status_to_io_result(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set a transform that will be applied to future buffers posted to the window.
|
||||||
|
#[cfg(all(feature = "nativewindow", feature = "api-level-26"))]
|
||||||
|
#[doc(alias = "ANativeWindow_setBuffersTransform")]
|
||||||
|
pub fn set_buffers_transform(&self, transform: NativeWindowTransform) -> io::Result<()> {
|
||||||
|
let status =
|
||||||
|
unsafe { ffi::ANativeWindow_setBuffersTransform(self.ptr.as_ptr(), transform.bits()) };
|
||||||
|
status_to_io_result(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All buffers queued after this call will be associated with the dataSpace parameter
|
||||||
|
/// specified.
|
||||||
|
///
|
||||||
|
/// `data_space` specifies additional information about the buffer. For example, it can be used
|
||||||
|
/// to convey the color space of the image data in the buffer, or it can be used to indicate
|
||||||
|
/// that the buffers contain depth measurement data instead of color images. The default
|
||||||
|
/// dataSpace is `0`, [`DataSpace::Unknown`], unless it has been overridden by the producer.
|
||||||
|
#[cfg(all(feature = "nativewindow", feature = "api-level-28"))]
|
||||||
|
#[doc(alias = "ANativeWindow_setBuffersDataSpace")]
|
||||||
|
pub fn set_buffers_data_space(&self, data_space: DataSpace) -> io::Result<()> {
|
||||||
|
let status =
|
||||||
|
unsafe { ffi::ANativeWindow_setBuffersDataSpace(self.ptr.as_ptr(), data_space.into()) };
|
||||||
|
status_to_io_result(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the dataspace of the buffers in this [`NativeWindow`].
|
||||||
|
#[cfg(all(feature = "nativewindow", feature = "api-level-28"))]
|
||||||
|
#[doc(alias = "ANativeWindow_getBuffersDataSpace")]
|
||||||
|
pub fn buffers_data_space(&self) -> io::Result<DataSpace> {
|
||||||
|
let status = unsafe { ffi::ANativeWindow_getBuffersDataSpace(self.ptr.as_ptr()) };
|
||||||
|
if status >= 0 {
|
||||||
|
Ok(status.into())
|
||||||
|
} else {
|
||||||
|
Err(status_to_io_result(status).unwrap_err())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the intended frame rate for this window.
|
||||||
|
///
|
||||||
|
/// Same as [`set_frame_rate_with_change_strategy(window, frame_rate, compatibility, ChangeFrameRateStrategy::OnlyIfSeamless)`][`NativeWindow::set_frame_rate_with_change_strategy()`].
|
||||||
|
///
|
||||||
|
#[cfg_attr(
|
||||||
|
not(feature = "api-level-31"),
|
||||||
|
doc = "[`NativeWindow::set_frame_rate_with_change_strategy()`]: https://developer.android.com/ndk/reference/group/a-native-window#anativewindow_setframeratewithchangestrategy"
|
||||||
|
)]
|
||||||
|
#[cfg(all(feature = "nativewindow", feature = "api-level-30"))]
|
||||||
|
#[doc(alias = "ANativeWindow_setFrameRate")]
|
||||||
|
pub fn set_frame_rate(
|
||||||
|
&self,
|
||||||
|
frame_rate: f32,
|
||||||
|
compatibility: FrameRateCompatibility,
|
||||||
|
) -> io::Result<()> {
|
||||||
|
let status = unsafe {
|
||||||
|
ffi::ANativeWindow_setFrameRate(self.ptr.as_ptr(), frame_rate, compatibility as i8)
|
||||||
|
};
|
||||||
|
status_to_io_result(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the intended frame rate for this window.
|
||||||
|
///
|
||||||
|
/// On devices that are capable of running the display at different refresh rates, the system
|
||||||
|
/// may choose a display refresh rate to better match this window's frame rate. Usage of this
|
||||||
|
/// API won't introduce frame rate throttling, or affect other aspects of the application's
|
||||||
|
/// frame production pipeline. However, because the system may change the display refresh rate,
|
||||||
|
/// calls to this function may result in changes to Choreographer callback timings, and changes
|
||||||
|
/// to the time interval at which the system releases buffers back to the application.
|
||||||
|
///
|
||||||
|
/// Note that this only has an effect for windows presented on the display. If this
|
||||||
|
/// [`NativeWindow`] is consumed by something other than the system compositor, e.g. a media
|
||||||
|
/// codec, this call has no effect.
|
||||||
|
///
|
||||||
|
/// You can register for changes in the refresh rate using
|
||||||
|
/// [`ffi::AChoreographer_registerRefreshRateCallback()`].
|
||||||
|
///
|
||||||
|
/// # Parameters
|
||||||
|
///
|
||||||
|
/// - `frame_rate`: The intended frame rate of this window, in frames per second. `0` is a
|
||||||
|
/// special value that indicates the app will accept the system's choice for the display
|
||||||
|
/// frame rate, which is the default behavior if this function isn't called. The `frame_rate`
|
||||||
|
/// param does not need to be a valid refresh rate for this device's display - e.g., it's
|
||||||
|
/// fine to pass `30`fps to a device that can only run the display at `60`fps.
|
||||||
|
/// - `compatibility`: The frame rate compatibility of this window. The compatibility value may
|
||||||
|
/// influence the system's choice of display refresh rate. See the [`FrameRateCompatibility`]
|
||||||
|
/// values for more info. This parameter is ignored when `frame_rate` is `0`.
|
||||||
|
/// - `change_frame_rate_strategy`: Whether display refresh rate transitions caused by this
|
||||||
|
/// window should be seamless. A seamless transition is one that doesn't have any visual
|
||||||
|
/// interruptions, such as a black screen for a second or two. See the
|
||||||
|
/// [`ChangeFrameRateStrategy`] values. This parameter is ignored when `frame_rate` is `0`.
|
||||||
|
#[cfg(all(feature = "nativewindow", feature = "api-level-31"))]
|
||||||
|
#[doc(alias = "ANativeWindow_setFrameRateWithChangeStrategy")]
|
||||||
|
pub fn set_frame_rate_with_change_strategy(
|
||||||
|
&self,
|
||||||
|
frame_rate: f32,
|
||||||
|
compatibility: FrameRateCompatibility,
|
||||||
|
change_frame_rate_strategy: ChangeFrameRateStrategy,
|
||||||
|
) -> io::Result<()> {
|
||||||
|
let status = unsafe {
|
||||||
|
ffi::ANativeWindow_setFrameRateWithChangeStrategy(
|
||||||
|
self.ptr.as_ptr(),
|
||||||
|
frame_rate,
|
||||||
|
compatibility as i8,
|
||||||
|
change_frame_rate_strategy as i8,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
status_to_io_result(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Provides a hint to the window that buffers should be preallocated ahead of time.
|
||||||
|
///
|
||||||
|
/// Note that the window implementation is not guaranteed to preallocate any buffers, for
|
||||||
|
/// instance if an implementation disallows allocation of new buffers, or if there is
|
||||||
|
/// insufficient memory in the system to preallocate additional buffers
|
||||||
|
#[cfg(all(feature = "nativewindow", feature = "api-level-30"))]
|
||||||
|
pub fn try_allocate_buffers(&self) {
|
||||||
|
unsafe { ffi::ANativeWindow_tryAllocateBuffers(self.ptr.as_ptr()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the [`NativeWindow`] associated with a JNI [`android.view.Surface`] pointer.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// By calling this function, you assert that `env` is a valid pointer to a [`JNIEnv`] and
|
||||||
|
/// `surface` is a valid pointer to an [`android.view.Surface`].
|
||||||
|
///
|
||||||
|
/// [`android.view.Surface`]: https://developer.android.com/reference/android/view/Surface
|
||||||
|
pub unsafe fn from_surface(env: *mut JNIEnv, surface: jobject) -> Option<Self> {
|
||||||
|
let ptr = ffi::ANativeWindow_fromSurface(env, surface);
|
||||||
|
Some(Self::from_ptr(NonNull::new(ptr)?))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return a JNI [`android.view.Surface`] pointer derived from this [`NativeWindow`].
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// By calling this function, you assert that `env` is a valid pointer to a [`JNIEnv`].
|
||||||
|
///
|
||||||
|
/// [`android.view.Surface`]: https://developer.android.com/reference/android/view/Surface
|
||||||
|
#[cfg(feature = "api-level-26")]
|
||||||
|
pub unsafe fn to_surface(&self, env: *mut JNIEnv) -> jobject {
|
||||||
|
ffi::ANativeWindow_toSurface(env, self.ptr().as_ptr())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lock the window's next drawing surface for writing.
|
||||||
|
///
|
||||||
|
/// Optionally pass the region you intend to draw into `dirty_bounds`. When this function
|
||||||
|
/// returns it is updated (commonly enlarged) with the actual area the caller needs to redraw.
|
||||||
|
pub fn lock(
|
||||||
|
&self,
|
||||||
|
dirty_bounds: Option<&mut Rect>,
|
||||||
|
) -> io::Result<NativeWindowBufferLockGuard<'_>> {
|
||||||
|
let dirty_bounds = match dirty_bounds {
|
||||||
|
Some(dirty_bounds) => dirty_bounds,
|
||||||
|
None => std::ptr::null_mut(),
|
||||||
|
};
|
||||||
|
let mut buffer = MaybeUninit::uninit();
|
||||||
|
let status = unsafe {
|
||||||
|
ffi::ANativeWindow_lock(self.ptr.as_ptr(), buffer.as_mut_ptr(), dirty_bounds)
|
||||||
|
};
|
||||||
|
status_to_io_result(status)?;
|
||||||
|
|
||||||
|
Ok(NativeWindowBufferLockGuard {
|
||||||
|
window: self,
|
||||||
|
buffer: unsafe { buffer.assume_init() },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lock holding the next drawing surface for writing. It is unlocked and posted on [`drop()`].
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct NativeWindowBufferLockGuard<'a> {
|
||||||
|
window: &'a NativeWindow,
|
||||||
|
buffer: ffi::ANativeWindow_Buffer,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> NativeWindowBufferLockGuard<'a> {
|
||||||
|
/// The number of pixels that are shown horizontally.
|
||||||
|
pub fn width(&self) -> usize {
|
||||||
|
usize::try_from(self.buffer.width).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
// The number of pixels that are shown vertically.
|
||||||
|
pub fn height(&self) -> usize {
|
||||||
|
usize::try_from(self.buffer.height).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The number of _pixels_ that a line in the buffer takes in memory.
|
||||||
|
///
|
||||||
|
/// This may be `>= width`.
|
||||||
|
pub fn stride(&self) -> usize {
|
||||||
|
usize::try_from(self.buffer.stride).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The format of the buffer. One of [`HardwareBufferFormat`].
|
||||||
|
pub fn format(&self) -> HardwareBufferFormat {
|
||||||
|
self.buffer.format.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The actual bits.
|
||||||
|
///
|
||||||
|
/// This points to a memory segment of [`stride()`][Self::stride()] *
|
||||||
|
/// [`height()`][Self::height()] * [`HardwareBufferFormat::bytes_per_pixel()`] bytes.
|
||||||
|
///
|
||||||
|
/// Only [`width()`][Self::width()] pixels are visible for each [`stride()`][Self::stride()]
|
||||||
|
/// line of pixels in the buffer.
|
||||||
|
///
|
||||||
|
/// See [`bytes()`][Self::bytes()] for safe access to these bytes.
|
||||||
|
pub fn bits(&mut self) -> *mut c_void {
|
||||||
|
self.buffer.bits
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Safe write access to likely uninitialized pixel buffer data.
|
||||||
|
///
|
||||||
|
/// Returns [`None`] when there is no [`HardwareBufferFormat::bytes_per_pixel()`] size
|
||||||
|
/// available for this [`format()`][Self::format()].
|
||||||
|
///
|
||||||
|
/// The returned slice consists of [`stride()`][Self::stride()] * [`height()`][Self::height()]
|
||||||
|
/// \* [`HardwareBufferFormat::bytes_per_pixel()`] bytes.
|
||||||
|
///
|
||||||
|
/// Only [`width()`][Self::width()] pixels are visible for each [`stride()`][Self::stride()]
|
||||||
|
/// line of pixels in the buffer.
|
||||||
|
pub fn bytes(&mut self) -> Option<&mut [MaybeUninit<u8>]> {
|
||||||
|
let num_pixels = self.stride() * self.height();
|
||||||
|
let num_bytes = num_pixels * self.format().bytes_per_pixel()?;
|
||||||
|
Some(unsafe { std::slice::from_raw_parts_mut(self.bits().cast(), num_bytes) })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a slice of bytes for each line of visible pixels in the buffer, ignoring any
|
||||||
|
/// padding pixels incurred by the stride.
|
||||||
|
///
|
||||||
|
/// See [`bits()`][Self::bits()] and [`bytes()`][Self::bytes()] for contiguous access to the
|
||||||
|
/// underlying buffer.
|
||||||
|
pub fn lines(&mut self) -> Option<impl Iterator<Item = &mut [MaybeUninit<u8>]>> {
|
||||||
|
let bpp = self.format().bytes_per_pixel()?;
|
||||||
|
let scanline_bytes = bpp * self.stride();
|
||||||
|
let width_bytes = bpp * self.width();
|
||||||
|
let bytes = self.bytes()?;
|
||||||
|
|
||||||
|
Some(
|
||||||
|
bytes
|
||||||
|
.chunks_exact_mut(scanline_bytes)
|
||||||
|
.map(move |scanline| &mut scanline[..width_bytes]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Drop for NativeWindowBufferLockGuard<'a> {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let ret = unsafe { ffi::ANativeWindow_unlockAndPost(self.window.ptr.as_ptr()) };
|
||||||
|
assert_eq!(ret, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(feature = "nativewindow", feature = "api-level-26"))]
|
||||||
|
bitflags::bitflags! {
|
||||||
|
/// Transforms that can be applied to buffers as they are displayed to a window.
|
||||||
|
///
|
||||||
|
/// Supported transforms are any combination of horizontal mirror, vertical mirror, and
|
||||||
|
/// clockwise 90 degree rotation, in that order. Rotations of 180 and 270 degrees are made up
|
||||||
|
/// of those basic transforms.
|
||||||
|
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
|
||||||
|
#[doc(alias = "ANativeWindowTransform")]
|
||||||
|
pub struct NativeWindowTransform : i32 {
|
||||||
|
#[doc(alias = "ANATIVEWINDOW_TRANSFORM_IDENTITY")]
|
||||||
|
const IDENTITY = ffi::ANativeWindowTransform::ANATIVEWINDOW_TRANSFORM_IDENTITY.0 as i32;
|
||||||
|
#[doc(alias = "ANATIVEWINDOW_TRANSFORM_MIRROR_HORIZONTAL")]
|
||||||
|
const MIRROR_HORIZONTAL = ffi::ANativeWindowTransform::ANATIVEWINDOW_TRANSFORM_MIRROR_HORIZONTAL.0 as i32;
|
||||||
|
#[doc(alias = "ANATIVEWINDOW_TRANSFORM_MIRROR_VERTICAL")]
|
||||||
|
const MIRROR_VERTICAL = ffi::ANativeWindowTransform::ANATIVEWINDOW_TRANSFORM_MIRROR_VERTICAL.0 as i32;
|
||||||
|
#[doc(alias = "ANATIVEWINDOW_TRANSFORM_ROTATE_90")]
|
||||||
|
const ROTATE_90 = ffi::ANativeWindowTransform::ANATIVEWINDOW_TRANSFORM_ROTATE_90.0 as i32;
|
||||||
|
/// Defined as [`Self::MIRROR_HORIZONTAL`] `|` [`Self::MIRROR_VERTICAL`].
|
||||||
|
#[doc(alias = "ANATIVEWINDOW_TRANSFORM_ROTATE_180")]
|
||||||
|
const ROTATE_180 = ffi::ANativeWindowTransform::ANATIVEWINDOW_TRANSFORM_ROTATE_180.0 as i32;
|
||||||
|
/// Defined as [`Self::ROTATE_180`] `|` [`Self::ROTATE_90`].
|
||||||
|
#[doc(alias = "ANATIVEWINDOW_TRANSFORM_ROTATE_270")]
|
||||||
|
const ROTATE_270 = ffi::ANativeWindowTransform::ANATIVEWINDOW_TRANSFORM_ROTATE_270.0 as i32;
|
||||||
|
|
||||||
|
// https://docs.rs/bitflags/latest/bitflags/#externally-defined-flags
|
||||||
|
const _ = !0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compatibility value for [`NativeWindow::set_frame_rate()`]
|
||||||
|
#[cfg_attr(
|
||||||
|
feature = "api-level-31",
|
||||||
|
doc = " and [`NativeWindow::set_frame_rate_with_change_strategy()`]"
|
||||||
|
)]
|
||||||
|
/// .
|
||||||
|
#[cfg(all(feature = "nativewindow", feature = "api-level-30"))]
|
||||||
|
#[repr(i8)]
|
||||||
|
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
|
||||||
|
#[doc(alias = "ANativeWindow_FrameRateCompatibility")]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum FrameRateCompatibility {
|
||||||
|
/// There are no inherent restrictions on the frame rate of this window.
|
||||||
|
///
|
||||||
|
/// When the system selects a frame rate other than what the app requested, the app will be
|
||||||
|
/// able to run at the system frame rate without requiring pull down. This value should be used
|
||||||
|
/// when displaying game content, UIs, and anything that isn't video.
|
||||||
|
#[doc(alias = "ANATIVEWINDOW_FRAME_RATE_COMPATIBILITY_DEFAULT")]
|
||||||
|
Default =
|
||||||
|
ffi::ANativeWindow_FrameRateCompatibility::ANATIVEWINDOW_FRAME_RATE_COMPATIBILITY_DEFAULT.0 as i8,
|
||||||
|
/// This window is being used to display content with an inherently fixed frame rate, e.g. a
|
||||||
|
/// video that has a specific frame rate.
|
||||||
|
///
|
||||||
|
/// When the system selects a frame rate other than what the app requested, the app will need
|
||||||
|
/// to do pull down or use some other technique to adapt to the system's frame rate. The user
|
||||||
|
/// experience is likely to be worse (e.g. more frame stuttering) than it would be if the
|
||||||
|
/// system had chosen the app's requested frame rate. This value should be used for video
|
||||||
|
/// content.
|
||||||
|
#[doc(alias = "ANATIVEWINDOW_FRAME_RATE_COMPATIBILITY_FIXED_SOURCE")]
|
||||||
|
FixedSource = ffi::ANativeWindow_FrameRateCompatibility::ANATIVEWINDOW_FRAME_RATE_COMPATIBILITY_FIXED_SOURCE.0 as i8,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Change frame rate strategy value for [`NativeWindow::set_frame_rate_with_change_strategy()`].
|
||||||
|
#[cfg(all(feature = "nativewindow", feature = "api-level-31"))]
|
||||||
|
#[repr(i8)]
|
||||||
|
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
|
||||||
|
#[doc(alias = "ANativeWindow_ChangeFrameRateStrategy")]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum ChangeFrameRateStrategy {
|
||||||
|
/// Change the frame rate only if the transition is going to be seamless.
|
||||||
|
#[doc(alias = "ANATIVEWINDOW_CHANGE_FRAME_RATE_ONLY_IF_SEAMLESS")]
|
||||||
|
OnlyIfSeamless =
|
||||||
|
ffi::ANativeWindow_ChangeFrameRateStrategy::ANATIVEWINDOW_CHANGE_FRAME_RATE_ONLY_IF_SEAMLESS
|
||||||
|
.0 as i8,
|
||||||
|
/// Change the frame rate even if the transition is going to be non-seamless, i.e. with visual interruptions for the user.
|
||||||
|
#[doc(alias = "ANATIVEWINDOW_CHANGE_FRAME_RATE_ALWAYS")]
|
||||||
|
Always =
|
||||||
|
ffi::ANativeWindow_ChangeFrameRateStrategy::ANATIVEWINDOW_CHANGE_FRAME_RATE_ALWAYS.0 as i8,
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
//! Bindings for [`ASharedMemory`]
|
||||||
|
//!
|
||||||
|
//! [`ASharedMemory`]: https://developer.android.com/ndk/reference/group/memory
|
||||||
|
#![cfg(feature = "api-level-26")]
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
ffi::CStr,
|
||||||
|
io::{Error, Result},
|
||||||
|
// TODO: Import from std::os::fd::{} since Rust 1.66
|
||||||
|
os::unix::io::{AsFd, AsRawFd, BorrowedFd, FromRawFd, IntoRawFd, OwnedFd, RawFd},
|
||||||
|
ptr,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(feature = "api-level-27")]
|
||||||
|
use jni_sys::{jobject, JNIEnv};
|
||||||
|
|
||||||
|
/// Enables the creation, mapping, and protection control over anonymous shared memory.
|
||||||
|
#[derive(Debug)]
|
||||||
|
#[doc(alias = "ASharedMemory")]
|
||||||
|
pub struct SharedMemory(OwnedFd);
|
||||||
|
|
||||||
|
impl SharedMemory {
|
||||||
|
/// Create a shared memory region.
|
||||||
|
///
|
||||||
|
/// Creates shared memory region and returns a file descriptor. The resulting file descriptor
|
||||||
|
/// can be `mmap`'ed to process memory space with `PROT_READ | PROT_WRITE | PROT_EXEC`. Access
|
||||||
|
/// to this shared memory region can be restricted with [`set_prot()`][Self::set_prot()].
|
||||||
|
///
|
||||||
|
/// Use [`android.os.ParcelFileDescriptor`] to pass the file descriptor to another process.
|
||||||
|
/// File descriptors may also be sent to other processes over a Unix domain socket with
|
||||||
|
/// `sendmsg` and `SCM_RIGHTS`. See `sendmsg(3)` and `cmsg(3)` man pages for more information.
|
||||||
|
///
|
||||||
|
/// If you intend to share this file descriptor with a child process after calling `exec(3)`,
|
||||||
|
/// note that you will need to use `fcntl(2)` with `F_SETFD` to clear the `FD_CLOEXEC` flag for
|
||||||
|
/// this to work on all versions of Android.
|
||||||
|
///
|
||||||
|
/// [`android.os.ParcelFileDescriptor`]: https://developer.android.com/reference/android/os/ParcelFileDescriptor
|
||||||
|
#[doc(alias = "ASharedMemory_create")]
|
||||||
|
pub fn create(name: Option<&CStr>, size: usize) -> Result<Self> {
|
||||||
|
let fd =
|
||||||
|
unsafe { ffi::ASharedMemory_create(name.map_or(ptr::null(), |p| p.as_ptr()), size) };
|
||||||
|
if fd < 0 {
|
||||||
|
Err(Error::last_os_error())
|
||||||
|
} else {
|
||||||
|
Ok(unsafe { Self::from_raw_fd(fd) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a `dup`'d FD from the given Java [`android.os.SharedMemory`] object.
|
||||||
|
///
|
||||||
|
/// The returned file descriptor has all the same properties & capabilities as the FD returned
|
||||||
|
/// from [`create()`][Self::create()], however the protection flags will be the same as those
|
||||||
|
/// of the [`android.os.SharedMemory`] object.
|
||||||
|
///
|
||||||
|
/// [`android.os.SharedMemory`]: https://developer.android.com/reference/android/os/SharedMemory
|
||||||
|
#[cfg(feature = "api-level-27")]
|
||||||
|
#[doc(alias = "ASharedMemory_dupFromJava")]
|
||||||
|
#[allow(clippy::not_unsafe_ptr_arg_deref)]
|
||||||
|
pub fn dup_from_java(env: *mut JNIEnv, shared_memory: jobject) -> Result<Self> {
|
||||||
|
let fd = unsafe { ffi::ASharedMemory_dupFromJava(env, shared_memory) };
|
||||||
|
if fd < 0 {
|
||||||
|
Err(Error::last_os_error())
|
||||||
|
} else {
|
||||||
|
Ok(unsafe { Self::from_raw_fd(fd) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the size of the shared memory region.
|
||||||
|
#[doc(alias = "ASharedMemory_getSize")]
|
||||||
|
pub fn size(&self) -> usize {
|
||||||
|
unsafe { ffi::ASharedMemory_getSize(self.as_raw_fd()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restrict access of shared memory region.
|
||||||
|
///
|
||||||
|
/// This function restricts access of a shared memory region. Access can only be removed. The
|
||||||
|
/// effect applies globally to all file descriptors in all processes across the system that
|
||||||
|
/// refer to this shared memory region. Existing memory mapped regions are not affected.
|
||||||
|
///
|
||||||
|
/// It is a common use case to create a shared memory region, map it read/write locally to
|
||||||
|
/// initialize content, and then send the shared memory to another process with read only
|
||||||
|
/// access. Code example as below:
|
||||||
|
///
|
||||||
|
/// ```no_run
|
||||||
|
/// # use ndk::shared_memory::SharedMemory;
|
||||||
|
/// # // TODO: Import from std::os::fd::{} since Rust 1.66
|
||||||
|
/// # use std::os::unix::io::AsRawFd;
|
||||||
|
/// # use std::ffi::CStr;
|
||||||
|
/// # unsafe {
|
||||||
|
/// let mem = SharedMemory::create(Some(CStr::from_bytes_with_nul_unchecked(b"memory\0")), 127).unwrap();
|
||||||
|
/// // By default it has PROT_READ | PROT_WRITE | PROT_EXEC.
|
||||||
|
/// let size = mem.size();
|
||||||
|
/// let buffer = libc::mmap(
|
||||||
|
/// std::ptr::null_mut(),
|
||||||
|
/// size,
|
||||||
|
/// libc::PROT_READ | libc::PROT_WRITE,
|
||||||
|
/// libc::MAP_SHARED,
|
||||||
|
/// mem.as_raw_fd(),
|
||||||
|
/// 0,
|
||||||
|
/// );
|
||||||
|
/// let buffer_slice = std::slice::from_raw_parts_mut(buffer.cast(), size);
|
||||||
|
///
|
||||||
|
/// // trivially initialize content
|
||||||
|
/// buffer_slice[..7].copy_from_slice(b"hello!\0");
|
||||||
|
///
|
||||||
|
/// // Existing mappings will retain their protection flags (PROT_WRITE here) after set_prod()
|
||||||
|
/// // unless it is unmapped:
|
||||||
|
/// libc::munmap(buffer, size);
|
||||||
|
///
|
||||||
|
/// // limit access to read only
|
||||||
|
/// mem.set_prot(libc::PROT_READ);
|
||||||
|
///
|
||||||
|
/// // share fd with another process here and the other process can only map with PROT_READ.
|
||||||
|
/// # }
|
||||||
|
/// ```
|
||||||
|
#[doc(alias = "ASharedMemory_setProt")]
|
||||||
|
pub fn set_prot(&self, prot: i32) -> Result<()> {
|
||||||
|
let status = unsafe { ffi::ASharedMemory_setProt(self.as_raw_fd(), prot) };
|
||||||
|
if status < 0 {
|
||||||
|
Err(Error::last_os_error())
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AsFd for SharedMemory {
|
||||||
|
fn as_fd(&self) -> BorrowedFd<'_> {
|
||||||
|
self.0.as_fd()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AsRawFd for SharedMemory {
|
||||||
|
fn as_raw_fd(&self) -> RawFd {
|
||||||
|
self.0.as_raw_fd()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoRawFd for SharedMemory {
|
||||||
|
fn into_raw_fd(self) -> RawFd {
|
||||||
|
self.0.into_raw_fd()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromRawFd for SharedMemory {
|
||||||
|
/// # Safety
|
||||||
|
///
|
||||||
|
/// The resource pointed to by `fd` must be open and suitable for assuming
|
||||||
|
/// ownership. The resource must not require any cleanup other than `close`.
|
||||||
|
unsafe fn from_raw_fd(fd: RawFd) -> Self {
|
||||||
|
Self(OwnedFd::from_raw_fd(fd))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
//! Bindings for [`ASurfaceTexture`]
|
||||||
|
//!
|
||||||
|
//! See <https://source.android.com/devices/graphics/arch-st> for an architectural overview of
|
||||||
|
//! [`SurfaceTexture`] internals.
|
||||||
|
//!
|
||||||
|
//! [`ASurfaceTexture`]: https://developer.android.com/ndk/reference/group/surface-texture
|
||||||
|
#![cfg(feature = "api-level-28")]
|
||||||
|
|
||||||
|
use crate::{native_window::NativeWindow, utils::status_to_io_result};
|
||||||
|
use jni_sys::{jobject, JNIEnv};
|
||||||
|
use std::{io::Result, ptr::NonNull, time::Duration};
|
||||||
|
|
||||||
|
/// An opaque type to manage [`android.graphics.SurfaceTexture`] from native code
|
||||||
|
///
|
||||||
|
/// [`android.graphics.SurfaceTexture`]: https://developer.android.com/reference/android/graphics/SurfaceTexture
|
||||||
|
#[derive(Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||||
|
pub struct SurfaceTexture {
|
||||||
|
ptr: NonNull<ffi::ASurfaceTexture>,
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe impl Send for SurfaceTexture {}
|
||||||
|
|
||||||
|
impl Drop for SurfaceTexture {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
unsafe { ffi::ASurfaceTexture_release(self.ptr.as_ptr()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SurfaceTexture {
|
||||||
|
/// Assumes ownership of `ptr`
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `ptr` must be a valid pointer to an Android [`ffi::ASurfaceTexture`].
|
||||||
|
pub unsafe fn from_ptr(ptr: NonNull<ffi::ASurfaceTexture>) -> Self {
|
||||||
|
Self { ptr }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a reference to the native [`SurfaceTexture`] from the corresponding Java object.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
///
|
||||||
|
/// This function should be called with a healthy JVM pointer and with a non-null
|
||||||
|
/// [`android.graphics.SurfaceTexture`], which must be kept alive on the Java/Kotlin side.
|
||||||
|
///
|
||||||
|
/// The caller must keep a reference to the Java [`android.graphics.SurfaceTexture`] during the
|
||||||
|
/// lifetime of the returned [`SurfaceTexture`]. Failing to do so could result in the
|
||||||
|
/// [`SurfaceTexture`] to stop functioning properly once the Java object gets finalized.
|
||||||
|
/// However, this will not result in program termination.
|
||||||
|
///
|
||||||
|
/// [`android.graphics.SurfaceTexture`]: https://developer.android.com/reference/android/graphics/SurfaceTexture
|
||||||
|
pub unsafe fn from_surface_texture(env: *mut JNIEnv, surface_texture: jobject) -> Option<Self> {
|
||||||
|
let a_surface_texture_ptr = ffi::ASurfaceTexture_fromSurfaceTexture(env, surface_texture);
|
||||||
|
let s = NonNull::new(a_surface_texture_ptr)?;
|
||||||
|
Some(SurfaceTexture::from_ptr(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a pointer to the native [`ffi::ASurfaceTexture`].
|
||||||
|
pub fn ptr(&self) -> NonNull<ffi::ASurfaceTexture> {
|
||||||
|
self.ptr
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a reference to a [`NativeWindow`] (i.e. the Producer) for this [`SurfaceTexture`].
|
||||||
|
///
|
||||||
|
/// This is equivalent to Java's:
|
||||||
|
/// ```java
|
||||||
|
/// Surface sur = new Surface(surfaceTexture);
|
||||||
|
/// ```
|
||||||
|
pub fn acquire_native_window(&self) -> Option<NativeWindow> {
|
||||||
|
let native_window = unsafe { ffi::ASurfaceTexture_acquireANativeWindow(self.ptr.as_ptr()) };
|
||||||
|
let n = NonNull::new(native_window)?;
|
||||||
|
Some(unsafe { NativeWindow::from_ptr(n) })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attach the [`SurfaceTexture`] to the OpenGL ES context that is current on the calling
|
||||||
|
/// thread.
|
||||||
|
///
|
||||||
|
/// A new OpenGL ES texture object is created and populated with the [`SurfaceTexture`] image
|
||||||
|
/// frame that was current at the time of the last call to
|
||||||
|
/// [`detach_from_gl_context()`][Self::detach_from_gl_context()]. This new texture is bound to
|
||||||
|
/// the `GL_TEXTURE_EXTERNAL_OES` texture target.
|
||||||
|
///
|
||||||
|
/// This can be used to access the [`SurfaceTexture`] image contents from multiple OpenGL ES
|
||||||
|
/// contexts. Note, however, that the image contents are only accessible from one OpenGL ES
|
||||||
|
/// context at a time.
|
||||||
|
pub fn attach_to_gl_context(&self, tex_name: u32) -> Result<()> {
|
||||||
|
let status = unsafe { ffi::ASurfaceTexture_attachToGLContext(self.ptr.as_ptr(), tex_name) };
|
||||||
|
status_to_io_result(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detach the [`SurfaceTexture`] from the OpenGL ES context that owns the OpenGL ES texture
|
||||||
|
/// object.
|
||||||
|
///
|
||||||
|
/// This call must be made with the OpenGL ES context current on the calling thread. The OpenGL
|
||||||
|
/// ES texture object will be deleted as a result of this call. After calling this method all
|
||||||
|
/// calls to [`update_tex_image()`][Self::update_tex_image()] will fail until a successful call
|
||||||
|
/// to [`attach_to_gl_context()`][Self::attach_to_gl_context()] is made.
|
||||||
|
///
|
||||||
|
/// This can be used to access the [`SurfaceTexture`] image contents from multiple OpenGL ES
|
||||||
|
/// contexts. Note, however, that the image contents are only accessible from one OpenGL ES
|
||||||
|
/// context at a time.
|
||||||
|
pub fn detach_from_gl_context(&self) -> Result<()> {
|
||||||
|
let status = unsafe { ffi::ASurfaceTexture_detachFromGLContext(self.ptr.as_ptr()) };
|
||||||
|
status_to_io_result(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieve the 4x4 texture coordinate transform matrix associated with the texture image set
|
||||||
|
/// by the most recent call to [`update_tex_image()`][Self::update_tex_image()].
|
||||||
|
///
|
||||||
|
/// This transform matrix maps 2D homogeneous texture coordinates of the form `(s, t, 0, 1)`
|
||||||
|
/// with `s` and `t` in the inclusive range `[0, 1]` to the texture coordinate that should be
|
||||||
|
/// used to sample that location from the texture. Sampling the texture outside of the range of
|
||||||
|
/// this transform is undefined.
|
||||||
|
///
|
||||||
|
/// The matrix is stored in column-major order so that it may be passed directly to OpenGL ES
|
||||||
|
/// via the [`glLoadMatrixf()`] or [`glUniformMatrix4fv()`] functions.
|
||||||
|
///
|
||||||
|
/// [`glLoadMatrixf()`]: https://www.khronos.org/registry/OpenGL-Refpages/gl2.1/xhtml/glLoadMatrix.xml
|
||||||
|
/// [`gluniformmatrix4fv()`]: https://www.khronos.org/registry/OpenGL-Refpages/es3.1/html/glUniform.xhtml
|
||||||
|
pub fn transform_matrix(&self) -> [f32; 16] {
|
||||||
|
let mut r = [0f32; 16];
|
||||||
|
unsafe { ffi::ASurfaceTexture_getTransformMatrix(self.ptr.as_ptr(), r.as_mut_ptr()) };
|
||||||
|
r
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieve the timestamp associated with the texture image set by the most recent call to
|
||||||
|
/// [`update_tex_image()`][Self::update_tex_image()].
|
||||||
|
///
|
||||||
|
/// This timestamp is in nanoseconds, and is normally monotonically increasing. The timestamp
|
||||||
|
/// should be unaffected by time-of-day adjustments, and for a camera should be strictly
|
||||||
|
/// monotonic but for a [`MediaPlayer`] may be reset when the position is set. The specific
|
||||||
|
/// meaning and zero point of the timestamp depends on the source providing images to the
|
||||||
|
/// [`SurfaceTexture`]. Unless otherwise specified by the image source, timestamps cannot
|
||||||
|
/// generally be compared across [`SurfaceTexture`] instances, or across multiple program
|
||||||
|
/// invocations. It is mostly useful for determining time offsets between subsequent frames.
|
||||||
|
///
|
||||||
|
/// For EGL/Vulkan producers, this timestamp is the desired present time set with the
|
||||||
|
/// [`EGL_ANDROID_presentation_time`] or [`VK_GOOGLE_display_timing`] extensions.
|
||||||
|
///
|
||||||
|
/// [`MediaPlayer`]: https://developer.android.com/reference/android/media/MediaPlayer
|
||||||
|
/// [`EGL_ANDROID_presentation_time`]: https://www.khronos.org/registry/EGL/extensions/ANDROID/EGL_ANDROID_presentation_time.txt
|
||||||
|
/// [`VK_GOOGLE_display_timing`]: https://www.khronos.org/registry/vulkan/specs/1.3-extensions/man/html/VK_GOOGLE_display_timing.html
|
||||||
|
pub fn timestamp(&self) -> Duration {
|
||||||
|
Duration::from_nanos(
|
||||||
|
unsafe { ffi::ASurfaceTexture_getTimestamp(self.ptr.as_ptr()) }
|
||||||
|
.try_into()
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update the texture image to the most recent frame from the image stream.
|
||||||
|
///
|
||||||
|
/// This may only be called while the OpenGL ES context that owns the texture is current on the
|
||||||
|
/// calling thread. It will implicitly bind its texture to the `GL_TEXTURE_EXTERNAL_OES`
|
||||||
|
/// texture target.
|
||||||
|
pub fn update_tex_image(&self) -> Result<()> {
|
||||||
|
let status = unsafe { ffi::ASurfaceTexture_updateTexImage(self.ptr.as_ptr()) };
|
||||||
|
status_to_io_result(status)
|
||||||
|
}
|
||||||
|
}
|
||||||
+143
@@ -0,0 +1,143 @@
|
|||||||
|
//! Bindings for [sync functions]
|
||||||
|
//!
|
||||||
|
//! [sync functions]: https://developer.android.com/ndk/reference/group/sync
|
||||||
|
#![cfg(feature = "sync")]
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
ffi::CStr,
|
||||||
|
fmt::Debug,
|
||||||
|
// TODO: Import from std::os::fd::{} since Rust 1.66
|
||||||
|
os::unix::io::{AsRawFd, BorrowedFd, FromRawFd, OwnedFd},
|
||||||
|
ptr::NonNull,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[doc(alias = "sync_file_info")]
|
||||||
|
#[repr(transparent)]
|
||||||
|
pub struct SyncFileInfo {
|
||||||
|
inner: NonNull<ffi::sync_file_info>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Debug for SyncFileInfo {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("SyncFileInfo")
|
||||||
|
.field("name", &self.name())
|
||||||
|
.field("status", &self.status())
|
||||||
|
.field("flags", &self.flags())
|
||||||
|
.field("num_fences", &self.num_fences())
|
||||||
|
.field("fence_info", &self.fence_info())
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SyncFileInfo {
|
||||||
|
/// Retrieve detailed information about a sync file and its fences.
|
||||||
|
#[doc(alias = "sync_file_info")]
|
||||||
|
pub fn new(fd: BorrowedFd<'_>) -> Option<Self> {
|
||||||
|
let inner = NonNull::new(unsafe { ffi::sync_file_info(fd.as_raw_fd()) })?;
|
||||||
|
Some(Self { inner })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn name(&self) -> &CStr {
|
||||||
|
let inner = unsafe { self.inner.as_ref() };
|
||||||
|
// TODO: Switch to CStr::from_bytes_until_nul (with c_char -> u8 transmute) since MSRV 1.69
|
||||||
|
// https://github.com/ash-rs/ash/pull/746
|
||||||
|
unsafe { CStr::from_ptr(inner.name.as_ptr()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn status(&self) -> i32 {
|
||||||
|
let inner = unsafe { self.inner.as_ref() };
|
||||||
|
inner.status
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn flags(&self) -> u32 {
|
||||||
|
let inner = unsafe { self.inner.as_ref() };
|
||||||
|
inner.flags
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn num_fences(&self) -> usize {
|
||||||
|
let inner = unsafe { self.inner.as_ref() };
|
||||||
|
inner.num_fences as usize
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the array of fence infos from the sync file's info.
|
||||||
|
#[doc(alias = "sync_get_fence_info")]
|
||||||
|
pub fn fence_info(&self) -> &[SyncFenceInfo] {
|
||||||
|
let inner = unsafe { self.inner.as_ref() };
|
||||||
|
|
||||||
|
if inner.num_fences == 0 {
|
||||||
|
&[]
|
||||||
|
} else {
|
||||||
|
let sync_fence_info = NonNull::new(inner.sync_fence_info as *mut _)
|
||||||
|
.expect("sync_fence_info cannot be null if num_fences > 0");
|
||||||
|
unsafe {
|
||||||
|
std::slice::from_raw_parts(sync_fence_info.as_ptr(), inner.num_fences as usize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for SyncFileInfo {
|
||||||
|
/// Free a [`struct@ffi::sync_file_info`] structure.
|
||||||
|
#[doc(alias = "sync_file_info_free")]
|
||||||
|
fn drop(&mut self) {
|
||||||
|
unsafe { ffi::sync_file_info_free(self.inner.as_ptr()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[doc(alias = "sync_fence_info")]
|
||||||
|
#[repr(transparent)]
|
||||||
|
pub struct SyncFenceInfo(ffi::sync_fence_info);
|
||||||
|
|
||||||
|
impl Debug for SyncFenceInfo {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("SyncFenceInfo")
|
||||||
|
.field("obj_name", &self.obj_name())
|
||||||
|
.field("driver_name", &self.driver_name())
|
||||||
|
.field("status", &self.status())
|
||||||
|
.field("flags", &self.flags())
|
||||||
|
.field("timestamp_ns", &self.timestamp_ns())
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SyncFenceInfo {
|
||||||
|
pub fn obj_name(&self) -> &CStr {
|
||||||
|
// TODO: Switch to CStr::from_bytes_until_nul (with c_char -> u8 transmute) since MSRV 1.69
|
||||||
|
unsafe { CStr::from_ptr(self.0.obj_name.as_ptr()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn driver_name(&self) -> &CStr {
|
||||||
|
// TODO: Switch to CStr::from_bytes_until_nul (with c_char -> u8 transmute) since MSRV 1.69
|
||||||
|
unsafe { CStr::from_ptr(self.0.driver_name.as_ptr()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn status(&self) -> i32 {
|
||||||
|
self.0.status
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn flags(&self) -> u32 {
|
||||||
|
self.0.flags
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn timestamp_ns(&self) -> u64 {
|
||||||
|
self.0.timestamp_ns
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merge two sync files.
|
||||||
|
///
|
||||||
|
/// This produces a new sync file with the given name which has the union of the two original sync
|
||||||
|
/// file's fences; redundant fences may be removed.
|
||||||
|
///
|
||||||
|
/// If one of the input sync files is signaled or invalid, then this function may behave like
|
||||||
|
/// `dup()`: the new file descriptor refers to the valid/unsignaled sync file with its original
|
||||||
|
/// name, rather than a new sync file.
|
||||||
|
pub fn sync_merge(name: &CStr, fd1: BorrowedFd<'_>, fd2: BorrowedFd<'_>) -> OwnedFd {
|
||||||
|
unsafe {
|
||||||
|
OwnedFd::from_raw_fd(ffi::sync_merge(
|
||||||
|
name.as_ptr(),
|
||||||
|
fd1.as_raw_fd(),
|
||||||
|
fd2.as_raw_fd(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
+92
@@ -0,0 +1,92 @@
|
|||||||
|
//! Bindings for the NDK tracing API.
|
||||||
|
//!
|
||||||
|
//! See also [the NDK docs](https://developer.android.com/ndk/reference/group/tracing)
|
||||||
|
#![cfg(feature = "api-level-23")]
|
||||||
|
use std::ffi::{CString, NulError};
|
||||||
|
use std::marker::PhantomData;
|
||||||
|
|
||||||
|
pub fn is_trace_enabled() -> bool {
|
||||||
|
unsafe { ffi::ATrace_isEnabled() }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Section {
|
||||||
|
// Section is !Sync and !Send
|
||||||
|
_pd: PhantomData<*mut ()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Section {
|
||||||
|
pub fn new(name: &str) -> Result<Self, NulError> {
|
||||||
|
let section_name = CString::new(name)?;
|
||||||
|
unsafe { ffi::ATrace_beginSection(section_name.as_ptr()) };
|
||||||
|
|
||||||
|
Ok(Self { _pd: PhantomData })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn end(self) {
|
||||||
|
drop(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Section {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
unsafe { ffi::ATrace_endSection() };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unique identifier for distinguishing simultaneous events
|
||||||
|
#[derive(Debug)]
|
||||||
|
#[cfg(feature = "api-level-29")]
|
||||||
|
pub struct Cookie(pub i32);
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
#[cfg(feature = "api-level-29")]
|
||||||
|
pub struct AsyncSection {
|
||||||
|
section_name: CString,
|
||||||
|
cookie: Cookie,
|
||||||
|
// AsyncSection is !Sync
|
||||||
|
_pd: PhantomData<&'static ()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "api-level-29")]
|
||||||
|
impl AsyncSection {
|
||||||
|
pub fn new(name: &str, cookie: Cookie) -> Result<Self, NulError> {
|
||||||
|
let section_name = CString::new(name)?;
|
||||||
|
unsafe { ffi::ATrace_beginAsyncSection(section_name.as_ptr(), cookie.0) };
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
section_name,
|
||||||
|
cookie,
|
||||||
|
_pd: PhantomData,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn end(self) {
|
||||||
|
drop(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "api-level-29")]
|
||||||
|
impl Drop for AsyncSection {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
unsafe { ffi::ATrace_endAsyncSection(self.section_name.as_ptr(), self.cookie.0) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "api-level-29")]
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Counter {
|
||||||
|
name: CString,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "api-level-29")]
|
||||||
|
impl Counter {
|
||||||
|
pub fn new(name: &str) -> Result<Self, NulError> {
|
||||||
|
let name = CString::new(name)?;
|
||||||
|
Ok(Self { name })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_value(&self, value: i64) {
|
||||||
|
unsafe { ffi::ATrace_setCounter(self.name.as_ptr(), value) }
|
||||||
|
}
|
||||||
|
}
|
||||||
+70
@@ -0,0 +1,70 @@
|
|||||||
|
//! Internal utilities
|
||||||
|
use log::{error, log_enabled, Level};
|
||||||
|
use std::ffi::{c_int, CStr, CString};
|
||||||
|
use std::io::{Error, Result};
|
||||||
|
|
||||||
|
/// Turns standard `<errno.h>` status codes - typically rewrapped by Android's [`Errors.h`] - into
|
||||||
|
/// Rust's [`std::io::Error`].
|
||||||
|
///
|
||||||
|
/// [`Errors.h`]: https://cs.android.com/android/platform/superproject/+/master:system/core/libutils/include/utils/Errors.h
|
||||||
|
pub(crate) fn status_to_io_result(status: i32) -> Result<()> {
|
||||||
|
match status {
|
||||||
|
0 => Ok(()),
|
||||||
|
r if r < 0 => Err(Error::from_raw_os_error(-r)),
|
||||||
|
r => unreachable!("Status is positive integer {}", r),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn android_log(level: Level, tag: &CStr, msg: &CStr) {
|
||||||
|
let prio = match level {
|
||||||
|
Level::Error => ffi::android_LogPriority::ANDROID_LOG_ERROR,
|
||||||
|
Level::Warn => ffi::android_LogPriority::ANDROID_LOG_WARN,
|
||||||
|
Level::Info => ffi::android_LogPriority::ANDROID_LOG_INFO,
|
||||||
|
Level::Debug => ffi::android_LogPriority::ANDROID_LOG_DEBUG,
|
||||||
|
Level::Trace => ffi::android_LogPriority::ANDROID_LOG_VERBOSE,
|
||||||
|
};
|
||||||
|
unsafe {
|
||||||
|
ffi::__android_log_write(prio.0 as c_int, tag.as_ptr(), msg.as_ptr());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn log_panic(panic: Box<dyn std::any::Any + Send>) {
|
||||||
|
fn log_panic(panic_str: &str) {
|
||||||
|
const RUST_PANIC_TAG: &CStr =
|
||||||
|
unsafe { CStr::from_bytes_with_nul_unchecked(b"RustPanic\0") };
|
||||||
|
|
||||||
|
let panic_str = CString::new(panic_str).unwrap_or_default();
|
||||||
|
|
||||||
|
// Use the Rust logger if installed and enabled, otherwise fall back to the Android system
|
||||||
|
// logger so there is at least some record of the panic
|
||||||
|
if log_enabled!(Level::Error) {
|
||||||
|
error!("RustPanic: {}", panic_str.to_string_lossy());
|
||||||
|
log::logger().flush();
|
||||||
|
} else {
|
||||||
|
android_log(Level::Error, RUST_PANIC_TAG, &panic_str);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match panic.downcast::<String>() {
|
||||||
|
Ok(panic_string) => log_panic(&panic_string),
|
||||||
|
Err(panic) => match panic.downcast::<&str>() {
|
||||||
|
Ok(panic_str) => log_panic(&panic_str),
|
||||||
|
Err(_) => log_panic("Unknown panic message type"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run a closure and abort the program if it panics.
|
||||||
|
///
|
||||||
|
/// This is generally used to ensure Rust callbacks won't unwind past the FFI boundary, which leads
|
||||||
|
/// to undefined behaviour.
|
||||||
|
pub(crate) fn abort_on_panic<R>(f: impl FnOnce() -> R) -> R {
|
||||||
|
std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).unwrap_or_else(|panic| {
|
||||||
|
// Try logging the panic before aborting
|
||||||
|
//
|
||||||
|
// Just in case our attempt to log a panic could itself cause a panic we use a
|
||||||
|
// second catch_unwind here.
|
||||||
|
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| log_panic(panic)));
|
||||||
|
std::process::abort();
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -366,7 +366,8 @@
|
|||||||
INFOPLIST_FILE = Config/Info.plist;
|
INFOPLIST_FILE = Config/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
|
INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
|
||||||
INFOPLIST_KEY_GCSupportsControllerUserInteraction = YES;
|
INFOPLIST_KEY_GCSupportsControllerUserInteraction = YES;
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
INFOPLIST_KEY_GCSupportsGameMode = YES;
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.games";
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
||||||
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone.";
|
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone.";
|
||||||
@@ -401,7 +402,8 @@
|
|||||||
INFOPLIST_FILE = Config/Info.plist;
|
INFOPLIST_FILE = Config/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
|
INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
|
||||||
INFOPLIST_KEY_GCSupportsControllerUserInteraction = YES;
|
INFOPLIST_KEY_GCSupportsControllerUserInteraction = YES;
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
INFOPLIST_KEY_GCSupportsGameMode = YES;
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.games";
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
||||||
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone.";
|
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone.";
|
||||||
@@ -433,7 +435,8 @@
|
|||||||
INFOPLIST_FILE = Config/Info.plist;
|
INFOPLIST_FILE = Config/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
|
INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
|
||||||
INFOPLIST_KEY_GCSupportsControllerUserInteraction = YES;
|
INFOPLIST_KEY_GCSupportsControllerUserInteraction = YES;
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
INFOPLIST_KEY_GCSupportsGameMode = YES;
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.games";
|
||||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
||||||
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone.";
|
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone.";
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
@@ -473,7 +476,8 @@
|
|||||||
INFOPLIST_FILE = Config/Info.plist;
|
INFOPLIST_FILE = Config/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
|
INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
|
||||||
INFOPLIST_KEY_GCSupportsControllerUserInteraction = YES;
|
INFOPLIST_KEY_GCSupportsControllerUserInteraction = YES;
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
INFOPLIST_KEY_GCSupportsGameMode = YES;
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.games";
|
||||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
||||||
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone.";
|
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone.";
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
@@ -511,7 +515,7 @@
|
|||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = Config/Info.plist;
|
INFOPLIST_FILE = Config/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
|
INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.games";
|
||||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
@@ -541,7 +545,7 @@
|
|||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = Config/Info.plist;
|
INFOPLIST_FILE = Config/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
|
INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.games";
|
||||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ Opus audio, cert pinning — lives in the shared Rust **`punktfunk-core`** (stat
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Hardware decode** — VideoToolbox HEVC, with a low-latency **stage-2 presenter**
|
- **Hardware decode** — VideoToolbox H.264/HEVC (plus **AV1** on devices with an AV1 hardware
|
||||||
|
decoder — M3-class Macs, A17 Pro-class iPhones), with a low-latency **stage-2 presenter**
|
||||||
(`VTDecompressionSession` → `CAMetalLayer`, presented off a `CADisplayLink`, ~11 ms p50) as the
|
(`VTDecompressionSession` → `CAMetalLayer`, presented off a `CADisplayLink`, ~11 ms p50) as the
|
||||||
default and an `AVSampleBufferDisplayLayer` fallback.
|
default and an `AVSampleBufferDisplayLayer` fallback.
|
||||||
- **HDR & 4:4:4** — PQ passthrough with a correct reference-white anchor, mid-session SDR↔HDR
|
- **HDR & 4:4:4** — PQ passthrough with a correct reference-white anchor, mid-session SDR↔HDR
|
||||||
@@ -22,8 +23,9 @@ Opus audio, cert pinning — lives in the shared Rust **`punktfunk-core`** (stat
|
|||||||
- **Full controller support** — one selected controller forwarded as pad 0, including **DualSense**
|
- **Full controller support** — one selected controller forwarded as pad 0, including **DualSense**
|
||||||
feedback (rumble → CoreHaptics, lightbar, player LEDs, adaptive triggers) and touchpad/motion. The
|
feedback (rumble → CoreHaptics, lightbar, player LEDs, adaptive triggers) and touchpad/motion. The
|
||||||
virtual pad type auto-resolves from your physical controller.
|
virtual pad type auto-resolves from your physical controller.
|
||||||
- **Mouse & keyboard** — `GCMouse`/`GCKeyboard` capture with click-to-capture and a ⌘⎋ release, plus
|
- **Mouse & keyboard** — `GCMouse`/`GCKeyboard` capture with click-to-capture and a ⌃⌥⇧Q release
|
||||||
iPad pointer lock and touch input.
|
(the cross-client Ctrl+Alt+Shift+Q; ⌘⎋ still works as the macOS/iPad toggle), plus iPad pointer
|
||||||
|
lock and touch input.
|
||||||
- **Find hosts automatically** — mDNS discovery (`NWBrowser` over `_punktfunk._udp`); first connect
|
- **Find hosts automatically** — mDNS discovery (`NWBrowser` over `_punktfunk._udp`); first connect
|
||||||
does a one-time **SPAKE2 PIN pairing** (or TOFU on trusted LANs), then reconnects on a pinned,
|
does a one-time **SPAKE2 PIN pairing** (or TOFU on trusted LANs), then reconnects on a pinned,
|
||||||
Keychain-stored identity.
|
Keychain-stored identity.
|
||||||
@@ -83,7 +85,9 @@ PUNKTFUNK_AUTOCONNECT=<box-ip> PUNKTFUNK_MODE=1280x720x60 swift run PunktfunkCli
|
|||||||
- **`PunktfunkClient`** (the app) — hosts grid with an *On this network* section, add-host sheet,
|
- **`PunktfunkClient`** (the app) — hosts grid with an *On this network* section, add-host sheet,
|
||||||
the two trust flows (TOFU prompt + SPAKE2 `PairSheet`), the stream view with the HUD, a
|
the two trust flows (TOFU prompt + SPAKE2 `PairSheet`), the stream view with the HUD, a
|
||||||
tabbed Settings pane (General / Display / Audio / Controllers / Advanced), and the network speed
|
tabbed Settings pane (General / Display / Audio / Controllers / Advanced), and the network speed
|
||||||
test. A Scene-level **Stream** menu carries Disconnect (⌘D) and the HUD toggle (⌘⇧S).
|
test. A Scene-level **Stream** menu carries the cross-client shortcut set: Release Mouse (⌃⌥⇧Q),
|
||||||
|
Disconnect (⌃⌥⇧D) and the HUD toggle (⌃⌥⇧S) — the same Ctrl+Alt+Shift combos the Windows and
|
||||||
|
Linux clients reserve, also shown on a 6-second banner at stream start.
|
||||||
On iOS/iPadOS **and macOS** a connected controller swaps the whole home for the **gamepad UI**
|
On iOS/iPadOS **and macOS** a connected controller swaps the whole home for the **gamepad UI**
|
||||||
(`Home/Gamepad*`, `Settings/GamepadSettingsView`): a console-style host carousel (A connect · Y
|
(`Home/Gamepad*`, `Settings/GamepadSettingsView`): a console-style host carousel (A connect · Y
|
||||||
library · X settings), a controller-navigable settings screen, an add-host flow with an
|
library · X settings), a controller-navigable settings screen, an add-host flow with an
|
||||||
|
|||||||
@@ -55,6 +55,18 @@ struct ContentView: View {
|
|||||||
/// Wakes a sleeping host and waits for it to come back online before connecting (drives the
|
/// Wakes a sleeping host and waits for it to come back online before connecting (drives the
|
||||||
/// "Waking…" overlay). macOS-only in practice — WoL is gated off on iOS/tvOS.
|
/// "Waking…" overlay). macOS-only in practice — WoL is gated off on iOS/tvOS.
|
||||||
@StateObject private var waker = HostWaker()
|
@StateObject private var waker = HostWaker()
|
||||||
|
#if os(macOS)
|
||||||
|
/// Whether the hosting window is native-fullscreen right now (reported by
|
||||||
|
/// FullscreenController). Drives the session view's safe-area choice: fullscreen goes
|
||||||
|
/// edge-to-edge (behind the notch); windowed respects the top inset so the title bar
|
||||||
|
/// never covers the video.
|
||||||
|
@State private var isFullscreen = false
|
||||||
|
/// Shows the start-of-stream shortcut banner (the Windows client's discoverability
|
||||||
|
/// pattern): raised on every transition to `.streaming`, dropped by the banner's own
|
||||||
|
/// 6-second task. Independent of the stats HUD so the keys are discoverable even with
|
||||||
|
/// statistics off.
|
||||||
|
@State private var showShortcutHint = false
|
||||||
|
#endif
|
||||||
#if !os(macOS)
|
#if !os(macOS)
|
||||||
@State private var showSettings = false
|
@State private var showSettings = false
|
||||||
#endif
|
#endif
|
||||||
@@ -89,6 +101,9 @@ struct ContentView: View {
|
|||||||
.onChange(of: model.phase) { _, phase in
|
.onChange(of: model.phase) { _, phase in
|
||||||
switch phase {
|
switch phase {
|
||||||
case .streaming:
|
case .streaming:
|
||||||
|
#if os(macOS)
|
||||||
|
showShortcutHint = true // the 6 s shortcut banner, per session start
|
||||||
|
#endif
|
||||||
// A session actually started — remember it on the card ("Connected … ago"
|
// A session actually started — remember it on the card ("Connected … ago"
|
||||||
// plus the accent ring on the most recent host).
|
// plus the accent ring on the most recent host).
|
||||||
guard let host = model.activeHost else { break }
|
guard let host = model.activeHost else { break }
|
||||||
@@ -115,7 +130,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onDisappear { model.disconnect() } // window closed mid-session (Cmd+N spawns more)
|
.onDisappear { model.disconnect() } // window closed mid-session (Cmd+N spawns more)
|
||||||
// Expose the session to the Scene-level Stream menu (Disconnect ⌘D works even when
|
// Expose the session to the Scene-level Stream menu (Disconnect ⌃⌥⇧D works even when
|
||||||
// the HUD is hidden). tvOS has no such menu.
|
// the HUD is hidden). tvOS has no such menu.
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
.focusedSceneValue(\.sessionFocus, SessionFocus(
|
.focusedSceneValue(\.sessionFocus, SessionFocus(
|
||||||
@@ -125,7 +140,12 @@ struct ContentView: View {
|
|||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
// Fullscreen only while a session is up (incl. the trust prompt over the blurred stream),
|
// Fullscreen only while a session is up (incl. the trust prompt over the blurred stream),
|
||||||
// windowed on the host list — so the picker isn't forced fullscreen. Opt-out in Settings.
|
// windowed on the host list — so the picker isn't forced fullscreen. Opt-out in Settings.
|
||||||
.background(FullscreenController(active: fullscreenWhileStreaming && model.connection != nil))
|
// The controller also reports the window's ACTUAL fullscreen state back into
|
||||||
|
// `isFullscreen` (the user can toggle it manually), which drives the session view's
|
||||||
|
// safe-area handling below.
|
||||||
|
.background(FullscreenController(
|
||||||
|
active: fullscreenWhileStreaming && model.connection != nil,
|
||||||
|
isFullscreen: $isFullscreen))
|
||||||
#endif
|
#endif
|
||||||
// On the outer Group so the sheet survives the trust-prompt → home transition
|
// On the outer Group so the sheet survives the trust-prompt → home transition
|
||||||
// (the "Pair with PIN instead" path disconnects first — the host's accept loop
|
// (the "Pair with PIN instead" path disconnects first — the host's accept loop
|
||||||
@@ -188,7 +208,21 @@ struct ContentView: View {
|
|||||||
.alert(
|
.alert(
|
||||||
"Connection failed",
|
"Connection failed",
|
||||||
isPresented: Binding(
|
isPresented: Binding(
|
||||||
get: { model.errorMessage != nil },
|
get: {
|
||||||
|
guard model.errorMessage != nil else { return false }
|
||||||
|
#if os(macOS)
|
||||||
|
// Defer the alert while a forced-fullscreen exit is still pending: a sheet
|
||||||
|
// attached to a fullscreen window makes AppKit drop `-toggleFullScreen:`, so
|
||||||
|
// presenting it now strands the window fullscreen on the home screen after a
|
||||||
|
// session error (a deliberate disconnect sets no `errorMessage`, which is why
|
||||||
|
// it never stuck). Tearing the session down already flipped `active`→false;
|
||||||
|
// once the window leaves fullscreen and `isFullscreen` flips, the alert shows
|
||||||
|
// over the windowed home UI. Not gated when fullscreen is the user's own manual
|
||||||
|
// choice (opt-out setting) — nothing is auto-exiting there to conflict with.
|
||||||
|
if fullscreenWhileStreaming && isFullscreen { return false }
|
||||||
|
#endif
|
||||||
|
return true
|
||||||
|
},
|
||||||
set: { if !$0 { model.errorMessage = nil } })
|
set: { if !$0 { model.errorMessage = nil } })
|
||||||
) {
|
) {
|
||||||
Button("OK", role: .cancel) {}
|
Button("OK", role: .cancel) {}
|
||||||
@@ -300,13 +334,17 @@ struct ContentView: View {
|
|||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
.frame(minWidth: 640, minHeight: 360)
|
.frame(minWidth: 640, minHeight: 360)
|
||||||
.background(Color.black)
|
.background(Color.black)
|
||||||
// Fill the whole display in fullscreen, INCLUDING behind the camera housing (notch).
|
// FULLSCREEN fills the whole display, INCLUDING behind the camera housing (notch).
|
||||||
// Without this the stream is laid out in the safe area below the notch, so an
|
// Without this the stream is laid out in the safe area below the notch, so an
|
||||||
// aspect-fit video at the display's native mode scales down and leaves black borders.
|
// aspect-fit video at the display's native mode scales down and leaves black borders.
|
||||||
// A fullscreen video behind the notch (a thin top-center strip occluded) is the
|
// A fullscreen video behind the notch (a thin top-center strip occluded) is the
|
||||||
// expected behavior — same edge-to-edge intent as the iOS/tvOS branches below. Inert
|
// expected behavior — same edge-to-edge intent as the iOS/tvOS branches below.
|
||||||
// in windowed mode (no notch safe-area inset on a titled window).
|
// WINDOWED keeps the TOP inset: macOS 26 windows extend content under the (glass)
|
||||||
.ignoresSafeArea()
|
// title bar and report its height as top safe area — ignoring it there put the top of
|
||||||
|
// the video (and the HUD) underneath the title bar. The black `.background` above is a
|
||||||
|
// ShapeStyle background, which always extends under every inset, so the strip behind
|
||||||
|
// the title bar stays black rather than showing the video.
|
||||||
|
.ignoresSafeArea(edges: isFullscreen ? .all : [.horizontal, .bottom])
|
||||||
#elseif os(iOS)
|
#elseif os(iOS)
|
||||||
// Streaming is immersive: edge-to-edge under the status bar and home
|
// Streaming is immersive: edge-to-edge under the status bar and home
|
||||||
// indicator, both hidden for the session (they return with the hosts grid).
|
// indicator, both hidden for the session (they return with the hosts grid).
|
||||||
@@ -335,6 +373,9 @@ struct ContentView: View {
|
|||||||
onCaptureChange: { [weak model] captured in
|
onCaptureChange: { [weak model] captured in
|
||||||
model?.mouseCaptured = captured
|
model?.mouseCaptured = captured
|
||||||
},
|
},
|
||||||
|
onDisconnectRequest: { [weak model] in
|
||||||
|
model?.disconnect() // the captured-state ⌃⌥⇧D combo
|
||||||
|
},
|
||||||
onFrame: { [meter = model.meter, latency = model.latency,
|
onFrame: { [meter = model.meter, latency = model.latency,
|
||||||
split = model.latencySplit, offset = conn.clockOffsetNs] au in
|
split = model.latencySplit, offset = conn.clockOffsetNs] au in
|
||||||
meter.note(byteCount: au.data.count)
|
meter.note(byteCount: au.data.count)
|
||||||
@@ -356,6 +397,30 @@ struct ContentView: View {
|
|||||||
StreamHUDView(model: model, connection: conn, placement: placement)
|
StreamHUDView(model: model, connection: conn, placement: placement)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#if os(macOS)
|
||||||
|
// The start-of-stream shortcut banner (Windows-client parity): the full
|
||||||
|
// reserved key set on a glass pill, bottom-centre, for the first 6 seconds of
|
||||||
|
// every session — independent of the stats HUD, so the keys are discoverable
|
||||||
|
// even with statistics off. The banner's own task drops it (cancelled cleanly
|
||||||
|
// if the session view goes away first).
|
||||||
|
.overlay(alignment: .bottom) {
|
||||||
|
if captureEnabled && showShortcutHint {
|
||||||
|
Text("Click the stream to capture · ⌃⌥⇧Q releases the mouse · "
|
||||||
|
+ "⌃⌥⇧D disconnects · ⌃⌥⇧S stats")
|
||||||
|
.font(.geist(12, relativeTo: .caption))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.glassBackground(Capsule())
|
||||||
|
.padding(.bottom, 24)
|
||||||
|
.transition(.opacity)
|
||||||
|
.task {
|
||||||
|
try? await Task.sleep(for: .seconds(6))
|
||||||
|
withAnimation(.easeOut(duration: 0.6)) { showShortcutHint = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
// Touch users have no menu / ⌘D, so when the HUD (and its Disconnect button)
|
// Touch users have no menu / ⌘D, so when the HUD (and its Disconnect button)
|
||||||
// is hidden, keep a minimal always-reachable exit in a corner. It rides a
|
// is hidden, keep a minimal always-reachable exit in a corner. It rides a
|
||||||
@@ -422,14 +487,24 @@ struct ContentView: View {
|
|||||||
host, launchID: launchID, allowTofu: allowTofu,
|
host, launchID: launchID, allowTofu: allowTofu,
|
||||||
requestAccess: requestAccess, approvalReq: approvalReq)
|
requestAccess: requestAccess, approvalReq: approvalReq)
|
||||||
}
|
}
|
||||||
// Asleep (not advertising) and we can wake it? Fire the magic packet and WAIT for it to come
|
// Not advertising and we can wake it? DIAL FIRST anyway — no mDNS advert does NOT mean
|
||||||
// back online — a cold box takes far longer to boot than a connect will sit — showing the
|
// unreachable: a host reached over a routed network (Tailscale/VPN/another subnet) is
|
||||||
// "Waking…" overlay meanwhile. Then connect. Otherwise dial straight away.
|
// mDNS-blind forever, and gating the dial on presence bricked exactly those reconnects
|
||||||
|
// (the host log shows no connection attempt at all; the tile pip and this gate share the
|
||||||
|
// LAN-only `advertises` predicate). `prepareWake` inside the dial already fires the magic
|
||||||
|
// packet up front, so a genuinely-asleep host is waking while the connect times out; only
|
||||||
|
// when that dial FAILS do we fall into the visible "Waking…" wait — a cold box takes far
|
||||||
|
// longer to boot than a connect will sit — and redial once it's back on mDNS.
|
||||||
if PunktfunkConnection.wakeOnLANAvailable, !host.wakeMacs.isEmpty, !discovery.advertises(host) {
|
if PunktfunkConnection.wakeOnLANAvailable, !host.wakeMacs.isEmpty, !discovery.advertises(host) {
|
||||||
discovery.start() // so we can observe it reappear
|
discovery.start() // so the wake-wait can observe it reappear
|
||||||
|
startSessionDirect(
|
||||||
|
host, launchID: launchID, allowTofu: allowTofu,
|
||||||
|
requestAccess: requestAccess, approvalReq: approvalReq,
|
||||||
|
onUnreachable: {
|
||||||
waker.start(
|
waker.start(
|
||||||
host: host, connectsAfter: true, macs: host.wakeMacs, lastIP: host.address,
|
host: host, connectsAfter: true, macs: host.wakeMacs, lastIP: host.address,
|
||||||
isOnline: { discovery.advertises(host) }, onOnline: go)
|
isOnline: { discovery.advertises(host) }, onOnline: go)
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
go()
|
go()
|
||||||
}
|
}
|
||||||
@@ -437,10 +512,12 @@ struct ContentView: View {
|
|||||||
|
|
||||||
/// The actual dial — reached directly when the host is awake, or from the waker once a woken
|
/// The actual dial — reached directly when the host is awake, or from the waker once a woken
|
||||||
/// host is back online. `prepareWake` still runs here to LEARN/refresh the MAC now that the host
|
/// host is back online. `prepareWake` still runs here to LEARN/refresh the MAC now that the host
|
||||||
/// is advertising (and is a harmless no-op otherwise).
|
/// is advertising (and is a harmless no-op otherwise). `onUnreachable` hands a plain connect
|
||||||
|
/// failure back to the caller (the wake-wait fallback) instead of the error alert.
|
||||||
private func startSessionDirect(
|
private func startSessionDirect(
|
||||||
_ host: StoredHost, launchID: String? = nil,
|
_ host: StoredHost, launchID: String? = nil,
|
||||||
allowTofu: Bool, requestAccess: Bool = false, approvalReq: ApprovalRequest? = nil
|
allowTofu: Bool, requestAccess: Bool = false, approvalReq: ApprovalRequest? = nil,
|
||||||
|
onUnreachable: (@MainActor () -> Void)? = nil
|
||||||
) {
|
) {
|
||||||
prepareWake(for: host)
|
prepareWake(for: host)
|
||||||
// The delegated-approval wait prompt only makes sense once we're actually dialing — set it
|
// The delegated-approval wait prompt only makes sense once we're actually dialing — set it
|
||||||
@@ -461,7 +538,8 @@ struct ContentView: View {
|
|||||||
preferredCodec: preferredCodecByte,
|
preferredCodec: preferredCodecByte,
|
||||||
launchID: launchID,
|
launchID: launchID,
|
||||||
allowTofu: allowTofu,
|
allowTofu: allowTofu,
|
||||||
requestAccess: requestAccess)
|
requestAccess: requestAccess,
|
||||||
|
onUnreachable: onUnreachable)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Learn-while-awake, wake-while-asleep — run just before every connect:
|
/// Learn-while-awake, wake-while-asleep — run just before every connect:
|
||||||
@@ -641,23 +719,62 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
/// Drives the hosting window in/out of native fullscreen from SwiftUI state. Mounted invisibly in
|
/// Drives the hosting window in/out of native fullscreen from SwiftUI state, and mirrors the
|
||||||
/// the view tree; on each `active` change it captures the window and toggles fullscreen only when
|
/// window's ACTUAL fullscreen state back into `isFullscreen` (the user can also toggle it with the
|
||||||
/// the current state differs (so it never fights a toggle already in flight, and never touches a
|
/// green button / ⌃⌘F — ContentView keys the session view's safe-area handling off the real state,
|
||||||
/// window the user fullscreened manually unless `active` says otherwise).
|
/// not the setting). Mounted invisibly in the view tree; on each `active` change it captures the
|
||||||
|
/// window and toggles fullscreen only when the current state differs (so it never fights a toggle
|
||||||
|
/// already in flight, and never touches a window the user fullscreened manually unless `active`
|
||||||
|
/// says otherwise).
|
||||||
private struct FullscreenController: NSViewRepresentable {
|
private struct FullscreenController: NSViewRepresentable {
|
||||||
let active: Bool
|
let active: Bool
|
||||||
|
@Binding var isFullscreen: Bool
|
||||||
|
|
||||||
|
/// Holds the window's fullscreen-transition observers so they're rebound on a window change
|
||||||
|
/// and removed on dismantle.
|
||||||
|
final class Coordinator {
|
||||||
|
var observers: [NSObjectProtocol] = []
|
||||||
|
weak var observedWindow: NSWindow?
|
||||||
|
deinit { observers.forEach(NotificationCenter.default.removeObserver(_:)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeCoordinator() -> Coordinator { Coordinator() }
|
||||||
|
|
||||||
func makeNSView(context: Context) -> NSView { NSView() }
|
func makeNSView(context: Context) -> NSView { NSView() }
|
||||||
|
|
||||||
func updateNSView(_ view: NSView, context: Context) {
|
func updateNSView(_ view: NSView, context: Context) {
|
||||||
let want = active
|
let want = active
|
||||||
|
let isFullscreen = $isFullscreen
|
||||||
|
let coordinator = context.coordinator
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
guard let window = view.window else { return }
|
guard let window = view.window else { return }
|
||||||
|
observeTransitions(of: window, coordinator: coordinator)
|
||||||
let isFull = window.styleMask.contains(.fullScreen)
|
let isFull = window.styleMask.contains(.fullScreen)
|
||||||
|
if isFullscreen.wrappedValue != isFull { isFullscreen.wrappedValue = isFull }
|
||||||
if want != isFull { window.toggleFullScreen(nil) }
|
if want != isFull { window.toggleFullScreen(nil) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `willEnter` (not did) so the video goes edge-to-edge while the title bar is already
|
||||||
|
/// animating away; `didExit` so the top inset returns only once the title bar is back —
|
||||||
|
/// no black gap in either direction.
|
||||||
|
private func observeTransitions(of window: NSWindow, coordinator: Coordinator) {
|
||||||
|
guard coordinator.observedWindow !== window else { return }
|
||||||
|
coordinator.observers.forEach(NotificationCenter.default.removeObserver(_:))
|
||||||
|
coordinator.observers.removeAll()
|
||||||
|
coordinator.observedWindow = window
|
||||||
|
let isFullscreen = $isFullscreen
|
||||||
|
for (name, value) in [
|
||||||
|
(NSWindow.willEnterFullScreenNotification, true),
|
||||||
|
(NSWindow.didExitFullScreenNotification, false),
|
||||||
|
] {
|
||||||
|
coordinator.observers.append(NotificationCenter.default.addObserver(
|
||||||
|
forName: name, object: window, queue: .main
|
||||||
|
) { _ in
|
||||||
|
isFullscreen.wrappedValue = value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
|||||||
@@ -96,6 +96,14 @@ struct GamepadHomeView: View {
|
|||||||
.background { GamepadScreenBackground() }
|
.background { GamepadScreenBackground() }
|
||||||
.onAppear { discovery.start() }
|
.onAppear { discovery.start() }
|
||||||
.onDisappear { discovery.stop() }
|
.onDisappear { discovery.stop() }
|
||||||
|
// Reachability sweep (mDNS-independent) so routed/VPN hosts that never advertise still show
|
||||||
|
// Online — the console mirror of HomeView's `.task`; cancelled on disappear.
|
||||||
|
.task {
|
||||||
|
while !Task.isCancelled {
|
||||||
|
await store.refreshReachability(discovery: discovery)
|
||||||
|
try? await Task.sleep(for: .seconds(10))
|
||||||
|
}
|
||||||
|
}
|
||||||
// The settings / add-host screens take over the controller (the carousel's `isActive`
|
// The settings / add-host screens take over the controller (the carousel's `isActive`
|
||||||
// gate above). iOS presents them full screen — the immersive console feel; macOS has no
|
// gate above). iOS presents them full screen — the immersive console feel; macOS has no
|
||||||
// fullScreenCover, so they become generously sized sheets over the dimmed launcher.
|
// fullScreenCover, so they become generously sized sheets over the dimmed launcher.
|
||||||
@@ -218,13 +226,16 @@ struct GamepadHomeView: View {
|
|||||||
id: .saved(host.id),
|
id: .saved(host.id),
|
||||||
title: host.displayName,
|
title: host.displayName,
|
||||||
subtitle: "\(host.address):\(String(host.port))",
|
subtitle: "\(host.address):\(String(host.port))",
|
||||||
isOnline: discovery.advertises(host),
|
// Online = advertising on mDNS OR proven reachable by the probe (a routed/VPN host
|
||||||
|
// never advertises); the wake item is offered only when neither holds.
|
||||||
|
isOnline: discovery.advertises(host) || store.probedOnline.contains(host.id),
|
||||||
isPaired: host.pinnedSHA256 != nil,
|
isPaired: host.pinnedSHA256 != nil,
|
||||||
isConnecting: model.phase == .connecting && model.activeHost?.id == host.id,
|
isConnecting: model.phase == .connecting && model.activeHost?.id == host.id,
|
||||||
filled: true,
|
filled: true,
|
||||||
hasLibrary: true,
|
hasLibrary: true,
|
||||||
canWake: PunktfunkConnection.wakeOnLANAvailable
|
canWake: PunktfunkConnection.wakeOnLANAvailable
|
||||||
&& !discovery.advertises(host) && !host.wakeMacs.isEmpty,
|
&& !discovery.advertises(host) && !store.probedOnline.contains(host.id)
|
||||||
|
&& !host.wakeMacs.isEmpty,
|
||||||
activate: { connect(host) })
|
activate: { connect(host) })
|
||||||
}
|
}
|
||||||
let discovered = discovery.unsaved(among: store.hosts).map { d in
|
let discovered = discovery.unsaved(among: store.hosts).map { d in
|
||||||
|
|||||||
@@ -76,6 +76,17 @@ struct HomeView: View {
|
|||||||
// session. The home appears/disappears as the stream swaps in and out.
|
// session. The home appears/disappears as the stream swaps in and out.
|
||||||
.onAppear { discovery.start() }
|
.onAppear { discovery.start() }
|
||||||
.onDisappear { discovery.stop() }
|
.onDisappear { discovery.stop() }
|
||||||
|
// Reachability sweep while the grid is up: a saved host reached only over a routed
|
||||||
|
// network (Tailscale/VPN) never advertises on mDNS, so `advertises` can't see it. Probe
|
||||||
|
// every non-advertising saved host ~every 10 s and publish the reachable set for the
|
||||||
|
// pips (`isOnline` above OR's it in). The `.task` is cancelled on disappear, matching
|
||||||
|
// `discovery.stop()`.
|
||||||
|
.task {
|
||||||
|
while !Task.isCancelled {
|
||||||
|
await store.refreshReachability(discovery: discovery)
|
||||||
|
try? await Task.sleep(for: .seconds(10))
|
||||||
|
}
|
||||||
|
}
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
// Pushed routes — the Settings-app navigation feel (push animation, Menu
|
// Pushed routes — the Settings-app navigation feel (push animation, Menu
|
||||||
// pops) instead of modal overlays.
|
// pops) instead of modal overlays.
|
||||||
@@ -157,7 +168,7 @@ struct HomeView: View {
|
|||||||
let onBrowseLibrary: (() -> Void)? = libraryEnabled ? { libraryTarget = host } : nil
|
let onBrowseLibrary: (() -> Void)? = libraryEnabled ? { libraryTarget = host } : nil
|
||||||
return HostCardView(
|
return HostCardView(
|
||||||
host: host,
|
host: host,
|
||||||
isOnline: discovery.advertises(host),
|
isOnline: discovery.advertises(host) || store.probedOnline.contains(host.id),
|
||||||
isConnecting: model.phase == .connecting && model.activeHost?.id == host.id,
|
isConnecting: model.phase == .connecting && model.activeHost?.id == host.id,
|
||||||
isMostRecent: host.id == mostRecentHostID,
|
isMostRecent: host.id == mostRecentHostID,
|
||||||
isBusy: model.isBusy,
|
isBusy: model.isBusy,
|
||||||
|
|||||||
@@ -43,8 +43,9 @@ struct PunktfunkClientApp: App {
|
|||||||
// form row labels; views that pick an explicit size/weight use `.geist(…)` directly.
|
// form row labels; views that pick an explicit size/weight use `.geist(…)` directly.
|
||||||
.font(.geist(17, relativeTo: .body))
|
.font(.geist(17, relativeTo: .body))
|
||||||
}
|
}
|
||||||
// The Stream menu (Disconnect ⌘D, Show/Hide Statistics ⌘⇧S) — a real menu bar on
|
// The Stream menu (Release Mouse ⌃⌥⇧Q, Disconnect ⌃⌥⇧D, Show/Hide Statistics ⌃⌥⇧S —
|
||||||
// macOS, hardware-keyboard shortcuts on iPad. tvOS has neither.
|
// the cross-client Ctrl+Alt+Shift set) — a real menu bar on macOS, hardware-keyboard
|
||||||
|
// shortcuts on iPad. tvOS has neither.
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
.commands { StreamCommands() }
|
.commands { StreamCommands() }
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -135,6 +135,10 @@ final class SessionModel: ObservableObject {
|
|||||||
/// successful connect streams directly (the approval IS the trust decision) — the caller pins
|
/// successful connect streams directly (the approval IS the trust decision) — the caller pins
|
||||||
/// the observed fingerprint as paired. `host.pinnedSHA256`, when set, pins the advertised cert
|
/// the observed fingerprint as paired. `host.pinnedSHA256`, when set, pins the advertised cert
|
||||||
/// for the wait; nil = trust-on-first-use.
|
/// for the wait; nil = trust-on-first-use.
|
||||||
|
/// `onUnreachable`, when set, replaces the "could not connect" alert for a plain connect
|
||||||
|
/// failure: the caller takes over recovery (the Wake-on-LAN wait for a host that stopped
|
||||||
|
/// advertising). It never fires for the delegated-approval path, whose failure text carries
|
||||||
|
/// its own instructions.
|
||||||
func connect(to host: StoredHost, width: UInt32, height: UInt32, hz: UInt32,
|
func connect(to host: StoredHost, width: UInt32, height: UInt32, hz: UInt32,
|
||||||
compositor: PunktfunkConnection.Compositor = .auto,
|
compositor: PunktfunkConnection.Compositor = .auto,
|
||||||
gamepad: PunktfunkConnection.GamepadType = .auto,
|
gamepad: PunktfunkConnection.GamepadType = .auto,
|
||||||
@@ -145,7 +149,8 @@ final class SessionModel: ObservableObject {
|
|||||||
launchID: String? = nil,
|
launchID: String? = nil,
|
||||||
allowTofu: Bool = false,
|
allowTofu: Bool = false,
|
||||||
autoTrust: Bool = false,
|
autoTrust: Bool = false,
|
||||||
requestAccess: Bool = false) {
|
requestAccess: Bool = false,
|
||||||
|
onUnreachable: (@MainActor () -> Void)? = nil) {
|
||||||
guard phase == .idle else { return }
|
guard phase == .idle else { return }
|
||||||
phase = .connecting
|
phase = .connecting
|
||||||
activeHost = host
|
activeHost = host
|
||||||
@@ -189,10 +194,13 @@ final class SessionModel: ObservableObject {
|
|||||||
if want444, canDecode444 {
|
if want444, canDecode444 {
|
||||||
videoCaps |= PunktfunkConnection.videoCap444
|
videoCaps |= PunktfunkConnection.videoCap444
|
||||||
}
|
}
|
||||||
// This client's VideoToolbox path decodes H.264 and HEVC (AV1 isn't wired — hosts don't
|
// This client's VideoToolbox path decodes H.264 and HEVC everywhere, and AV1 when
|
||||||
// emit it on the native path yet). The host resolves the emitted codec from these + the
|
// this device has an AV1 hardware decoder (M3-class Macs, A17 Pro-class iPhones —
|
||||||
// soft `preferredCodec`; `resolvedCodec` reflects what it chose.
|
// VideoToolbox has no software AV1 decoder, so advertising it elsewhere would invite
|
||||||
let videoCodecs = PunktfunkConnection.codecH264 | PunktfunkConnection.codecHEVC
|
// a stream that can't decode; see AV1.swift). The host resolves the emitted codec
|
||||||
|
// from these + the soft `preferredCodec`; `resolvedCodec` reflects what it chose.
|
||||||
|
var videoCodecs = PunktfunkConnection.codecH264 | PunktfunkConnection.codecHEVC
|
||||||
|
if AV1.hardwareDecodeSupported { videoCodecs |= PunktfunkConnection.codecAV1 }
|
||||||
let result = Result { try PunktfunkConnection(
|
let result = Result { try PunktfunkConnection(
|
||||||
host: host.address, port: host.port,
|
host: host.address, port: host.port,
|
||||||
width: width, height: height, refreshHz: hz,
|
width: width, height: height, refreshHz: hz,
|
||||||
@@ -241,7 +249,11 @@ final class SessionModel: ObservableObject {
|
|||||||
case .failure:
|
case .failure:
|
||||||
self.phase = .idle
|
self.phase = .idle
|
||||||
self.activeHost = nil
|
self.activeHost = nil
|
||||||
if requestAccess {
|
if let onUnreachable, !requestAccess {
|
||||||
|
// The caller owns recovery (wake-and-retry) — no error alert here; its
|
||||||
|
// own overlay explains what's happening.
|
||||||
|
onUnreachable()
|
||||||
|
} else if requestAccess {
|
||||||
// The delegated-approval connect ended without being admitted: the
|
// The delegated-approval connect ended without being admitted: the
|
||||||
// operator didn't approve it before the host's park window elapsed (or
|
// operator didn't approve it before the host's park window elapsed (or
|
||||||
// the host was unreachable).
|
// the host was unreachable).
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
// The app's "Stream" menu (macOS menu bar + iPad hardware-keyboard shortcuts). These live at
|
// The app's "Stream" menu (macOS menu bar + iPad hardware-keyboard shortcuts). These live at
|
||||||
// the Scene level so they keep working when the HUD overlay is hidden — in particular ⌘D
|
// the Scene level so they keep working when the HUD overlay is hidden. The shortcuts are the
|
||||||
// disconnect, which used to be reachable only via the HUD's button. The toggle just flips the
|
// CROSS-CLIENT set every punktfunk client reserves — Ctrl+Alt+Shift+Q (release the captured
|
||||||
|
// mouse) / +D (disconnect) / +S (stats) — and the menu is their discoverable surface on macOS
|
||||||
|
// (the Linux client has its GTK Shortcuts window, Windows its start-of-stream banner). While
|
||||||
|
// input is CAPTURED these key equivalents never reach the menu (the stream view swallows
|
||||||
|
// keys); InputCapture's monitor detects the same combos there and performs the same actions —
|
||||||
|
// the menu covers the released state and discoverability. The stats toggle just flips the
|
||||||
// shared `hudEnabled` setting; ContentView reads the same @AppStorage and reacts.
|
// shared `hudEnabled` setting; ContentView reads the same @AppStorage and reacts.
|
||||||
//
|
//
|
||||||
// tvOS has no menu bar / hardware-keyboard command surface (disconnect there is the Siri
|
// tvOS has no menu bar / hardware-keyboard command surface (disconnect there is the Siri
|
||||||
@@ -38,10 +43,19 @@ struct StreamCommands: Commands {
|
|||||||
Button(hudEnabled ? "Hide Statistics" : "Show Statistics") {
|
Button(hudEnabled ? "Hide Statistics" : "Show Statistics") {
|
||||||
hudEnabled.toggle()
|
hudEnabled.toggle()
|
||||||
}
|
}
|
||||||
.keyboardShortcut("s", modifiers: [.command, .shift])
|
.keyboardShortcut("s", modifiers: [.control, .option, .shift])
|
||||||
|
// Reaches the key window's stream view via NotificationCenter — capture is view
|
||||||
|
// state the Scene can't touch directly. (Captured, the combo is handled by
|
||||||
|
// InputCapture's monitor before menus see it; this item is the released-state
|
||||||
|
// path and the shortcut's menu-bar documentation.)
|
||||||
|
Button("Release Mouse") {
|
||||||
|
NotificationCenter.default.post(name: .punktfunkReleaseCapture, object: nil)
|
||||||
|
}
|
||||||
|
.keyboardShortcut("q", modifiers: [.control, .option, .shift])
|
||||||
|
.disabled(session?.isStreaming != true)
|
||||||
Divider()
|
Divider()
|
||||||
Button("Disconnect") { session?.disconnect() }
|
Button("Disconnect") { session?.disconnect() }
|
||||||
.keyboardShortcut("d", modifiers: .command)
|
.keyboardShortcut("d", modifiers: [.control, .option, .shift])
|
||||||
.disabled(session?.isStreaming != true)
|
.disabled(session?.isStreaming != true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,10 +64,11 @@ struct StreamHUDView: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
// While captured the cursor is hidden+frozen, so the button is keyboard-only
|
// While captured the cursor is hidden+frozen, so the button is keyboard-only
|
||||||
// (⌘⎋ or Cmd+Tab release the cursor; released, it's clickable again).
|
// (⌃⌥⇧Q — the cross-client Ctrl+Alt+Shift+Q — or ⌘⎋/Cmd+Tab release the cursor;
|
||||||
|
// released, it's clickable again).
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
Text(model.mouseCaptured
|
Text(model.mouseCaptured
|
||||||
? "⌘⎋ releases the mouse"
|
? "⌃⌥⇧Q releases the mouse"
|
||||||
: "Click the stream to capture input")
|
: "Click the stream to capture input")
|
||||||
.font(.geist(11, relativeTo: .caption2))
|
.font(.geist(11, relativeTo: .caption2))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
@@ -87,10 +88,16 @@ struct StreamHUDView: View {
|
|||||||
.font(.geist(12, relativeTo: .caption))
|
.font(.geist(12, relativeTo: .caption))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
#else
|
#else
|
||||||
// ⌘D lives on the app's Stream menu (so it still works when the HUD is hidden);
|
// ⌃⌥⇧D lives on the app's Stream menu (so it still works when the HUD is hidden)
|
||||||
// this button is the in-overlay, click-to-disconnect affordance.
|
// and in InputCapture's monitor while captured; this button is the in-overlay,
|
||||||
Button("Disconnect (⌘D)") { model.disconnect() }
|
// click-to-disconnect affordance.
|
||||||
|
#if os(macOS)
|
||||||
|
Button("Disconnect (⌃⌥⇧D)") { model.disconnect() }
|
||||||
.font(.geist(12, relativeTo: .caption))
|
.font(.geist(12, relativeTo: .caption))
|
||||||
|
#else
|
||||||
|
Button("Disconnect") { model.disconnect() }
|
||||||
|
.font(.geist(12, relativeTo: .caption))
|
||||||
|
#endif
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
.padding(10)
|
.padding(10)
|
||||||
|
|||||||
@@ -38,13 +38,21 @@ enum SettingsOptions {
|
|||||||
HUDPlacement.allCases.map { ($0.label, $0.rawValue) }
|
HUDPlacement.allCases.map { ($0.label, $0.rawValue) }
|
||||||
|
|
||||||
/// Video-codec preference (`DefaultsKey.codec`) — a soft preference the host falls back from.
|
/// Video-codec preference (`DefaultsKey.codec`) — a soft preference the host falls back from.
|
||||||
/// No AV1: this client's VideoToolbox path decodes H.264/HEVC only (hosts don't emit AV1 on
|
/// AV1 appears only on devices with an AV1 hardware decoder (the same
|
||||||
/// the native path yet).
|
/// `AV1.hardwareDecodeSupported` gate SessionModel advertises by) — elsewhere it would be a
|
||||||
static let codecs: [(label: String, tag: String)] = [
|
/// dead setting the host could never honor. Ordered by the host's resolve precedence
|
||||||
|
/// (HEVC > AV1 > H.264).
|
||||||
|
static let codecs: [(label: String, tag: String)] = {
|
||||||
|
var options: [(label: String, tag: String)] = [
|
||||||
("Automatic", "auto"),
|
("Automatic", "auto"),
|
||||||
("HEVC (H.265)", "hevc"),
|
("HEVC (H.265)", "hevc"),
|
||||||
("H.264 (AVC)", "h264"),
|
("H.264 (AVC)", "h264"),
|
||||||
]
|
]
|
||||||
|
if AV1.hardwareDecodeSupported {
|
||||||
|
options.insert(("AV1", "av1"), at: 2)
|
||||||
|
}
|
||||||
|
return options
|
||||||
|
}()
|
||||||
|
|
||||||
// MARK: - Bitrate
|
// MARK: - Bitrate
|
||||||
|
|
||||||
|
|||||||
@@ -223,9 +223,7 @@ extension SettingsView {
|
|||||||
} header: {
|
} header: {
|
||||||
Text("Audio")
|
Text("Audio")
|
||||||
} footer: {
|
} footer: {
|
||||||
Text("Host audio plays through the speaker; the microphone feeds the "
|
Text(Self.audioFooter)
|
||||||
+ "host's virtual mic. System default follows macOS device changes. "
|
|
||||||
+ "Applies from the next session.")
|
|
||||||
.font(.geist(12, relativeTo: .caption))
|
.font(.geist(12, relativeTo: .caption))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
@@ -257,17 +255,14 @@ extension SettingsView {
|
|||||||
/// `+` chain (with a ternary) inside the ViewBuilder — that single expression blew Swift's
|
/// `+` chain (with a ternary) inside the ViewBuilder — that single expression blew Swift's
|
||||||
/// type-checker budget and was what actually broke the iOS archive.
|
/// type-checker budget and was what actually broke the iOS archive.
|
||||||
private var pointerFooterText: String {
|
private var pointerFooterText: String {
|
||||||
var text = "Trackpad: your finger nudges the host cursor like a laptop touchpad — tap to "
|
var text = "Trackpad: your finger moves the host cursor like a laptop touchpad — tap "
|
||||||
text += "click, two-finger tap for a right click, two-finger drag to scroll, "
|
text += "to click, two-finger tap to right-click, two-finger drag to scroll, "
|
||||||
text += "tap-then-drag to hold the button, three-finger tap for the stats overlay. "
|
text += "tap-and-drag to hold, three-finger tap for the stats overlay. Direct pointer: "
|
||||||
text += "Direct pointer: the cursor jumps to your finger. Touch passthrough: real "
|
text += "the cursor jumps to your finger. Touch passthrough: real multi-touch reaches "
|
||||||
text += "multi-touch reaches the host, for apps that understand touch. Applies from "
|
text += "the host. Applies from the next touch."
|
||||||
text += "the next touch."
|
|
||||||
if UIDevice.current.userInterfaceIdiom == .pad {
|
if UIDevice.current.userInterfaceIdiom == .pad {
|
||||||
text += " Pointer capture locks a hardware mouse/trackpad for relative movement "
|
text += " Pointer capture locks a hardware mouse for relative mouse-look; off sends "
|
||||||
text += "(mouse-look); off keeps the pointer free and sends absolute positions. "
|
text += "absolute positions. Needs the stream full-screen and frontmost."
|
||||||
text += "The lock needs the stream full-screen and frontmost, and falls back "
|
|
||||||
text += "automatically (Stage Manager, Slide Over)."
|
|
||||||
}
|
}
|
||||||
return text
|
return text
|
||||||
}
|
}
|
||||||
@@ -306,6 +301,42 @@ extension SettingsView {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Non-tvOS: the Apple TV drives a fixed HDMI mode, so there's no adaptive refresh to allow.
|
||||||
|
@ViewBuilder var vrrSection: some View {
|
||||||
|
#if !os(tvOS)
|
||||||
|
Section {
|
||||||
|
Toggle("Allow VRR", isOn: $allowVRR)
|
||||||
|
} header: {
|
||||||
|
Text("Variable refresh rate")
|
||||||
|
} footer: {
|
||||||
|
Text("Let a ProMotion or adaptive-sync display vary its refresh rate to match the "
|
||||||
|
+ "stream — smoother motion without tearing. No effect on fixed-refresh displays. "
|
||||||
|
+ "Applies from the next session.")
|
||||||
|
.font(.geist(12, relativeTo: .caption))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
// macOS-only: iOS/tvOS layers always present on the display's vsync, so the choice only
|
||||||
|
// exists on the Mac (where the layer's own sync must stay off — see MetalVideoPresenter).
|
||||||
|
@ViewBuilder var vsyncSection: some View {
|
||||||
|
#if os(macOS)
|
||||||
|
Section {
|
||||||
|
Toggle("V-Sync", isOn: $vsync)
|
||||||
|
} header: {
|
||||||
|
Text("Presentation")
|
||||||
|
} footer: {
|
||||||
|
Text("Off (default): each frame is shown as soon as it's ready — lowest latency, "
|
||||||
|
+ "but frame timing can look uneven and fullscreen may tear. On: frames flip "
|
||||||
|
+ "in step with the display's refresh — evenly paced, up to one refresh of "
|
||||||
|
+ "added latency. Applies from the next session.")
|
||||||
|
.font(.geist(12, relativeTo: .caption))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
// Stage-2 (Metal/VTDecompressionSession) is the default and only user-visible presenter — it
|
// Stage-2 (Metal/VTDecompressionSession) is the default and only user-visible presenter — it
|
||||||
// recovers from a wedged decoder, where stage-1's AVSampleBufferDisplayLayer freezes hard on a
|
// recovers from a wedged decoder, where stage-1's AVSampleBufferDisplayLayer freezes hard on a
|
||||||
// lost HEVC reference. Stage-1 is kept reachable as a DEBUG-only override for diagnostics, like
|
// lost HEVC reference. Stage-1 is kept reachable as a DEBUG-only override for diagnostics, like
|
||||||
@@ -320,12 +351,10 @@ extension SettingsView {
|
|||||||
} header: {
|
} header: {
|
||||||
Text("Video presenter · debug")
|
Text("Video presenter · debug")
|
||||||
} footer: {
|
} footer: {
|
||||||
Text("Stage 2 (default) decodes explicitly and presents through Metal with a display "
|
Text("Stage 2 (default): explicit decode + Metal present — full HUD latency "
|
||||||
+ "link — it gives the HUD the end-to-end (capture→on-glass) headline with the "
|
+ "breakdown and self-recovery from decode stalls. Stage 1: compressed video "
|
||||||
+ "host+network/decode/display stage equation and self-recovers from decode "
|
+ "straight to the system layer; freezes on a lost HEVC reference, so it's a "
|
||||||
+ "stalls. Stage 1 feeds compressed video straight to the system display layer; "
|
+ "debug fallback only. Applies from the next session.")
|
||||||
+ "it freezes on a lost HEVC reference frame, so it's a debug fallback only. "
|
|
||||||
+ "Applies from the next session.")
|
|
||||||
.font(.geist(12, relativeTo: .caption))
|
.font(.geist(12, relativeTo: .caption))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
@@ -344,12 +373,11 @@ extension SettingsView {
|
|||||||
} header: {
|
} header: {
|
||||||
Text("Video quality")
|
Text("Video quality")
|
||||||
} footer: {
|
} footer: {
|
||||||
Text("Codec is a preference — the host falls back if it can't encode the one you pick "
|
Text("Codec is a preference; the host falls back if it can't encode your choice. "
|
||||||
+ "(and 10-bit/4:4:4 are HEVC-only). HDR requests a 10-bit BT.2020 PQ (HDR10) stream — "
|
+ "HDR (HDR10) and full chroma (4:4:4) are HEVC-only, and each engages only when "
|
||||||
+ "it only engages when the host is sending HDR content AND this display supports HDR. "
|
+ "both this device and the host support it — otherwise the stream stays 8-bit "
|
||||||
+ "4:4:4 requests full chroma (sharper text/UI, more bandwidth) — it only engages when "
|
+ "4:2:0 SDR. 4:4:4 sharpens text and UI for extra bandwidth. Applies from the "
|
||||||
+ "this device can hardware-decode it AND the host opted in. Otherwise the stream stays "
|
+ "next session.")
|
||||||
+ "8-bit 4:2:0 SDR. Applies from the next session.")
|
|
||||||
.font(.geist(12, relativeTo: .caption))
|
.font(.geist(12, relativeTo: .caption))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
@@ -380,9 +408,8 @@ extension SettingsView {
|
|||||||
Text("Experimental")
|
Text("Experimental")
|
||||||
} footer: {
|
} footer: {
|
||||||
Text("Adds a “Browse Library…” action to each host that lists its games "
|
Text("Adds a “Browse Library…” action to each host that lists its games "
|
||||||
+ "(Steam + custom) via the host's management API; tap a title to launch it. "
|
+ "(Steam + custom); tap a title to launch it. Works once you've paired — no "
|
||||||
+ "Works once you've paired with the host — the library is authorized by this "
|
+ "extra host setup.")
|
||||||
+ "device's certificate, with no extra host setup.")
|
|
||||||
.font(.geist(12, relativeTo: .caption))
|
.font(.geist(12, relativeTo: .caption))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,32 +54,41 @@ extension SettingsView {
|
|||||||
// MARK: - Statistics
|
// MARK: - Statistics
|
||||||
|
|
||||||
static var statisticsFooter: String {
|
static var statisticsFooter: String {
|
||||||
let base = "The overlay shows resolution, frame rate, throughput and latency while "
|
let base = "Shows resolution, frame rate, throughput and latency in the chosen "
|
||||||
+ "streaming, in the chosen corner."
|
+ "corner while streaming."
|
||||||
#if os(macOS) || os(iOS)
|
#if os(macOS) || os(iOS)
|
||||||
return base + " Toggle it any time with ⌘⇧S."
|
return base + " Toggle it any time with ⌃⌥⇧S."
|
||||||
#else
|
#else
|
||||||
return base
|
return base
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Audio
|
||||||
|
|
||||||
|
static var audioFooter: String {
|
||||||
|
#if os(macOS)
|
||||||
|
return "Host audio plays through the chosen speaker; your microphone feeds the host's "
|
||||||
|
+ "virtual mic. System default follows your Mac's device changes. Applies from the "
|
||||||
|
+ "next session."
|
||||||
|
#else
|
||||||
|
return "Host audio plays locally; your microphone feeds the host's virtual mic. "
|
||||||
|
+ "Applies from the next session."
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Controllers
|
// MARK: - Controllers
|
||||||
|
|
||||||
static let controllersFooter =
|
static let controllersFooter =
|
||||||
"One controller is forwarded to the host, as player 1 — Automatic picks the most "
|
"One controller is forwarded as player 1 — Automatic picks the most recently "
|
||||||
+ "recently connected one. The type is the virtual pad the host creates: Automatic "
|
+ "connected. Type is the virtual pad the host creates; Automatic matches your "
|
||||||
+ "matches the controller (a DualSense gets adaptive triggers, lightbar, touchpad "
|
+ "controller (a DualSense keeps adaptive triggers, lightbar, touchpad and motion). "
|
||||||
+ "and motion; a DualShock 4 the same minus adaptive triggers), and changes apply "
|
+ "Applies from the next session."
|
||||||
+ "from the next session. Two identical controllers may swap a manual selection "
|
|
||||||
+ "after reconnecting."
|
|
||||||
|
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
static let gamepadUIFooter =
|
static let gamepadUIFooter =
|
||||||
"When a controller is connected, the host list and game library switch to a "
|
"When a controller connects, the host list and library switch to a controller-"
|
||||||
+ "controller-friendly layout — larger focus targets, controller-navigable settings, "
|
+ "friendly layout — larger focus targets and a swipeable cover browser. Turn this "
|
||||||
+ "and a swipeable cover browser for the library. Turn this off to always use the "
|
+ "off to always use the standard layout."
|
||||||
+ "standard layout. (The system may still move basic focus with a controller "
|
|
||||||
+ "connected even with this off — that's outside the app's control.)"
|
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
/// "Use controller" choices for this view's manager (see `SettingsOptions.controllerOptions`).
|
/// "Use controller" choices for this view's manager (see `SettingsOptions.controllerOptions`).
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ struct SettingsView: View {
|
|||||||
@AppStorage(DefaultsKey.gamepadType) var gamepadType = 0
|
@AppStorage(DefaultsKey.gamepadType) var gamepadType = 0
|
||||||
@AppStorage(DefaultsKey.bitrateKbps) var bitrateKbps = 0
|
@AppStorage(DefaultsKey.bitrateKbps) var bitrateKbps = 0
|
||||||
@AppStorage(DefaultsKey.presenter) var presenter = "stage2"
|
@AppStorage(DefaultsKey.presenter) var presenter = "stage2"
|
||||||
|
#if os(macOS)
|
||||||
|
@AppStorage(DefaultsKey.vsync) var vsync = false
|
||||||
|
#endif
|
||||||
|
#if !os(tvOS)
|
||||||
|
@AppStorage(DefaultsKey.allowVRR) var allowVRR = true
|
||||||
|
#endif
|
||||||
@AppStorage(DefaultsKey.hdrEnabled) var hdrEnabled = true
|
@AppStorage(DefaultsKey.hdrEnabled) var hdrEnabled = true
|
||||||
@AppStorage(DefaultsKey.enable444) var enable444 = true
|
@AppStorage(DefaultsKey.enable444) var enable444 = true
|
||||||
@AppStorage(DefaultsKey.libraryEnabled) var libraryEnabled = false
|
@AppStorage(DefaultsKey.libraryEnabled) var libraryEnabled = false
|
||||||
@@ -106,6 +112,8 @@ struct SettingsView: View {
|
|||||||
Form {
|
Form {
|
||||||
presenterSection
|
presenterSection
|
||||||
hdrSection
|
hdrSection
|
||||||
|
vrrSection
|
||||||
|
vsyncSection
|
||||||
windowSection
|
windowSection
|
||||||
statisticsSection
|
statisticsSection
|
||||||
}
|
}
|
||||||
@@ -234,6 +242,7 @@ struct SettingsView: View {
|
|||||||
Form {
|
Form {
|
||||||
presenterSection
|
presenterSection
|
||||||
hdrSection
|
hdrSection
|
||||||
|
vrrSection
|
||||||
statisticsSection
|
statisticsSection
|
||||||
}
|
}
|
||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
|
|||||||
@@ -81,6 +81,11 @@ final class HostStore: ObservableObject {
|
|||||||
didSet { persist() }
|
didSet { persist() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Saved hosts proven reachable by the periodic QUIC probe (by id) — the mDNS-independent
|
||||||
|
/// counterpart to discovery presence, OR'd into the "online" pip so a routed/VPN host that
|
||||||
|
/// never advertises still reads Online. Not persisted (it's live reachability, not config).
|
||||||
|
@Published var probedOnline: Set<StoredHost.ID> = []
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
if let data = UserDefaults.standard.data(forKey: Self.key),
|
if let data = UserDefaults.standard.data(forKey: Self.key),
|
||||||
let decoded = try? JSONDecoder().decode([StoredHost].self, from: data) {
|
let decoded = try? JSONDecoder().decode([StoredHost].self, from: data) {
|
||||||
@@ -110,6 +115,23 @@ final class HostStore: ObservableObject {
|
|||||||
hosts[i].lastConnected = Date()
|
hosts[i].lastConnected = Date()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One reachability sweep, driving `probedOnline`: probe every saved host NOT currently
|
||||||
|
/// advertising on `discovery` (mDNS already answers for those) off the main actor via a bounded,
|
||||||
|
/// trust-agnostic QUIC handshake, then publish the reachable set. This is the mDNS-independent
|
||||||
|
/// half of presence — a host reached over a routed network (Tailscale/VPN) never advertises but
|
||||||
|
/// answers here. Call in a loop from a home view's `.task` (cancelled on disappear).
|
||||||
|
func refreshReachability(discovery: HostDiscovery) async {
|
||||||
|
let targets = hosts.filter { !discovery.advertises($0) }
|
||||||
|
var online: Set<StoredHost.ID> = []
|
||||||
|
for host in targets {
|
||||||
|
let reachable = await Task.detached(priority: .utility) {
|
||||||
|
PunktfunkConnection.probe(host: host.address, port: host.port)
|
||||||
|
}.value
|
||||||
|
if reachable { online.insert(host.id) }
|
||||||
|
}
|
||||||
|
probedOnline = online
|
||||||
|
}
|
||||||
|
|
||||||
func pin(_ hostID: UUID, fingerprint: Data) {
|
func pin(_ hostID: UUID, fingerprint: Data) {
|
||||||
guard let i = hosts.firstIndex(where: { $0.id == hostID }) else { return }
|
guard let i = hosts.firstIndex(where: { $0.id == hostID }) else { return }
|
||||||
hosts[i].pinnedSHA256 = fingerprint
|
hosts[i].pinnedSHA256 = fingerprint
|
||||||
|
|||||||
@@ -127,7 +127,16 @@ public final class HostDiscovery: ObservableObject {
|
|||||||
.map { $0.trimmingCharacters(in: .whitespaces) }
|
.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||||
.filter { !$0.isEmpty }
|
.filter { !$0.isEmpty }
|
||||||
}
|
}
|
||||||
let conn = NWConnection(to: result.endpoint, using: .udp)
|
// Resolve over IPv4 only: Network.framework prefers IPv6 (RFC 6724), and the host's OS
|
||||||
|
// mDNS responder often answers AAAA for its hostname even though the punktfunk host stack
|
||||||
|
// (control QUIC + data UDP) binds IPv4 sockets exclusively — a v6-resolved address would
|
||||||
|
// produce a host card whose connect always fails in the Rust core (`host:port` parse).
|
||||||
|
// Same policy as the Android/desktop clients; lift when the stack speaks IPv6.
|
||||||
|
let params = NWParameters.udp
|
||||||
|
if let ip = params.defaultProtocolStack.internetProtocol as? NWProtocolIP.Options {
|
||||||
|
ip.version = .v4
|
||||||
|
}
|
||||||
|
let conn = NWConnection(to: result.endpoint, using: params)
|
||||||
connections[key] = conn
|
connections[key] = conn
|
||||||
conn.stateUpdateHandler = { state in
|
conn.stateUpdateHandler = { state in
|
||||||
MainActor.assumeIsolated { [weak self] in
|
MainActor.assumeIsolated { [weak self] in
|
||||||
|
|||||||
@@ -112,6 +112,16 @@ public extension PunktfunkConnection {
|
|||||||
}
|
}
|
||||||
return rc == statusOK
|
return rc == statusOK
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bounded, trust-agnostic QUIC-handshake reachability probe to `host:port` — mDNS-INDEPENDENT,
|
||||||
|
/// so a host reached over a routed network (Tailscale/VPN/another subnet), which never
|
||||||
|
/// advertises, still reports reachable. No pin/identity presented. The display-side companion
|
||||||
|
/// to the dial-first connect fix: lets saved-host "online" pips reflect real reachability.
|
||||||
|
/// Blocking (builds its own runtime) — call OFF the main thread.
|
||||||
|
static func probe(host: String, port: UInt16, timeoutMs: UInt32 = 1500) -> Bool {
|
||||||
|
let rc: Int32 = host.withCString { punktfunk_probe($0, port, timeoutMs) }
|
||||||
|
return rc == statusOK
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public final class PunktfunkConnection {
|
public final class PunktfunkConnection {
|
||||||
@@ -259,7 +269,8 @@ public final class PunktfunkConnection {
|
|||||||
/// `2` = HEVC (default / older host), `1` = H.264, `4` = AV1. Build the decoder from THIS. The
|
/// `2` = HEVC (default / older host), `1` = H.264, `4` = AV1. Build the decoder from THIS. The
|
||||||
/// resolved value honors the client's `preferredCodec` when the host could emit it.
|
/// resolved value honors the client's `preferredCodec` when the host could emit it.
|
||||||
public private(set) var resolvedCodec: UInt8 = 2 // PUNKTFUNK_CODEC_HEVC
|
public private(set) var resolvedCodec: UInt8 = 2 // PUNKTFUNK_CODEC_HEVC
|
||||||
/// The resolved codec as an `AnnexB.VideoCodec` (H.264 vs HEVC) — drives the NAL parsing.
|
/// The resolved codec as a `VideoCodec` (H.264 / HEVC / AV1) — drives the bitstream framing
|
||||||
|
/// (Annex-B NAL parsing vs the AV1 OBU repack).
|
||||||
public var videoCodec: VideoCodec { VideoCodec(wire: resolvedCodec) }
|
public var videoCodec: VideoCodec { VideoCodec(wire: resolvedCodec) }
|
||||||
|
|
||||||
/// Connect and start a session at the requested mode (the host creates a native virtual
|
/// Connect and start a session at the requested mode (the host creates a native virtual
|
||||||
|
|||||||
@@ -24,8 +24,9 @@
|
|||||||
//
|
//
|
||||||
// Forwarding is gated by `forwarding` (driven by StreamLayerView's capture state): the
|
// Forwarding is gated by `forwarding` (driven by StreamLayerView's capture state): the
|
||||||
// handlers stay attached for the whole session, but while the user has released capture
|
// handlers stay attached for the whole session, but while the user has released capture
|
||||||
// (⌘⎋, focus loss) nothing reaches the host and key events travel the responder chain
|
// (⌃⌥⇧Q — the cross-client Ctrl+Alt+Shift+Q — or ⌘⎋, focus loss) nothing reaches the host
|
||||||
// normally. Everything held is flushed host-side on each transition to released.
|
// and key events travel the responder chain normally. Everything held is flushed host-side
|
||||||
|
// on each transition to released.
|
||||||
//
|
//
|
||||||
// GCMouse.current/GCKeyboard.coalesced are process-global singletons with one handler
|
// GCMouse.current/GCKeyboard.coalesced are process-global singletons with one handler
|
||||||
// slot each: only one InputCapture can be live per process. `activeCapture` tracks
|
// slot each: only one InputCapture can be live per process. `activeCapture` tracks
|
||||||
@@ -108,6 +109,16 @@ public final class InputCapture {
|
|||||||
/// the absolute-vs-relative forwarding lives entirely in StreamLayerView. Main queue.
|
/// the absolute-vs-relative forwarding lives entirely in StreamLayerView. Main queue.
|
||||||
public var onToggleCursor: (() -> Void)?
|
public var onToggleCursor: (() -> Void)?
|
||||||
|
|
||||||
|
/// The cross-client combos (Windows/Linux parity: Ctrl+Alt+Shift+Q/D/S), fired from the macOS
|
||||||
|
/// keyDown monitor only WHILE FORWARDING — that's the state in which the app's menu (which
|
||||||
|
/// carries the same key equivalents for discoverability) can't see them, so the monitor is the
|
||||||
|
/// captured-state delivery path; released, the events pass through and the menu handles them.
|
||||||
|
/// ⌃⌥⇧Q releases the captured mouse/keyboard; ⌃⌥⇧D disconnects; ⌃⌥⇧S toggles the stats
|
||||||
|
/// overlay. Main queue.
|
||||||
|
public var onReleaseCapture: (() -> Void)?
|
||||||
|
public var onDisconnect: (() -> Void)?
|
||||||
|
public var onToggleStats: (() -> Void)?
|
||||||
|
|
||||||
/// Fired when a newer InputCapture takes the process-global GC handler slots (the
|
/// Fired when a newer InputCapture takes the process-global GC handler slots (the
|
||||||
/// singletons hold ONE handler each): the preempted owner must drop its capture
|
/// singletons hold ONE handler each): the preempted owner must drop its capture
|
||||||
/// state — its handlers are gone, so it would otherwise sit "captured" with dead
|
/// state — its handlers are gone, so it would otherwise sit "captured" with dead
|
||||||
@@ -215,6 +226,32 @@ public final class InputCapture {
|
|||||||
self.onToggleCursor?()
|
self.onToggleCursor?()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
// The cross-client combos (Ctrl+Alt+Shift+Q/D/S — the same set every other
|
||||||
|
// punktfunk client reserves), intercepted only while forwarding so the host never
|
||||||
|
// sees the letter (the ⌃⌥⇧ modifiers were already forwarded as they went down;
|
||||||
|
// they're flushed by the release path / released by the user as usual). The letter
|
||||||
|
// is latched (suppressedVK) so its keyUp doesn't leak to the host either. While
|
||||||
|
// NOT forwarding the events pass through and the menu's identical key equivalents
|
||||||
|
// handle them (with the standard menu-flash feedback). keyCodes are kVK_ANSI_* —
|
||||||
|
// physical positions, layout-independent.
|
||||||
|
if self.forwarding, flags == [.control, .option, .shift] {
|
||||||
|
switch event.keyCode {
|
||||||
|
case 12 /* Q */:
|
||||||
|
self.suppressedVK = 0x51
|
||||||
|
self.onReleaseCapture?()
|
||||||
|
return nil
|
||||||
|
case 2 /* D */:
|
||||||
|
self.suppressedVK = 0x44
|
||||||
|
self.onDisconnect?()
|
||||||
|
return nil
|
||||||
|
case 1 /* S */:
|
||||||
|
self.suppressedVK = 0x53
|
||||||
|
self.onToggleStats?()
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
return event
|
return event
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -31,6 +31,17 @@ public enum DefaultsKey {
|
|||||||
/// is higher up), so we fold to mono ourselves. Only meaningful for multi-channel devices.
|
/// is higher up), so we fold to mono ourselves. Only meaningful for multi-channel devices.
|
||||||
public static let micChannel = "punktfunk.micChannel"
|
public static let micChannel = "punktfunk.micChannel"
|
||||||
public static let presenter = "punktfunk.presenter"
|
public static let presenter = "punktfunk.presenter"
|
||||||
|
/// macOS: V-Sync the stream's presents — each decoded frame flips on the next display vsync
|
||||||
|
/// (evenly paced, no tearing under direct scanout) instead of as soon as the GPU finishes
|
||||||
|
/// (lowest latency — the default, OFF). Resolved once per session;
|
||||||
|
/// PUNKTFUNK_PRESENT_MODE=immediate|vsync overrides it for A/B. See Stage2Pipeline's header.
|
||||||
|
public static let vsync = "punktfunk.vsync"
|
||||||
|
/// Allow variable refresh rate: hand the display link a wide frame-rate RANGE (low floor,
|
||||||
|
/// preferred = stream rate) so a ProMotion / adaptive-sync display can vary its physical
|
||||||
|
/// refresh to match the stream. On by default; a no-op on fixed-refresh displays. When off,
|
||||||
|
/// macOS lets the link free-run at the display's native rate and iOS keeps its proven 30 Hz
|
||||||
|
/// floor. Read per session/reconfigure by `SessionPresenter.syncFrameRate`.
|
||||||
|
public static let allowVRR = "punktfunk.allowVRR"
|
||||||
/// Request a 10-bit BT.2020 PQ (HDR10) stream. On by default; only takes effect when the host
|
/// Request a 10-bit BT.2020 PQ (HDR10) stream. On by default; only takes effect when the host
|
||||||
/// has HDR content AND this display supports HDR — otherwise the stream stays 8-bit SDR.
|
/// has HDR content AND this display supports HDR — otherwise the stream stays 8-bit SDR.
|
||||||
public static let hdrEnabled = "punktfunk.hdrEnabled"
|
public static let hdrEnabled = "punktfunk.hdrEnabled"
|
||||||
@@ -57,7 +68,7 @@ public enum DefaultsKey {
|
|||||||
/// macOS: take the window fullscreen while streaming and restore it on the host list. On by default.
|
/// macOS: take the window fullscreen while streaming and restore it on the host list. On by default.
|
||||||
public static let fullscreenWhileStreaming = "punktfunk.fullscreenWhileStreaming"
|
public static let fullscreenWhileStreaming = "punktfunk.fullscreenWhileStreaming"
|
||||||
/// Show the streaming statistics overlay (mode/fps/throughput/latency). On by default; toggle
|
/// Show the streaming statistics overlay (mode/fps/throughput/latency). On by default; toggle
|
||||||
/// while streaming with ⌘⇧S (macOS / hardware keyboard).
|
/// while streaming with ⌃⌥⇧S (the cross-client Ctrl+Alt+Shift+S; macOS / hardware keyboard).
|
||||||
public static let hudEnabled = "punktfunk.hudEnabled"
|
public static let hudEnabled = "punktfunk.hudEnabled"
|
||||||
/// Which corner the statistics overlay sits in — a `HUDPlacement` raw value
|
/// Which corner the statistics overlay sits in — a `HUDPlacement` raw value
|
||||||
/// ("topLeading"/"topTrailing"/"bottomLeading"/"bottomTrailing"). Default top-trailing.
|
/// ("topLeading"/"topTrailing"/"bottomLeading"/"bottomTrailing"). Default top-trailing.
|
||||||
@@ -67,3 +78,12 @@ public enum DefaultsKey {
|
|||||||
/// whenever a gamepad is connected. On by default; see `GamepadUIEnvironment.isActive`.
|
/// whenever a gamepad is connected. On by default; see `GamepadUIEnvironment.isActive`.
|
||||||
public static let gamepadUIEnabled = "punktfunk.gamepadUIEnabled"
|
public static let gamepadUIEnabled = "punktfunk.gamepadUIEnabled"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension Notification.Name {
|
||||||
|
/// Posted by the app's Stream menu ("Release Mouse", ⌃⌥⇧Q): the key window's stream view
|
||||||
|
/// releases input capture if it holds it. Only reachable while NOT captured (a captured
|
||||||
|
/// session swallows the combo in InputCapture's monitor and the frozen cursor can't click
|
||||||
|
/// menus) — it exists so the menu item is honest whenever it CAN fire, and as the shortcut's
|
||||||
|
/// discoverable menu-bar surface.
|
||||||
|
public static let punktfunkReleaseCapture = Notification.Name("io.unom.punktfunk.release-capture")
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,561 @@
|
|||||||
|
// AV1 (low-overhead OBU bitstream) → CoreMedia plumbing — the AV1 sibling of AnnexB.swift.
|
||||||
|
//
|
||||||
|
// The punktfunk host emits AV1 access units as low-overhead temporal units (the raw encoder
|
||||||
|
// output every other client feeds ffmpeg): a temporal-delimiter OBU, then — on every keyframe,
|
||||||
|
// per the same in-band-config policy as the NAL codecs — a sequence-header OBU, then the frame
|
||||||
|
// OBUs. VideoToolbox instead wants the ISOBMFF 'av01' flavor: a CMVideoFormatDescription
|
||||||
|
// carrying an `av1C` configuration record (built from the sequence header), and sample buffers
|
||||||
|
// holding the temporal unit with the temporal delimiter stripped and every OBU size-fielded.
|
||||||
|
// This file converts between the two.
|
||||||
|
//
|
||||||
|
// HOT PATH: like AnnexB, both pumps run `formatDescription(fromKeyframe:)` +
|
||||||
|
// `sampleBuffer(au:format:)` once per AU, so everything is built on `forEachOBU` — a zero-copy
|
||||||
|
// scan over the AU's bytes (ranges, not materialized Datas). A delta AU (no sequence header)
|
||||||
|
// costs a few OBU-header reads; the sample repack leaves exactly one copy (source → block
|
||||||
|
// buffer), mirroring AnnexB.sampleBuffer.
|
||||||
|
//
|
||||||
|
// The full sequence-header parse (AV1 spec 5.5.1) runs only when a keyframe actually carries
|
||||||
|
// one — it exists to fill the `av1C` record fields (profile/level/tier/depth/chroma) and the
|
||||||
|
// colorimetry extensions (so VideoDecoder.isHDRFormat and the presenter's color handling work
|
||||||
|
// identically across codecs). The host currently gates 10-bit and 4:4:4 to HEVC, so an AV1
|
||||||
|
// stream is 8-bit 4:2:0 today; the parser still reads depth/chroma/color faithfully so nothing
|
||||||
|
// here needs touching when that gate lifts.
|
||||||
|
|
||||||
|
import CoreMedia
|
||||||
|
import Foundation
|
||||||
|
import VideoToolbox
|
||||||
|
|
||||||
|
public enum AV1 {
|
||||||
|
/// True when this device can hardware-decode AV1 (M3-class Macs, A17 Pro-class iPhones,
|
||||||
|
/// current iPads; false on every Apple TV to date). VideoToolbox has no software AV1
|
||||||
|
/// decoder, so this is the advertisement gate: a client must never invite a stream it
|
||||||
|
/// can't decode in real time.
|
||||||
|
public static let hardwareDecodeSupported: Bool =
|
||||||
|
VTIsHardwareDecodeSupported(kCMVideoCodecType_AV1)
|
||||||
|
|
||||||
|
// MARK: - OBU walking
|
||||||
|
|
||||||
|
/// OBU types (AV1 spec 6.2.2) — only the ones this file dispatches on.
|
||||||
|
enum OBUType {
|
||||||
|
static let sequenceHeader: UInt8 = 1
|
||||||
|
static let temporalDelimiter: UInt8 = 2
|
||||||
|
static let padding: UInt8 = 15
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Walk the OBUs of a low-overhead temporal unit without copying: `body` receives the buffer
|
||||||
|
/// base, each OBU's header range (header byte + optional extension byte + size field, i.e.
|
||||||
|
/// everything before the payload), payload range, and type — and returns false to stop early.
|
||||||
|
/// The walk ends at the first malformed OBU (forbidden bit set, truncated header, or a size
|
||||||
|
/// field overrunning the buffer): a torn AU decodes as garbage anyway and the pumps' keyframe
|
||||||
|
/// recovery re-anchors, so bailing beats guessing at boundaries. An OBU with
|
||||||
|
/// `obu_has_size_field == 0` extends to the end of the buffer (legal only for the last one).
|
||||||
|
/// The base pointer is only valid inside `body`.
|
||||||
|
static func forEachOBU(
|
||||||
|
in data: Data,
|
||||||
|
_ body: (
|
||||||
|
_ base: UnsafePointer<UInt8>, _ header: Range<Int>, _ payload: Range<Int>,
|
||||||
|
_ type: UInt8
|
||||||
|
) -> Bool
|
||||||
|
) {
|
||||||
|
data.withUnsafeBytes { (raw: UnsafeRawBufferPointer) in
|
||||||
|
guard let base = raw.bindMemory(to: UInt8.self).baseAddress else { return }
|
||||||
|
let count = raw.count
|
||||||
|
var i = 0
|
||||||
|
while i < count {
|
||||||
|
let start = i
|
||||||
|
let h = base[i]
|
||||||
|
guard h & 0x80 == 0 else { return } // obu_forbidden_bit — not an OBU stream
|
||||||
|
let type = (h >> 3) & 0x0F
|
||||||
|
let hasExtension = h & 0x04 != 0
|
||||||
|
let hasSize = h & 0x02 != 0
|
||||||
|
i += 1
|
||||||
|
if hasExtension {
|
||||||
|
guard i < count else { return }
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
let payloadLen: Int
|
||||||
|
if hasSize {
|
||||||
|
guard let (size, sizeLen) = leb128(base: base, at: i, count: count)
|
||||||
|
else { return }
|
||||||
|
i += sizeLen
|
||||||
|
payloadLen = size
|
||||||
|
} else {
|
||||||
|
payloadLen = count - i // no size field: extends to the end (must be last)
|
||||||
|
}
|
||||||
|
guard i + payloadLen <= count else { return }
|
||||||
|
if !body(base, start..<i, i..<(i + payloadLen), type) { return }
|
||||||
|
i += payloadLen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode a leb128 value at `at` (AV1 spec 4.10.5). Returns (value, encoded length) or nil
|
||||||
|
/// on truncation / a value past 32 bits (sizes beyond that are nonsense for an OBU).
|
||||||
|
private static func leb128(
|
||||||
|
base: UnsafePointer<UInt8>, at: Int, count: Int
|
||||||
|
) -> (Int, Int)? {
|
||||||
|
var value: UInt64 = 0
|
||||||
|
for i in 0..<8 {
|
||||||
|
guard at + i < count else { return nil }
|
||||||
|
let byte = base[at + i]
|
||||||
|
value |= UInt64(byte & 0x7F) << (7 * i)
|
||||||
|
if byte & 0x80 == 0 {
|
||||||
|
guard value <= UInt64(UInt32.max) else { return nil }
|
||||||
|
return (Int(value), i + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// leb128-encoded byte length of `value`.
|
||||||
|
private static func leb128Length(_ value: Int) -> Int {
|
||||||
|
var v = UInt32(value)
|
||||||
|
var n = 1
|
||||||
|
while v >= 0x80 {
|
||||||
|
v >>= 7
|
||||||
|
n += 1
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encode `value` as leb128 into `dst`; returns the byte count written.
|
||||||
|
private static func putLeb128(_ value: Int, into dst: UnsafeMutableRawPointer) -> Int {
|
||||||
|
var v = UInt32(value)
|
||||||
|
var n = 0
|
||||||
|
repeat {
|
||||||
|
var byte = UInt8(v & 0x7F)
|
||||||
|
v >>= 7
|
||||||
|
if v != 0 { byte |= 0x80 }
|
||||||
|
dst.storeBytes(of: byte, toByteOffset: n, as: UInt8.self)
|
||||||
|
n += 1
|
||||||
|
} while v != 0
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sequence header
|
||||||
|
|
||||||
|
/// The sequence-header fields the `av1C` record and the colorimetry extensions need
|
||||||
|
/// (AV1 spec 5.5; color codes are ITU-T H.273, shared with the HEVC VUI).
|
||||||
|
struct SequenceHeader {
|
||||||
|
var profile: UInt8 = 0
|
||||||
|
var levelIdx0: UInt8 = 0
|
||||||
|
var tier0: UInt8 = 0
|
||||||
|
var highBitdepth = false
|
||||||
|
var twelveBit = false
|
||||||
|
var monochrome = false
|
||||||
|
var subsamplingX = true
|
||||||
|
var subsamplingY = true
|
||||||
|
var chromaSamplePosition: UInt8 = 0
|
||||||
|
/// H.273 codes; 2 = unspecified (the spec default when no color description is coded).
|
||||||
|
var colorPrimaries: UInt8 = 2
|
||||||
|
var transferCharacteristics: UInt8 = 2
|
||||||
|
var matrixCoefficients: UInt8 = 2
|
||||||
|
var fullRange = false
|
||||||
|
var maxWidth = 0
|
||||||
|
var maxHeight = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// MSB-first bit reader over the sequence-header payload. Every read is bounds-checked and
|
||||||
|
/// returns nil on overrun — the parser guard-lets each field so a truncated header yields
|
||||||
|
/// nil rather than garbage.
|
||||||
|
private struct BitReader {
|
||||||
|
private let bytes: UnsafePointer<UInt8>
|
||||||
|
private let bitCount: Int
|
||||||
|
private var pos = 0
|
||||||
|
|
||||||
|
init(bytes: UnsafePointer<UInt8>, count: Int) {
|
||||||
|
self.bytes = bytes
|
||||||
|
self.bitCount = count * 8
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func f(_ n: Int) -> UInt32? {
|
||||||
|
guard n <= 32, pos + n <= bitCount else { return nil }
|
||||||
|
var v: UInt32 = 0
|
||||||
|
for _ in 0..<n {
|
||||||
|
let bit = (bytes[pos >> 3] >> (7 - UInt8(pos & 7))) & 1
|
||||||
|
v = (v << 1) | UInt32(bit)
|
||||||
|
pos += 1
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func flag() -> Bool? { f(1).map { $0 == 1 } }
|
||||||
|
|
||||||
|
/// uvlc() (spec 4.10.3) — only `num_ticks_per_picture_minus_1` uses it here.
|
||||||
|
mutating func uvlc() -> UInt32? {
|
||||||
|
var leadingZeros = 0
|
||||||
|
while true {
|
||||||
|
guard let b = f(1) else { return nil }
|
||||||
|
if b == 1 { break }
|
||||||
|
leadingZeros += 1
|
||||||
|
if leadingZeros >= 32 { return nil }
|
||||||
|
}
|
||||||
|
if leadingZeros == 0 { return 0 }
|
||||||
|
guard let v = f(leadingZeros) else { return nil }
|
||||||
|
return v + (1 << leadingZeros) - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a sequence-header OBU payload (spec 5.5.1 — the full walk down to color_config,
|
||||||
|
/// which is what `av1C` + the colorimetry extensions are built from). Returns nil on any
|
||||||
|
/// truncation or spec violation.
|
||||||
|
static func parseSequenceHeader(_ payload: Data) -> SequenceHeader? {
|
||||||
|
payload.withUnsafeBytes { (raw: UnsafeRawBufferPointer) -> SequenceHeader? in
|
||||||
|
guard let base = raw.bindMemory(to: UInt8.self).baseAddress else { return nil }
|
||||||
|
var r = BitReader(bytes: base, count: raw.count)
|
||||||
|
var sh = SequenceHeader()
|
||||||
|
|
||||||
|
guard let profile = r.f(3), profile <= 2 else { return nil }
|
||||||
|
sh.profile = UInt8(profile)
|
||||||
|
guard r.flag() != nil else { return nil } // still_picture
|
||||||
|
guard let reduced = r.flag() else { return nil }
|
||||||
|
|
||||||
|
var decoderModelInfoPresent = false
|
||||||
|
var bufferDelayLengthMinus1 = 0
|
||||||
|
if reduced {
|
||||||
|
guard let level = r.f(5) else { return nil }
|
||||||
|
sh.levelIdx0 = UInt8(level)
|
||||||
|
sh.tier0 = 0
|
||||||
|
} else {
|
||||||
|
guard let timingInfoPresent = r.flag() else { return nil }
|
||||||
|
if timingInfoPresent {
|
||||||
|
guard r.f(32) != nil, r.f(32) != nil, // num_units_in_display_tick, time_scale
|
||||||
|
let equalPictureInterval = r.flag()
|
||||||
|
else { return nil }
|
||||||
|
if equalPictureInterval, r.uvlc() == nil { return nil }
|
||||||
|
guard let dmip = r.flag() else { return nil }
|
||||||
|
decoderModelInfoPresent = dmip
|
||||||
|
if decoderModelInfoPresent {
|
||||||
|
guard let bdl = r.f(5), r.f(32) != nil, r.f(5) != nil, r.f(5) != nil
|
||||||
|
else { return nil }
|
||||||
|
bufferDelayLengthMinus1 = Int(bdl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard let initialDisplayDelayPresent = r.flag(),
|
||||||
|
let opCountMinus1 = r.f(5)
|
||||||
|
else { return nil }
|
||||||
|
for i in 0...Int(opCountMinus1) {
|
||||||
|
guard r.f(12) != nil, let level = r.f(5) else { return nil }
|
||||||
|
var tier: UInt32 = 0
|
||||||
|
if level > 7 {
|
||||||
|
guard let t = r.f(1) else { return nil }
|
||||||
|
tier = t
|
||||||
|
}
|
||||||
|
if i == 0 {
|
||||||
|
sh.levelIdx0 = UInt8(level)
|
||||||
|
sh.tier0 = UInt8(tier)
|
||||||
|
}
|
||||||
|
if decoderModelInfoPresent {
|
||||||
|
guard let present = r.flag() else { return nil }
|
||||||
|
if present {
|
||||||
|
let n = bufferDelayLengthMinus1 + 1
|
||||||
|
guard r.f(n) != nil, r.f(n) != nil, r.f(1) != nil else { return nil }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if initialDisplayDelayPresent {
|
||||||
|
guard let present = r.flag() else { return nil }
|
||||||
|
if present, r.f(4) == nil { return nil }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let widthBitsMinus1 = r.f(4), let heightBitsMinus1 = r.f(4),
|
||||||
|
let maxWidthMinus1 = r.f(Int(widthBitsMinus1) + 1),
|
||||||
|
let maxHeightMinus1 = r.f(Int(heightBitsMinus1) + 1)
|
||||||
|
else { return nil }
|
||||||
|
sh.maxWidth = Int(maxWidthMinus1) + 1
|
||||||
|
sh.maxHeight = Int(maxHeightMinus1) + 1
|
||||||
|
|
||||||
|
if !reduced {
|
||||||
|
guard let frameIdNumbersPresent = r.flag() else { return nil }
|
||||||
|
if frameIdNumbersPresent {
|
||||||
|
guard r.f(4) != nil, r.f(3) != nil else { return nil }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// use_128x128_superblock, enable_filter_intra, enable_intra_edge_filter
|
||||||
|
guard r.f(3) != nil else { return nil }
|
||||||
|
if !reduced {
|
||||||
|
// enable_interintra_compound … enable_dual_filter
|
||||||
|
guard r.f(4) != nil, let enableOrderHint = r.flag() else { return nil }
|
||||||
|
if enableOrderHint {
|
||||||
|
guard r.f(2) != nil else { return nil } // jnt_comp, ref_frame_mvs
|
||||||
|
}
|
||||||
|
guard let chooseScreenContentTools = r.flag() else { return nil }
|
||||||
|
let forceScreenContentTools: UInt32
|
||||||
|
if chooseScreenContentTools {
|
||||||
|
forceScreenContentTools = 2 // SELECT_SCREEN_CONTENT_TOOLS
|
||||||
|
} else {
|
||||||
|
guard let v = r.f(1) else { return nil }
|
||||||
|
forceScreenContentTools = v
|
||||||
|
}
|
||||||
|
if forceScreenContentTools > 0 {
|
||||||
|
guard let chooseIntegerMv = r.flag() else { return nil }
|
||||||
|
if !chooseIntegerMv, r.f(1) == nil { return nil }
|
||||||
|
}
|
||||||
|
if enableOrderHint, r.f(3) == nil { return nil } // order_hint_bits_minus_1
|
||||||
|
}
|
||||||
|
// enable_superres, enable_cdef, enable_restoration
|
||||||
|
guard r.f(3) != nil else { return nil }
|
||||||
|
|
||||||
|
// color_config() (spec 5.5.2)
|
||||||
|
guard let highBitdepth = r.flag() else { return nil }
|
||||||
|
sh.highBitdepth = highBitdepth
|
||||||
|
if sh.profile == 2, highBitdepth {
|
||||||
|
guard let twelveBit = r.flag() else { return nil }
|
||||||
|
sh.twelveBit = twelveBit
|
||||||
|
}
|
||||||
|
if sh.profile == 1 {
|
||||||
|
sh.monochrome = false
|
||||||
|
} else {
|
||||||
|
guard let mono = r.flag() else { return nil }
|
||||||
|
sh.monochrome = mono
|
||||||
|
}
|
||||||
|
guard let colorDescriptionPresent = r.flag() else { return nil }
|
||||||
|
if colorDescriptionPresent {
|
||||||
|
guard let cp = r.f(8), let tc = r.f(8), let mc = r.f(8) else { return nil }
|
||||||
|
sh.colorPrimaries = UInt8(cp)
|
||||||
|
sh.transferCharacteristics = UInt8(tc)
|
||||||
|
sh.matrixCoefficients = UInt8(mc)
|
||||||
|
}
|
||||||
|
if sh.monochrome {
|
||||||
|
guard let fullRange = r.flag() else { return nil }
|
||||||
|
sh.fullRange = fullRange
|
||||||
|
sh.subsamplingX = true
|
||||||
|
sh.subsamplingY = true
|
||||||
|
sh.chromaSamplePosition = 0
|
||||||
|
return sh
|
||||||
|
}
|
||||||
|
if sh.colorPrimaries == 1, sh.transferCharacteristics == 13,
|
||||||
|
sh.matrixCoefficients == 0 {
|
||||||
|
// BT.709 + sRGB + identity forces full-range 4:4:4.
|
||||||
|
sh.fullRange = true
|
||||||
|
sh.subsamplingX = false
|
||||||
|
sh.subsamplingY = false
|
||||||
|
return sh
|
||||||
|
}
|
||||||
|
guard let fullRange = r.flag() else { return nil }
|
||||||
|
sh.fullRange = fullRange
|
||||||
|
switch sh.profile {
|
||||||
|
case 0:
|
||||||
|
sh.subsamplingX = true
|
||||||
|
sh.subsamplingY = true
|
||||||
|
case 1:
|
||||||
|
sh.subsamplingX = false
|
||||||
|
sh.subsamplingY = false
|
||||||
|
default: // profile 2
|
||||||
|
if sh.highBitdepth, sh.twelveBit {
|
||||||
|
guard let ssx = r.flag() else { return nil }
|
||||||
|
sh.subsamplingX = ssx
|
||||||
|
if ssx {
|
||||||
|
guard let ssy = r.flag() else { return nil }
|
||||||
|
sh.subsamplingY = ssy
|
||||||
|
} else {
|
||||||
|
sh.subsamplingY = false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sh.subsamplingX = true
|
||||||
|
sh.subsamplingY = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sh.subsamplingX, sh.subsamplingY {
|
||||||
|
guard let csp = r.f(2) else { return nil }
|
||||||
|
sh.chromaSamplePosition = UInt8(csp)
|
||||||
|
}
|
||||||
|
return sh
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Format description
|
||||||
|
|
||||||
|
/// Build a format description from a keyframe AU's in-band sequence header — the AV1
|
||||||
|
/// equivalent of `AnnexB.formatDescription(fromIDR:)`. Returns nil when the AU carries no
|
||||||
|
/// sequence-header OBU (a delta frame): the pumps latch the previous description exactly as
|
||||||
|
/// they do for the NAL codecs. The description carries the `av1C` record (with the sequence
|
||||||
|
/// header as its configOBUs) plus colorimetry extensions mapped from color_config, so
|
||||||
|
/// `VideoDecoder.isHDRFormat` and the presenter treat AV1 like any other stream.
|
||||||
|
public static func formatDescription(fromKeyframe au: Data) -> CMVideoFormatDescription? {
|
||||||
|
// The sequence-header OBU, re-emitted with a size field (encoders size-field everything
|
||||||
|
// in practice; the rewrap also covers a last-OBU-without-size corner).
|
||||||
|
var seqHeaderOBU: Data?
|
||||||
|
var seqHeaderPayload: Data?
|
||||||
|
forEachOBU(in: au) { base, header, payload, type in
|
||||||
|
guard type == OBUType.sequenceHeader else { return true }
|
||||||
|
var obu = Data(capacity: 2 + leb128Length(payload.count) + payload.count)
|
||||||
|
obu.append(base[header.lowerBound] | 0x02) // has_size_field set
|
||||||
|
if base[header.lowerBound] & 0x04 != 0 { // extension byte rides along
|
||||||
|
obu.append(base[header.lowerBound + 1])
|
||||||
|
}
|
||||||
|
var lenBuf = [UInt8](repeating: 0, count: 8)
|
||||||
|
let lenLen = lenBuf.withUnsafeMutableBytes {
|
||||||
|
putLeb128(payload.count, into: $0.baseAddress!)
|
||||||
|
}
|
||||||
|
obu.append(contentsOf: lenBuf[0..<lenLen])
|
||||||
|
obu.append(UnsafeBufferPointer(start: base + payload.lowerBound, count: payload.count))
|
||||||
|
seqHeaderOBU = obu
|
||||||
|
seqHeaderPayload = Data(bytes: base + payload.lowerBound, count: payload.count)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
guard let seqHeaderOBU, let seqHeaderPayload,
|
||||||
|
let sh = parseSequenceHeader(seqHeaderPayload),
|
||||||
|
sh.maxWidth > 0, sh.maxHeight > 0
|
||||||
|
else { return nil }
|
||||||
|
|
||||||
|
// AV1CodecConfigurationRecord (AV1-ISOBMFF §2.3): 4 fixed bytes + configOBUs.
|
||||||
|
var av1C = Data(capacity: 4 + seqHeaderOBU.count)
|
||||||
|
av1C.append(0x81) // marker=1, version=1
|
||||||
|
av1C.append((sh.profile << 5) | sh.levelIdx0)
|
||||||
|
av1C.append(
|
||||||
|
(sh.tier0 << 7)
|
||||||
|
| ((sh.highBitdepth ? 1 : 0) << 6)
|
||||||
|
| ((sh.twelveBit ? 1 : 0) << 5)
|
||||||
|
| ((sh.monochrome ? 1 : 0) << 4)
|
||||||
|
| ((sh.subsamplingX ? 1 : 0) << 3)
|
||||||
|
| ((sh.subsamplingY ? 1 : 0) << 2)
|
||||||
|
| sh.chromaSamplePosition)
|
||||||
|
av1C.append(0) // no initial_presentation_delay
|
||||||
|
av1C.append(seqHeaderOBU)
|
||||||
|
|
||||||
|
// Colorimetry from color_config's H.273 codes; unspecified (2) falls back to BT.709 —
|
||||||
|
// the host's SDR default, same policy the presenter applies elsewhere.
|
||||||
|
let primaries: CFString = {
|
||||||
|
switch sh.colorPrimaries {
|
||||||
|
case 9: return kCMFormatDescriptionColorPrimaries_ITU_R_2020
|
||||||
|
case 6: return kCMFormatDescriptionColorPrimaries_SMPTE_C
|
||||||
|
case 5: return kCMFormatDescriptionColorPrimaries_EBU_3213
|
||||||
|
default: return kCMFormatDescriptionColorPrimaries_ITU_R_709_2
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
let transfer: CFString = {
|
||||||
|
switch sh.transferCharacteristics {
|
||||||
|
case 16: return kCMFormatDescriptionTransferFunction_SMPTE_ST_2084_PQ
|
||||||
|
case 18: return kCMFormatDescriptionTransferFunction_ITU_R_2100_HLG
|
||||||
|
case 13: return kCMFormatDescriptionTransferFunction_sRGB
|
||||||
|
case 8: return kCMFormatDescriptionTransferFunction_Linear
|
||||||
|
default: return kCMFormatDescriptionTransferFunction_ITU_R_709_2
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
let matrix: CFString = {
|
||||||
|
switch sh.matrixCoefficients {
|
||||||
|
case 9, 10: return kCMFormatDescriptionYCbCrMatrix_ITU_R_2020
|
||||||
|
case 5, 6: return kCMFormatDescriptionYCbCrMatrix_ITU_R_601_4
|
||||||
|
default: return kCMFormatDescriptionYCbCrMatrix_ITU_R_709_2
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
let extensions: [CFString: Any] = [
|
||||||
|
kCMFormatDescriptionExtension_SampleDescriptionExtensionAtoms: ["av1C": av1C],
|
||||||
|
kCMFormatDescriptionExtension_ColorPrimaries: primaries,
|
||||||
|
kCMFormatDescriptionExtension_TransferFunction: transfer,
|
||||||
|
kCMFormatDescriptionExtension_YCbCrMatrix: matrix,
|
||||||
|
kCMFormatDescriptionExtension_FullRangeVideo: sh.fullRange,
|
||||||
|
]
|
||||||
|
var format: CMVideoFormatDescription?
|
||||||
|
let status = CMVideoFormatDescriptionCreate(
|
||||||
|
allocator: kCFAllocatorDefault,
|
||||||
|
codecType: kCMVideoCodecType_AV1,
|
||||||
|
width: Int32(sh.maxWidth), height: Int32(sh.maxHeight),
|
||||||
|
extensions: extensions as CFDictionary,
|
||||||
|
formatDescriptionOut: &format)
|
||||||
|
return status == noErr ? format : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sample buffers
|
||||||
|
|
||||||
|
/// Wrap one temporal unit as a decode-ready CMSampleBuffer in the ISOBMFF 'av01' sample
|
||||||
|
/// format: the temporal-delimiter (and padding) OBUs are dropped, every remaining OBU is
|
||||||
|
/// re-emitted with a size field, and — mirroring AnnexB.sampleBuffer — the result is packed
|
||||||
|
/// straight into the CMBlockBuffer's allocation (sized by a first cheap scan). The sequence
|
||||||
|
/// header stays in-band (spec-legal: it's bit-identical to the one in `av1C`, which is
|
||||||
|
/// rebuilt from the same keyframe), preserving the host's self-contained-keyframe policy.
|
||||||
|
public static func sampleBuffer(
|
||||||
|
au: AccessUnit, format: CMVideoFormatDescription
|
||||||
|
) -> CMSampleBuffer? {
|
||||||
|
// Pass 1: byte scan only — total repacked size of the kept OBUs.
|
||||||
|
var total = 0
|
||||||
|
forEachOBU(in: au.data) { base, header, payload, type in
|
||||||
|
if type == OBUType.temporalDelimiter || type == OBUType.padding { return true }
|
||||||
|
let headerLen = base[header.lowerBound] & 0x04 != 0 ? 2 : 1
|
||||||
|
total += headerLen + leb128Length(payload.count) + payload.count
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Nothing decodable (a delimiter-only AU — our host never sends one): drop it rather
|
||||||
|
// than hand the decoder an empty sample.
|
||||||
|
guard total > 0 else { return nil }
|
||||||
|
|
||||||
|
var blockBuffer: CMBlockBuffer?
|
||||||
|
guard CMBlockBufferCreateWithMemoryBlock(
|
||||||
|
allocator: kCFAllocatorDefault, memoryBlock: nil,
|
||||||
|
blockLength: total, blockAllocator: kCFAllocatorDefault,
|
||||||
|
customBlockSource: nil, offsetToData: 0, dataLength: total,
|
||||||
|
flags: kCMBlockBufferAssureMemoryNowFlag, blockBufferOut: &blockBuffer) == noErr,
|
||||||
|
let block = blockBuffer
|
||||||
|
else { return nil }
|
||||||
|
var dstLen = 0
|
||||||
|
var dstPtr: UnsafeMutablePointer<CChar>?
|
||||||
|
guard CMBlockBufferGetDataPointer(
|
||||||
|
block, atOffset: 0, lengthAtOffsetOut: nil, totalLengthOut: &dstLen,
|
||||||
|
dataPointerOut: &dstPtr) == noErr,
|
||||||
|
dstLen == total, let dstPtr
|
||||||
|
else { return nil }
|
||||||
|
// Pass 2: the single copy — header (+extension) byte, size field, payload per OBU.
|
||||||
|
let dst = UnsafeMutableRawPointer(dstPtr)
|
||||||
|
var off = 0
|
||||||
|
forEachOBU(in: au.data) { base, header, payload, type in
|
||||||
|
if type == OBUType.temporalDelimiter || type == OBUType.padding { return true }
|
||||||
|
dst.storeBytes(
|
||||||
|
of: base[header.lowerBound] | 0x02, toByteOffset: off, as: UInt8.self)
|
||||||
|
off += 1
|
||||||
|
if base[header.lowerBound] & 0x04 != 0 {
|
||||||
|
dst.storeBytes(
|
||||||
|
of: base[header.lowerBound + 1], toByteOffset: off, as: UInt8.self)
|
||||||
|
off += 1
|
||||||
|
}
|
||||||
|
off += putLeb128(payload.count, into: dst.advanced(by: off))
|
||||||
|
dst.advanced(by: off)
|
||||||
|
.copyMemory(from: base + payload.lowerBound, byteCount: payload.count)
|
||||||
|
off += payload.count
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
var timing = CMSampleTimingInfo(
|
||||||
|
duration: .invalid,
|
||||||
|
presentationTimeStamp: CMTime(value: Int64(au.ptsNs), timescale: 1_000_000_000),
|
||||||
|
decodeTimeStamp: .invalid)
|
||||||
|
var sampleSize = total
|
||||||
|
var sample: CMSampleBuffer?
|
||||||
|
guard CMSampleBufferCreate(
|
||||||
|
allocator: kCFAllocatorDefault, dataBuffer: block, dataReady: true,
|
||||||
|
makeDataReadyCallback: nil, refcon: nil, formatDescription: format,
|
||||||
|
sampleCount: 1, sampleTimingEntryCount: 1, sampleTimingArray: &timing,
|
||||||
|
sampleSizeEntryCount: 1, sampleSizeArray: &sampleSize,
|
||||||
|
sampleBufferOut: &sample) == noErr
|
||||||
|
else { return nil }
|
||||||
|
// Low-latency display: render on arrival, don't wait for a clock.
|
||||||
|
if let attachments = CMSampleBufferGetSampleAttachmentsArray(sample!, createIfNecessary: true) {
|
||||||
|
let dict = unsafeBitCast(CFArrayGetValueAtIndex(attachments, 0), to: CFMutableDictionary.self)
|
||||||
|
CFDictionarySetValue(
|
||||||
|
dict,
|
||||||
|
Unmanaged.passUnretained(kCMSampleAttachmentKey_DisplayImmediately).toOpaque(),
|
||||||
|
Unmanaged.passUnretained(kCFBooleanTrue).toOpaque())
|
||||||
|
}
|
||||||
|
return sample
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension VideoCodec {
|
||||||
|
/// Codec-dispatching format-description refresh: the AV1 path keys on an in-band sequence
|
||||||
|
/// header, the NAL codecs on in-band parameter sets — one call site in each pump.
|
||||||
|
public func formatDescription(fromKeyframe au: Data) -> CMVideoFormatDescription? {
|
||||||
|
self == .av1
|
||||||
|
? AV1.formatDescription(fromKeyframe: au)
|
||||||
|
: AnnexB.formatDescription(fromIDR: au, codec: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Codec-dispatching sample wrap (see `formatDescription(fromKeyframe:)`).
|
||||||
|
public func sampleBuffer(
|
||||||
|
au: AccessUnit, format: CMVideoFormatDescription
|
||||||
|
) -> CMSampleBuffer? {
|
||||||
|
self == .av1
|
||||||
|
? AV1.sampleBuffer(au: au, format: format)
|
||||||
|
: AnnexB.sampleBuffer(au: au, format: format, codec: self)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,8 +6,10 @@
|
|||||||
// buffers whose NALs are 4-byte-length-prefixed. This file converts between the two, for
|
// buffers whose NALs are 4-byte-length-prefixed. This file converts between the two, for
|
||||||
// the codec the host resolved in the Welcome (`connection.videoCodec`) — HEVC and H.264
|
// the codec the host resolved in the Welcome (`connection.videoCodec`) — HEVC and H.264
|
||||||
// differ only in NAL-header layout and which parameter sets exist (HEVC adds a VPS). AV1
|
// differ only in NAL-header layout and which parameter sets exist (HEVC adds a VPS). AV1
|
||||||
// is not an Annex-B/NAL codec and isn't handled here (hosts don't emit it on the native
|
// is not an Annex-B/NAL codec and isn't handled here — its OBU flavor of the same plumbing
|
||||||
// path yet).
|
// lives in AV1.swift, and the pumps reach both through `VideoCodec`'s dispatching
|
||||||
|
// `formatDescription(fromKeyframe:)` / `sampleBuffer(au:format:)`, so nothing below is ever
|
||||||
|
// called with `.av1`.
|
||||||
//
|
//
|
||||||
// HOT PATH: both pumps run `formatDescription(fromIDR:codec:)` + `sampleBuffer(au:format:codec:)`
|
// HOT PATH: both pumps run `formatDescription(fromIDR:codec:)` + `sampleBuffer(au:format:codec:)`
|
||||||
// once per AU, so the conversion is built on `forEachNAL` — a zero-copy scan over the AU's bytes
|
// once per AU, so the conversion is built on `forEachNAL` — a zero-copy scan over the AU's bytes
|
||||||
@@ -23,10 +25,15 @@ import Foundation
|
|||||||
public enum VideoCodec: Equatable {
|
public enum VideoCodec: Equatable {
|
||||||
case h264
|
case h264
|
||||||
case hevc
|
case hevc
|
||||||
|
case av1
|
||||||
|
|
||||||
/// Resolve from the wire `Welcome.codec` byte (`PUNKTFUNK_CODEC_*`; unknown → HEVC).
|
/// Resolve from the wire `Welcome.codec` byte (`PUNKTFUNK_CODEC_*`; unknown → HEVC).
|
||||||
public init(wire: UInt8) {
|
public init(wire: UInt8) {
|
||||||
self = wire == 0x01 ? .h264 : .hevc // 0x01 = PUNKTFUNK_CODEC_H264
|
switch wire {
|
||||||
|
case 0x01: self = .h264 // PUNKTFUNK_CODEC_H264
|
||||||
|
case 0x04: self = .av1 // PUNKTFUNK_CODEC_AV1
|
||||||
|
default: self = .hevc // PUNKTFUNK_CODEC_HEVC — the default / older-host codec
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// NAL unit type from a NAL's first byte. HEVC: bits 1..6; H.264: bits 0..4.
|
/// NAL unit type from a NAL's first byte. HEVC: bits 1..6; H.264: bits 0..4.
|
||||||
@@ -140,6 +147,8 @@ public enum AnnexB {
|
|||||||
sets = [vps, sps, pps]
|
sets = [vps, sps, pps]
|
||||||
case .h264:
|
case .h264:
|
||||||
sets = [sps, pps]
|
sets = [sps, pps]
|
||||||
|
case .av1:
|
||||||
|
return nil // OBU stream, no parameter-set NALs — handled in AV1.swift, never here
|
||||||
}
|
}
|
||||||
|
|
||||||
var format: CMVideoFormatDescription?
|
var format: CMVideoFormatDescription?
|
||||||
@@ -175,6 +184,8 @@ public enum AnnexB {
|
|||||||
parameterSetSizes: sizes,
|
parameterSetSizes: sizes,
|
||||||
nalUnitHeaderLength: 4,
|
nalUnitHeaderLength: 4,
|
||||||
formatDescriptionOut: &format)
|
formatDescriptionOut: &format)
|
||||||
|
case .av1:
|
||||||
|
break // unreachable — the .av1 arm above already returned
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return status == noErr ? format : nil
|
return status == noErr ? format : nil
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
// Stage-2 presenter, present half: draw a decoded NV12 / P010 / 4:4:4 CVPixelBuffer into a CAMetalLayer
|
// Stage-2 presenter, present half: draw a decoded NV12 / P010 / 4:4:4 CVPixelBuffer into a CAMetalLayer
|
||||||
// drawable with a Y′CbCr→RGB shader. The hosting view's CADisplayLink drives `render` once per vsync
|
// drawable with a Y′CbCr→RGB shader. The hosting view's CADisplayLink still paces the pipeline once per
|
||||||
// (via Stage2Pipeline.renderTick) with the target present time, so a present can be stamped and the
|
// vsync, but the actual `render` runs on Stage2Pipeline's dedicated RENDER THREAD (the link tick just
|
||||||
// present tail hand-paced. See docs apple-stage2-presenter.md.
|
// signals it), so `nextDrawable()`'s blocking never lands on the main thread. See docs
|
||||||
|
// apple-stage2-presenter.md.
|
||||||
//
|
//
|
||||||
// Main-thread only: created during view setup, `render`/`configure` called from the view's CADisplayLink
|
// Threading: created during view setup (main); `render`/`configure` run on the render thread — the
|
||||||
// (which fires on the main runloop). The Metal objects + texture cache are touched only here. The one
|
// layer's drawable/format/colour mutations all happen there (CAMetalLayer is designed for dedicated
|
||||||
// exception is `setHdrMeta`, called from the pump thread — it hops the layer write to main so every
|
// render threads; only the layer's GEOMETRY — frame/contentsScale — is touched from main, in
|
||||||
// CALayer mutation stays on one thread.
|
// SessionPresenter.layout, which also pushes the resulting pixel size here via `setDrawableTarget`
|
||||||
|
// so the render thread never reads layer geometry cross-thread). `setHdrMeta` (pump thread) and
|
||||||
|
// `setDrawableTarget` (main) only write lock-guarded staging state the render thread drains.
|
||||||
|
|
||||||
#if canImport(Metal) && canImport(QuartzCore)
|
#if canImport(Metal) && canImport(QuartzCore)
|
||||||
import CoreGraphics
|
import CoreGraphics
|
||||||
@@ -144,14 +147,23 @@ public final class MetalVideoPresenter {
|
|||||||
private var textureCache: CVMetalTextureCache?
|
private var textureCache: CVMetalTextureCache?
|
||||||
|
|
||||||
/// Current layer configuration — switched in `configure(hdr:)` when a frame's HDR-ness differs.
|
/// Current layer configuration — switched in `configure(hdr:)` when a frame's HDR-ness differs.
|
||||||
/// Main-thread only (read + written from `render`/`configure`, all on the display-link runloop).
|
/// Render-thread confined once the pipeline runs (Stage2Pipeline.start's one pre-thread
|
||||||
|
/// `configure` call is ordered before the thread starts, so it doesn't race).
|
||||||
private var hdrActive = false
|
private var hdrActive = false
|
||||||
/// Last HDR mastering grade received via `setHdrMeta` (the host's 0xCE). Cached so a mid-session
|
/// Last HDR mastering grade received via `setHdrMeta` (the host's 0xCE). Cached so a mid-session
|
||||||
/// SDR→HDR flip's `configureColor` re-applies the real grade instead of clobbering it back to the
|
/// SDR→HDR flip's `configureColor` re-applies the real grade instead of clobbering it back to the
|
||||||
/// bare reference-white anchor (an out-of-order race otherwise: `setHdrMeta` and the flip both write
|
/// bare reference-white anchor (an out-of-order race otherwise: `setHdrMeta` and the flip both write
|
||||||
/// `edrMetadata`). Main-thread only.
|
/// `edrMetadata`). Render-thread confined (drained from `pendingHdrMeta` at the top of `render`).
|
||||||
private var lastHdrMeta: PunktfunkConnection.HdrMeta?
|
private var lastHdrMeta: PunktfunkConnection.HdrMeta?
|
||||||
|
|
||||||
|
/// Cross-thread staging, all under `stagingLock`: the pump thread parks a fresh 0xCE grade in
|
||||||
|
/// `pendingHdrMeta` and the main thread parks the layout-derived drawable pixel size in
|
||||||
|
/// `drawableTarget`; the render thread drains both at the top of `render`, so every layer
|
||||||
|
/// format/colour mutation stays on the one thread that also calls `nextDrawable()`.
|
||||||
|
private let stagingLock = NSLock()
|
||||||
|
private var pendingHdrMeta: PunktfunkConnection.HdrMeta?
|
||||||
|
private var drawableTarget: CGSize = .zero
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
/// Last logged "decoded→drawable" signature, so the diagnostic logs only on a size/HDR change.
|
/// Last logged "decoded→drawable" signature, so the diagnostic logs only on a size/HDR change.
|
||||||
private var lastSizeSig = ""
|
private var lastSizeSig = ""
|
||||||
@@ -191,12 +203,18 @@ public final class MetalVideoPresenter {
|
|||||||
layer.framebufferOnly = true
|
layer.framebufferOnly = true
|
||||||
layer.isOpaque = true
|
layer.isOpaque = true
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
// The display link already paces exactly one present per vsync. Leaving the layer's own vsync
|
// displaySyncEnabled MUST stay false on macOS. It has flip-flopped, so the full history:
|
||||||
// wait on means `commandBuffer.present` ALSO blocks for the hardware vsync, so `nextDrawable()`
|
// sync ON was tried twice and starves the drawable pool both times — on macOS 26 a synced
|
||||||
// stalls the MAIN thread until a drawable frees — windowed, the WindowServer's looser
|
// present only reaches glass when the WindowServer composites the window, and its FramePacing
|
||||||
// compositing hides it; FULLSCREEN's tighter path serializes the main thread to the display and
|
// path does not treat our out-of-band image-queue presents as damage, so with a static scene
|
||||||
// the stall surfaces as bad judder. Disabling the layer-level sync lets present return promptly
|
// the ONLY recurring damage is the 1 Hz stats HUD update: presents queue, all drawables stay
|
||||||
// (the display link is the pacing source) — the fix for the fullscreen stutter. macOS-only.
|
// held, `nextDrawable()` sleeps (sampled: ~70% of the render thread inside
|
||||||
|
// CAMetalLayerPrivateNextDrawableLocked → usleep), and the stream turns into a ~1 fps
|
||||||
|
// slideshow with normal-LOOKING stats (each rare frame is fresh, newest-wins ring). The
|
||||||
|
// 94fb7d1b fullscreen judder was the same starvation biting the then-main-thread render.
|
||||||
|
// With sync OFF the flip is immediate; the vsync alignment that sync was supposed to give
|
||||||
|
// (the HUD-off direct-scanout pacing fix) comes from scheduling the present at the display
|
||||||
|
// link's target time instead (`present(at:)` — see `render`).
|
||||||
layer.displaySyncEnabled = false
|
layer.displaySyncEnabled = false
|
||||||
#endif
|
#endif
|
||||||
// The drawable is rendered at the LAYER's pixel size (set per-frame in `render`), so the
|
// The drawable is rendered at the LAYER's pixel size (set per-frame in `render`), so the
|
||||||
@@ -226,11 +244,12 @@ public final class MetalVideoPresenter {
|
|||||||
self.layer = layer
|
self.layer = layer
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Configure the layer + active pipeline for an SDR or HDR session. MAIN THREAD ONLY. Called once at
|
/// Configure the layer + active pipeline for an SDR or HDR session. Called once at session start
|
||||||
/// session start and again per-frame from `render` (idempotent — the guard makes a same-state call a
|
/// (main, before the render thread exists) and again per-frame from `render` on the RENDER THREAD
|
||||||
/// no-op), so a mid-session HDR toggle (the host re-inits its encoder; the decoded `frame.isHDR`
|
/// (idempotent — the guard makes a same-state call a no-op), so a mid-session HDR toggle (the host
|
||||||
/// flips) reconfigures here automatically. HDR uses an rgba16Float drawable + BT.2020 PQ colour space
|
/// re-inits its encoder; the decoded `frame.isHDR` flips) reconfigures here automatically. HDR uses
|
||||||
/// + EDR with a 203-nit reference-white anchor; SDR uses the plain 8-bit sRGB path.
|
/// an rgba16Float drawable + BT.2020 PQ colour space + EDR with a 203-nit reference-white anchor;
|
||||||
|
/// SDR uses the plain 8-bit sRGB path.
|
||||||
public func configure(hdr: Bool) {
|
public func configure(hdr: Bool) {
|
||||||
guard hdr != hdrActive else { return }
|
guard hdr != hdrActive else { return }
|
||||||
hdrActive = hdr
|
hdrActive = hdr
|
||||||
@@ -273,36 +292,65 @@ public final class MetalVideoPresenter {
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
/// Update the HDR mastering metadata (drained from the host's 0xCE datagram) to refine the system
|
/// Update the HDR mastering metadata (drained from the host's 0xCE datagram) to refine the system
|
||||||
/// tone-map from the real grade. Called from the PUMP thread, so the layer write is hopped to MAIN
|
/// tone-map from the real grade. Called from the PUMP thread — the grade is only PARKED here (lock-
|
||||||
/// (every CALayer mutation stays on one thread). The grade is cached so a later SDR→HDR
|
/// guarded); the render thread applies it at the top of the next `render`, keeping every layer
|
||||||
/// `configureColor` re-applies it; the `edrMetadata` write is gated on `hdrActive` (setting it on an
|
/// colour mutation on the one thread that also vends drawables.
|
||||||
/// SDR layer is harmless but pointless, and the flip will apply it anyway).
|
|
||||||
public func setHdrMeta(_ meta: PunktfunkConnection.HdrMeta) {
|
public func setHdrMeta(_ meta: PunktfunkConnection.HdrMeta) {
|
||||||
DispatchQueue.main.async { [weak self] in
|
stagingLock.lock()
|
||||||
guard let self else { return }
|
pendingHdrMeta = meta
|
||||||
self.lastHdrMeta = meta
|
stagingLock.unlock()
|
||||||
// tvOS has no edrMetadata — the cached grade is still kept above (harmless), it just can't
|
|
||||||
// be applied to the layer there. macOS/iOS refine the system tone-map from the real grade.
|
|
||||||
#if !os(tvOS)
|
|
||||||
if self.hdrActive { self.layer.edrMetadata = self.makeEDR(meta) }
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Draw one decoded frame to the next drawable and present it. MAIN THREAD (the display link).
|
/// Park the drawable pixel size the shader should render at: the metal layer's laid-out frame ×
|
||||||
/// `isHDR` selects the 10-bit BT.2020 PQ path vs the 8-bit BT.709 path and is reconciled with the
|
/// contentsScale, both owned by the MAIN thread (SessionPresenter.layout pushes it on every layout/
|
||||||
|
/// backing change). The render thread reads this instead of the layer's geometry so it never
|
||||||
|
/// touches main-owned CALayer state. Zero until the first layout → `render` falls back to the
|
||||||
|
/// decoded frame size.
|
||||||
|
public func setDrawableTarget(_ size: CGSize) {
|
||||||
|
stagingLock.lock()
|
||||||
|
drawableTarget = size
|
||||||
|
stagingLock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw one decoded frame to the next drawable and present it. RENDER THREAD (Stage2Pipeline's;
|
||||||
|
/// `nextDrawable()` may block up to a frame — that wait belongs here, never on main). `isHDR`
|
||||||
|
/// selects the 10-bit BT.2020 PQ path vs the 8-bit BT.709 path and is reconciled with the
|
||||||
/// layer config via `configure`. Returns true on success; false when there's no drawable yet, a
|
/// layer config via `configure`. Returns true on success; false when there's no drawable yet, a
|
||||||
/// texture couldn't be made, or Metal errored — the caller then doesn't stamp a present (and can
|
/// texture couldn't be made, or Metal errored — the caller then doesn't stamp a present (and can
|
||||||
/// requeue the frame). `onPresented` fires once the drawable actually reached glass, with the
|
/// requeue the frame). `onPresented` fires once the drawable actually reached glass, with the
|
||||||
/// `CLOCK_REALTIME` instant from the drawable's `presentedTime` — or nil when the system reports
|
/// `CLOCK_REALTIME` instant from the drawable's `presentedTime` — or nil when the system reports
|
||||||
/// none (a dropped drawable). It runs on a Metal callback thread; keep the handler thread-safe.
|
/// none (a dropped drawable). It runs on a Metal callback thread; keep the handler thread-safe.
|
||||||
|
///
|
||||||
|
/// `presentAtMediaTime` (a `CACurrentMediaTime`-basis host time — the display link's
|
||||||
|
/// `targetTimestamp`) schedules the flip ON the vsync instead of "as soon as the GPU finishes":
|
||||||
|
/// with the layer's own sync disabled (mandatory on macOS — see init) an immediate present hits
|
||||||
|
/// glass mid-refresh whenever the layer is direct-scanout promoted (fullscreen, no HUD), which
|
||||||
|
/// is the "frametimes are off with the stats HUD closed" report. nil presents immediately
|
||||||
|
/// (`PUNKTFUNK_PRESENT_MODE=immediate` — the pre-fix behavior, kept as a diagnostic A/B).
|
||||||
@discardableResult
|
@discardableResult
|
||||||
public func render(
|
public func render(
|
||||||
_ pixelBuffer: CVPixelBuffer, isHDR: Bool = false,
|
_ pixelBuffer: CVPixelBuffer, isHDR: Bool = false,
|
||||||
|
presentAtMediaTime: CFTimeInterval? = nil,
|
||||||
onPresented: ((Int64?) -> Void)? = nil
|
onPresented: ((Int64?) -> Void)? = nil
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
|
// Drain the cross-thread staging (see `stagingLock`): the layout-derived drawable size and
|
||||||
|
// any freshly-arrived HDR grade, both applied from this thread.
|
||||||
|
stagingLock.lock()
|
||||||
|
let targetFromLayout = drawableTarget
|
||||||
|
let newHdrMeta = pendingHdrMeta
|
||||||
|
pendingHdrMeta = nil
|
||||||
|
stagingLock.unlock()
|
||||||
|
|
||||||
// Reconcile the layer with the decoded frame's HDR-ness (handles a mid-session SDR↔HDR flip).
|
// Reconcile the layer with the decoded frame's HDR-ness (handles a mid-session SDR↔HDR flip).
|
||||||
configure(hdr: isHDR)
|
configure(hdr: isHDR)
|
||||||
|
if let newHdrMeta {
|
||||||
|
self.lastHdrMeta = newHdrMeta
|
||||||
|
// tvOS has no edrMetadata — the cached grade is still kept (a later HDR flip's
|
||||||
|
// configureColor is where it matters there). macOS/iOS refine the live tone-map now.
|
||||||
|
#if !os(tvOS)
|
||||||
|
if hdrActive { layer.edrMetadata = makeEDR(newHdrMeta) }
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
// P010/x444 store 10-bit luma/chroma in 16-bit samples → R16/RG16; NV12/444v is 8-bit → R8/RG8.
|
// P010/x444 store 10-bit luma/chroma in 16-bit samples → R16/RG16; NV12/444v is 8-bit → R8/RG8.
|
||||||
// Derived from the actual decoded buffer so a 4:4:4 (full chroma plane) frame just works.
|
// Derived from the actual decoded buffer so a 4:4:4 (full chroma plane) frame just works.
|
||||||
@@ -319,22 +367,18 @@ public final class MetalVideoPresenter {
|
|||||||
pixelBuffer, plane: 1, format: tenBit ? .rg16Unorm : .rg8Unorm, cache: textureCache)
|
pixelBuffer, plane: 1, format: tenBit ? .rg16Unorm : .rg8Unorm, cache: textureCache)
|
||||||
else { return false }
|
else { return false }
|
||||||
|
|
||||||
// Size the drawable to the LAYER's pixels (bounds × contentsScale, both set by the hosting
|
// Size the drawable to the LAYER's pixels (its laid-out frame × contentsScale, pushed here by
|
||||||
// view's layout) so the Catmull-Rom shader performs the decoded→on-screen scale in one pass:
|
// SessionPresenter.layout via `setDrawableTarget` — not read off the layer, whose geometry the
|
||||||
|
// main thread owns) so the Catmull-Rom shader performs the decoded→on-screen scale in one pass:
|
||||||
// a native-mode session stays exactly 1:1 (the kernel reduces to the identity texel), and a
|
// a native-mode session stays exactly 1:1 (the kernel reduces to the identity texel), and a
|
||||||
// window bigger than the host's mode gets bicubic luma instead of the compositor's bilinear.
|
// window bigger than the host's mode gets bicubic luma instead of the compositor's bilinear.
|
||||||
// Before the first layout (empty bounds) fall back to the decoded size. drawableSize does NOT
|
// Before the first layout (zero target) fall back to the decoded size. drawableSize does NOT
|
||||||
// track bounds (defaults to 0), so set it BEFORE nextDrawable; re-set only on a change
|
// track bounds (defaults to 0), so set it BEFORE nextDrawable; re-set only on a change
|
||||||
// (layout / Reconfigure / HDR flip — and every frame of a live resize, which is fine).
|
// (layout / Reconfigure / HDR flip — and every frame of a live resize, which is fine).
|
||||||
let decodedSize = CGSize(
|
let decodedSize = CGSize(
|
||||||
width: CVPixelBufferGetWidth(pixelBuffer), height: CVPixelBufferGetHeight(pixelBuffer))
|
width: CVPixelBufferGetWidth(pixelBuffer), height: CVPixelBufferGetHeight(pixelBuffer))
|
||||||
let scale = layer.contentsScale
|
let targetSize = (targetFromLayout.width > 0 && targetFromLayout.height > 0)
|
||||||
let boundsSize = layer.bounds.size
|
? targetFromLayout : decodedSize
|
||||||
let targetSize = (boundsSize.width > 0 && boundsSize.height > 0)
|
|
||||||
? CGSize(
|
|
||||||
width: (boundsSize.width * scale).rounded(),
|
|
||||||
height: (boundsSize.height * scale).rounded())
|
|
||||||
: decodedSize
|
|
||||||
if layer.drawableSize != targetSize { layer.drawableSize = targetSize }
|
if layer.drawableSize != targetSize { layer.drawableSize = targetSize }
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
logSizeIfChanged(decoded: decodedSize, drawable: targetSize)
|
logSizeIfChanged(decoded: decodedSize, drawable: targetSize)
|
||||||
@@ -374,7 +418,13 @@ public final class MetalVideoPresenter {
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
commandBuffer.present(drawable) // present at the next vsync — lowest latency
|
// Scheduled on the vsync when the pipeline gave us the link's target (see the doc comment);
|
||||||
|
// immediate otherwise. A target already in the past presents immediately — same thing.
|
||||||
|
if let presentAtMediaTime {
|
||||||
|
commandBuffer.present(drawable, atTime: presentAtMediaTime)
|
||||||
|
} else {
|
||||||
|
commandBuffer.present(drawable)
|
||||||
|
}
|
||||||
// Hold the CVMetalTextures + source pixel buffer (its IOSurface) alive until the GPU finishes
|
// Hold the CVMetalTextures + source pixel buffer (its IOSurface) alive until the GPU finishes
|
||||||
// sampling — releasing them at scope exit could free the backing mid-read.
|
// sampling — releasing them at scope exit could free the backing mid-read.
|
||||||
commandBuffer.addCompletedHandler { _ in _ = (luma, chroma, pixelBuffer) }
|
commandBuffer.addCompletedHandler { _ in _ = (luma, chroma, pixelBuffer) }
|
||||||
|
|||||||
@@ -68,10 +68,13 @@ final class SessionPresenter {
|
|||||||
baseLayer.addSublayer(metal)
|
baseLayer.addSublayer(metal)
|
||||||
metalLayer = metal
|
metalLayer = metal
|
||||||
stage2 = pipeline
|
stage2 = pipeline
|
||||||
|
// The link is the vsync CLOCK + putBack-retry nudge, not the presentation trigger
|
||||||
|
// (frame arrival is — see Stage2Pipeline's header). timestamp→targetTimestamp is the
|
||||||
|
// link's own report of the current refresh period (tracks VRR rate changes).
|
||||||
let proxy = DisplayLinkProxy { [weak self] link in
|
let proxy = DisplayLinkProxy { [weak self] link in
|
||||||
self?.stage2?.renderTick(
|
self?.stage2?.renderTick(
|
||||||
targetPresentNs: Stage2Pipeline.realtimeNs(
|
targetMediaTime: link.targetTimestamp,
|
||||||
forDisplayLinkTimestamp: link.targetTimestamp))
|
period: link.targetTimestamp - link.timestamp)
|
||||||
}
|
}
|
||||||
let link = makeDisplayLink(proxy, #selector(DisplayLinkProxy.tick(_:)))
|
let link = makeDisplayLink(proxy, #selector(DisplayLinkProxy.tick(_:)))
|
||||||
link.add(to: .main, forMode: .common)
|
link.add(to: .main, forMode: .common)
|
||||||
@@ -87,23 +90,37 @@ final class SessionPresenter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ask the display link for the stream's own cadence. iOS/tvOS-only: without an explicit
|
/// Hint the display link with the stream's cadence. On iOS/tvOS a range is always required:
|
||||||
/// range, ProMotion devices cap CADisplayLink at 60 Hz (iPhones additionally need
|
/// without one, ProMotion devices cap CADisplayLink at 60 Hz (iPhones additionally need
|
||||||
/// `CADisableMinimumFrameDurationOnPhone` in Info.plist), so a 120 fps stream would present
|
/// `CADisableMinimumFrameDurationOnPhone` in Info.plist), so a 120 fps stream would present
|
||||||
/// at half rate with the ring silently dropping every other frame. `maximum` allows up to
|
/// at half rate with the ring silently dropping every other frame. `maximum` allows up to 120
|
||||||
/// 120 so the system MAY tick faster than a sub-120 stream (each extra tick is a near-free
|
/// so the system MAY tick faster than a sub-120 stream (each extra tick is a near-free empty
|
||||||
/// empty `renderTick`, and presenting on a denser grid shortens the decode→glass wait); the
|
/// `renderTick`, and presenting on a denser grid shortens the decode→glass wait).
|
||||||
/// macOS NSView link already tracks its display and must NOT be capped to the stream rate.
|
///
|
||||||
|
/// The `allowVRR` setting (default on) widens that hint into a true variable-refresh request:
|
||||||
|
/// `preferred` = the stream rate with a low floor, so a ProMotion / adaptive-sync display can
|
||||||
|
/// drop its physical refresh to match the content. With VRR off we fall back to the proven
|
||||||
|
/// behavior — iOS keeps a 30 Hz floor; macOS leaves the NSView link at its display's native
|
||||||
|
/// rate (it already tracks the display and must NOT be capped to the stream rate).
|
||||||
/// Re-applied from `layout` so a mid-session `Reconfigure` picks up a new refresh.
|
/// Re-applied from `layout` so a mid-session `Reconfigure` picks up a new refresh.
|
||||||
private func syncFrameRate(hz: UInt32) {
|
private func syncFrameRate(hz: UInt32) {
|
||||||
#if !os(macOS)
|
|
||||||
guard hz > 0, let link = stage2Link else { return }
|
guard hz > 0, let link = stage2Link else { return }
|
||||||
let hzF = Float(hz)
|
let hzF = Float(hz)
|
||||||
if link.preferredFrameRateRange.preferred != hzF {
|
let allowVRR = UserDefaults.standard.object(forKey: DefaultsKey.allowVRR) as? Bool ?? true
|
||||||
link.preferredFrameRateRange = CAFrameRateRange(
|
#if os(macOS)
|
||||||
minimum: min(30, hzF), maximum: max(hzF, 120), preferred: hzF)
|
// Off: `.default` = the link free-runs at the display's native rate (pre-VRR behavior).
|
||||||
}
|
// On: request the content rate with a 24 Hz floor — capped at the display, never at the
|
||||||
|
// stream rate, so an adaptive-sync panel can track the stream.
|
||||||
|
let range: CAFrameRateRange = allowVRR
|
||||||
|
? CAFrameRateRange(minimum: min(hzF, 24), maximum: max(hzF, 120), preferred: hzF)
|
||||||
|
: .default
|
||||||
|
#else
|
||||||
|
// A range is mandatory here (see above); VRR only lowers the floor (24 vs 30) so the
|
||||||
|
// panel can drop deeper to match content on a sub-rate or momentarily stalling stream.
|
||||||
|
let floor = allowVRR ? min(hzF, 24) : min(hzF, 30)
|
||||||
|
let range = CAFrameRateRange(minimum: floor, maximum: max(hzF, 120), preferred: hzF)
|
||||||
#endif
|
#endif
|
||||||
|
if link.preferredFrameRateRange != range { link.preferredFrameRateRange = range }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Position the stage-2 metal sublayer aspect-fit in the hosting view (the host streams at the
|
/// Position the stage-2 metal sublayer aspect-fit in the hosting view (the host streams at the
|
||||||
@@ -127,6 +144,11 @@ final class SessionPresenter {
|
|||||||
metalLayer.contentsScale = contentsScale
|
metalLayer.contentsScale = contentsScale
|
||||||
metalLayer.frame = fit
|
metalLayer.frame = fit
|
||||||
CATransaction.commit()
|
CATransaction.commit()
|
||||||
|
// Hand the resulting pixel size to the render thread (it must not read layer geometry
|
||||||
|
// cross-thread) — this is what the presenter sizes its drawable to.
|
||||||
|
stage2?.setDrawableTarget(CGSize(
|
||||||
|
width: (fit.width * contentsScale).rounded(),
|
||||||
|
height: (fit.height * contentsScale).rounded()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stop the active pump/pipeline (≤ one poll timeout; stage-2 joins its pump) and detach the
|
/// Stop the active pump/pipeline (≤ one poll timeout; stage-2 joins its pump) and detach the
|
||||||
|
|||||||
@@ -1,25 +1,60 @@
|
|||||||
// Stage-2 presenter orchestrator: a pump thread pulls AUs → VideoDecoder; the decoder's async output
|
// Stage-2 presenter orchestrator. GOAL ARCHITECTURE (the result of the 2026-07 pacing saga —
|
||||||
// drops the newest decoded frame into a 1-slot ring; the hosting view's display link calls `renderTick`
|
// read this before touching presentation):
|
||||||
// once per vsync to draw + present the newest ready frame and stamp the unified latency stages
|
|
||||||
// (end-to-end capture→on-glass, plus the decode and display stage terms —
|
|
||||||
// design/stats-unification.md). Mirrors StreamPump's lifecycle (one per start; cancel is permanent).
|
|
||||||
//
|
//
|
||||||
// Threading: the pump runs on its own thread; the decoder callback on a VT thread; `renderTick` +
|
// net pump ──► VideoDecoder (VT async) ──► newest-wins 1-slot ring ──► RENDER THREAD ──► CAMetalLayer
|
||||||
// `start`/`stop` on the MAIN thread (the view's CADisplayLink fires there). Only the ring (lock-guarded)
|
//
|
||||||
// and the decoder/presenter (internally locked / main-hopped) cross threads.
|
// • The render thread is woken by FRAME ARRIVAL (the decoder callback signals it), never gated on
|
||||||
|
// the display link: on macOS the WindowServer's damage tracking / FramePacing does not count our
|
||||||
|
// out-of-band presents, so anything display-link-gated stalls exactly when the rest of the screen
|
||||||
|
// goes quiet (adaptive-sync displays idle the link down). A decoded frame is always presented
|
||||||
|
// promptly. The display link remains only as (a) a vsync CLOCK (phase + period, for the opt-in
|
||||||
|
// V-Sync policy below), (b) a retry tick for a frame that couldn't get a drawable (`putBack`),
|
||||||
|
// and (c) the iOS ProMotion rate hint.
|
||||||
|
// • The layer's own displaySyncEnabled stays FALSE on macOS — synced presents starve the drawable
|
||||||
|
// pool outright (see MetalVideoPresenter's init for the post-mortem).
|
||||||
|
// • Present policy is a USER SETTING (DefaultsKey.vsync; PUNKTFUNK_PRESENT_MODE=immediate|vsync
|
||||||
|
// overrides it for A/B), resolved once per session in start():
|
||||||
|
// – V-Sync OFF (default): present immediately — lowest latency, the long-proven behavior.
|
||||||
|
// – V-Sync ON: present(at: next vsync) predicted from the link's last phase/period, at most one
|
||||||
|
// period ahead by construction, falling back to immediate when the link data is stale — a
|
||||||
|
// schedule can never sit far in the future holding drawables hostage.
|
||||||
|
// • Rendering lives on its own thread so any `nextDrawable()` wait lands off-main (input, SwiftUI).
|
||||||
|
//
|
||||||
|
// The render thread also stamps the unified latency stages (end-to-end capture→on-glass + decode and
|
||||||
|
// display stage terms — design/stats-unification.md). Mirrors StreamPump's lifecycle (one per start;
|
||||||
|
// cancel is permanent). PUNKTFUNK_PRESENT_DEBUG=1 prints per-second pacing stats (see
|
||||||
|
// PresentDebugStats).
|
||||||
|
//
|
||||||
|
// Threading: the pump runs on its own thread; the decoder callback on a VT thread; the render loop on
|
||||||
|
// the render thread; `renderTick` + `start`/`stop` on the MAIN thread (the view's CADisplayLink fires
|
||||||
|
// there). Only the ring (lock-guarded), the vsync clock (lock-guarded), and the decoder/presenter
|
||||||
|
// (internally locked / staged) cross threads.
|
||||||
|
|
||||||
#if canImport(Metal) && canImport(QuartzCore)
|
#if canImport(Metal) && canImport(QuartzCore)
|
||||||
import AVFoundation
|
import AVFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
import QuartzCore
|
import QuartzCore
|
||||||
|
|
||||||
|
/// PUNKTFUNK_PRESENT_DEBUG=1: the render thread prints a once-per-second line with the decode
|
||||||
|
/// (ring-submit) rate, present rate, failed/empty wakes and the slowest render call — for
|
||||||
|
/// diagnosing pacing regressions without instruments. Plain print: the unbundled CLI client's
|
||||||
|
/// stdout is the cheapest reliable capture channel.
|
||||||
|
let presentDebug = ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENT_DEBUG"] == "1"
|
||||||
|
|
||||||
/// Newest-ready 1-slot ring: the decoder overwrites (drops the older undisplayed frame — lowest
|
/// Newest-ready 1-slot ring: the decoder overwrites (drops the older undisplayed frame — lowest
|
||||||
/// latency, no smoothing buffer), the display link takes-and-clears. Sendable; lock-guarded.
|
/// latency, no smoothing buffer), the display link takes-and-clears. Sendable; lock-guarded.
|
||||||
private final class ReadyRing: @unchecked Sendable {
|
private final class ReadyRing: @unchecked Sendable {
|
||||||
private let lock = NSLock()
|
private let lock = NSLock()
|
||||||
private var frame: ReadyFrame?
|
private var frame: ReadyFrame?
|
||||||
|
/// Ring submissions since the last `drainSubmitted` — the decode rate for the
|
||||||
|
/// PUNKTFUNK_PRESENT_DEBUG stat line.
|
||||||
|
private var submitted = 0
|
||||||
func submit(_ f: ReadyFrame) {
|
func submit(_ f: ReadyFrame) {
|
||||||
lock.lock(); frame = f; lock.unlock()
|
lock.lock(); frame = f; submitted += 1; lock.unlock()
|
||||||
|
}
|
||||||
|
func drainSubmitted() -> Int {
|
||||||
|
lock.lock(); defer { lock.unlock() }
|
||||||
|
let n = submitted; submitted = 0; return n
|
||||||
}
|
}
|
||||||
func take() -> ReadyFrame? {
|
func take() -> ReadyFrame? {
|
||||||
lock.lock(); defer { lock.unlock() }
|
lock.lock(); defer { lock.unlock() }
|
||||||
@@ -37,6 +72,86 @@ private final class ReadyRing: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The display's vsync grid as last reported by the display link (target timestamp + period,
|
||||||
|
/// `CACurrentMediaTime` basis), written on main by `renderTick`, read by the render thread to
|
||||||
|
/// schedule V-Sync-mode presents. A shared box (like `ReadyRing`) so neither thread captures the
|
||||||
|
/// pipeline itself. Sendable; lock-guarded.
|
||||||
|
private final class VsyncClock: @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var target: CFTimeInterval = 0
|
||||||
|
private var period: CFTimeInterval = 0
|
||||||
|
|
||||||
|
func set(target t: CFTimeInterval, period p: CFTimeInterval) {
|
||||||
|
lock.lock(); target = t; period = p; lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The next vsync at or after `now`, extrapolated from the last reported phase/period — by
|
||||||
|
/// construction less than one period ahead, so a scheduled present can never sit far in the
|
||||||
|
/// future holding its drawable. nil (⇒ present immediately) when the link has reported nothing
|
||||||
|
/// yet, its period is nonsense, or its data is STALE (an idle/suspended link on an
|
||||||
|
/// adaptive-sync display — exactly the case where scheduling onto its grid stalls the stream).
|
||||||
|
func nextVsync(after now: CFTimeInterval) -> CFTimeInterval? {
|
||||||
|
lock.lock(); defer { lock.unlock() }
|
||||||
|
guard period > 0.0005, target > 0, now - target < 0.25 else { return nil }
|
||||||
|
if target >= now { return target }
|
||||||
|
return target + ceil((now - target) / period) * period
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PUNKTFUNK_PRESENT_DEBUG=1 aggregation: one printed line per second from the render thread with
|
||||||
|
/// the decode rate, render outcomes, the slowest render call (≈ nextDrawable wait) and the deltas
|
||||||
|
/// between system-reported on-glass times (vsync-aligned presents show clean refresh-period
|
||||||
|
/// multiples; immediate flips scatter). Lock-guarded — `presented` lands on a Metal callback thread.
|
||||||
|
private final class PresentDebugStats: @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var last = CACurrentMediaTime()
|
||||||
|
private var ok = 0, failed = 0, empty = 0, dropped = 0
|
||||||
|
private var maxRenderMs = 0.0
|
||||||
|
private var lastGlassNs: Int64 = 0
|
||||||
|
private var glassDeltasMs: [Double] = []
|
||||||
|
|
||||||
|
func emptyWake() { lock.lock(); empty += 1; lock.unlock() }
|
||||||
|
|
||||||
|
func renderReturned(ok rendered: Bool, tookMs: Double) {
|
||||||
|
lock.lock()
|
||||||
|
if rendered { ok += 1 } else { failed += 1 }
|
||||||
|
maxRenderMs = max(maxRenderMs, tookMs)
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func presented(atNs: Int64?) {
|
||||||
|
lock.lock()
|
||||||
|
if let atNs {
|
||||||
|
if lastGlassNs > 0 { glassDeltasMs.append(Double(atNs - lastGlassNs) / 1e6) }
|
||||||
|
lastGlassNs = atNs
|
||||||
|
} else {
|
||||||
|
dropped += 1
|
||||||
|
}
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func flushIfDue(ring: ReadyRing) {
|
||||||
|
lock.lock()
|
||||||
|
let now = CACurrentMediaTime()
|
||||||
|
guard now - last >= 1 else { lock.unlock(); return }
|
||||||
|
last = now
|
||||||
|
let decoded = ring.drainSubmitted()
|
||||||
|
let deltas = glassDeltasMs.sorted()
|
||||||
|
let p50 = deltas.isEmpty ? 0 : deltas[deltas.count / 2]
|
||||||
|
let dMax = deltas.last ?? 0
|
||||||
|
let line = String(
|
||||||
|
format: "pf-present decoded=%d ok=%d fail=%d empty=%d dropped=%d "
|
||||||
|
+ "maxRenderMs=%.1f glassDeltaMs p50=%.2f max=%.2f n=%d",
|
||||||
|
decoded, ok, failed, empty, dropped, maxRenderMs, p50, dMax, deltas.count)
|
||||||
|
ok = 0; failed = 0; empty = 0; dropped = 0
|
||||||
|
maxRenderMs = 0
|
||||||
|
glassDeltasMs.removeAll(keepingCapacity: true)
|
||||||
|
lock.unlock()
|
||||||
|
print(line)
|
||||||
|
fflush(stdout) // stdout is a pipe when captured — flush per line or nothing shows
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public final class Stage2Pipeline {
|
public final class Stage2Pipeline {
|
||||||
private let ring = ReadyRing()
|
private let ring = ReadyRing()
|
||||||
private let presenter: MetalVideoPresenter
|
private let presenter: MetalVideoPresenter
|
||||||
@@ -54,6 +169,19 @@ public final class Stage2Pipeline {
|
|||||||
private let pumpStopped = DispatchSemaphore(value: 0)
|
private let pumpStopped = DispatchSemaphore(value: 0)
|
||||||
private var pumpJoinable = false
|
private var pumpJoinable = false
|
||||||
|
|
||||||
|
/// Render-thread plumbing. `renderSignal` wakes the render thread — signalled by the DECODER
|
||||||
|
/// callback on every frame (the primary trigger: presentation must never be gated on the
|
||||||
|
/// display link, see the header) and by each display-link tick (the `putBack` retry + the
|
||||||
|
/// vsync-clock refresh). Signals coalesce harmlessly (an extra wake finds an empty ring and
|
||||||
|
/// goes back to sleep). `vsyncClock` is the link's last phase/period for V-Sync-mode
|
||||||
|
/// scheduling. Lock-guarded boxes — the render thread, like the pump thread, must not capture
|
||||||
|
/// `self`, or a missed stop() would leak a spinning pipeline. `renderStopped`/`renderJoinable`
|
||||||
|
/// mirror the pump's bounded join.
|
||||||
|
private let renderSignal = DispatchSemaphore(value: 0)
|
||||||
|
private let vsyncClock = VsyncClock()
|
||||||
|
private let renderStopped = DispatchSemaphore(value: 0)
|
||||||
|
private var renderJoinable = false
|
||||||
|
|
||||||
/// The Metal layer the hosting view installs + sizes.
|
/// The Metal layer the hosting view installs + sizes.
|
||||||
public var layer: CAMetalLayer { presenter.layer }
|
public var layer: CAMetalLayer { presenter.layer }
|
||||||
|
|
||||||
@@ -74,6 +202,7 @@ public final class Stage2Pipeline {
|
|||||||
self.displayMeter = displayMeter
|
self.displayMeter = displayMeter
|
||||||
let ring = ring
|
let ring = ring
|
||||||
let recovery = recovery
|
let recovery = recovery
|
||||||
|
let renderSignal = renderSignal
|
||||||
self.decoder = VideoDecoder(
|
self.decoder = VideoDecoder(
|
||||||
onDecoded: { frame in
|
onDecoded: { frame in
|
||||||
// Decode stage = received→decoded, both client CLOCK_REALTIME (offset 0 — no
|
// Decode stage = received→decoded, both client CLOCK_REALTIME (offset 0 — no
|
||||||
@@ -82,6 +211,8 @@ public final class Stage2Pipeline {
|
|||||||
decodeMeter?.record(
|
decodeMeter?.record(
|
||||||
ptsNs: UInt64(frame.receivedNs), atNs: frame.decodedNs, offsetNs: 0)
|
ptsNs: UInt64(frame.receivedNs), atNs: frame.decodedNs, offsetNs: 0)
|
||||||
ring.submit(frame)
|
ring.submit(frame)
|
||||||
|
// FRAME ARRIVAL is the render trigger (never the display link — see the header).
|
||||||
|
renderSignal.signal()
|
||||||
},
|
},
|
||||||
// Async decode failure (a bad P-frame referencing a lost/corrupt IDR): the pump resets to
|
// Async decode failure (a bad P-frame referencing a lost/corrupt IDR): the pump resets to
|
||||||
// re-gate on the next IDR, and we ask the host to send one now (infinite GOP — it wouldn't
|
// re-gate on the next IDR, and we ask the host to send one now (infinite GOP — it wouldn't
|
||||||
@@ -146,7 +277,7 @@ public final class Stage2Pipeline {
|
|||||||
}
|
}
|
||||||
guard let au = try connection.nextAU(timeoutMs: 100) else { continue }
|
guard let au = try connection.nextAU(timeoutMs: 100) else { continue }
|
||||||
onFrame?(au)
|
onFrame?(au)
|
||||||
if let f = AnnexB.formatDescription(fromIDR: au.data, codec: connection.videoCodec) {
|
if let f = connection.videoCodec.formatDescription(fromKeyframe: au.data) {
|
||||||
format = f // refreshed on every IDR (mode changes included)
|
format = f // refreshed on every IDR (mode changes included)
|
||||||
awaitingIDR = false // a fresh IDR re-anchored decode — recovery complete
|
awaitingIDR = false // a fresh IDR re-anchored decode — recovery complete
|
||||||
}
|
}
|
||||||
@@ -176,34 +307,89 @@ public final class Stage2Pipeline {
|
|||||||
thread.qualityOfService = .userInteractive
|
thread.qualityOfService = .userInteractive
|
||||||
pumpJoinable = true
|
pumpJoinable = true
|
||||||
thread.start()
|
thread.start()
|
||||||
}
|
|
||||||
|
|
||||||
/// MAIN thread, once per vsync. Present the newest ready frame (if any). The latency stamps
|
// The render thread: one present per display-link signal. It owns every layer format/colour/
|
||||||
/// use the drawable's ACTUAL on-glass instant (`addPresentedHandler`/`presentedTime` — the
|
// drawable interaction (see MetalVideoPresenter's threading notes); with displaySyncEnabled on,
|
||||||
/// handler fires on a Metal callback thread; the meters are thread-safe), falling back to
|
// nextDrawable's up-to-a-frame wait lands here instead of on main. The 100 ms timed wait is
|
||||||
/// `targetPresentNs` — the display link's target present instant, already converted to
|
// only the stop-flag poll for a session whose link stopped ticking.
|
||||||
/// `CLOCK_REALTIME` (see `realtimeNs(forDisplayLinkTimestamp:)`) — when the system reports
|
let ring = ring
|
||||||
/// no presented time (a dropped drawable). A frame that could not be rendered (no drawable
|
|
||||||
/// yet) goes back into the ring so the next tick retries it.
|
|
||||||
public func renderTick(targetPresentNs: Int64) {
|
|
||||||
guard let frame = ring.take() else { return }
|
|
||||||
let offsetNs = offsetNs
|
|
||||||
let endToEndMeter = endToEndMeter
|
let endToEndMeter = endToEndMeter
|
||||||
let displayMeter = displayMeter
|
let displayMeter = displayMeter
|
||||||
let rendered = presenter.render(frame.pixelBuffer, isHDR: frame.isHDR) { presentedNs in
|
let offsetNs = offsetNs
|
||||||
let atNs = presentedNs ?? targetPresentNs
|
let renderSignal = renderSignal
|
||||||
|
let renderStopped = renderStopped
|
||||||
|
// Present policy — the user's V-Sync setting (default OFF = immediate, the long-proven
|
||||||
|
// lowest-latency behavior); PUNKTFUNK_PRESENT_MODE=immediate|vsync overrides it for A/B.
|
||||||
|
// Resolved once per session.
|
||||||
|
let presentMode = ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENT_MODE"]
|
||||||
|
let vsyncEnabled = presentMode == "vsync"
|
||||||
|
|| (presentMode != "immediate"
|
||||||
|
&& UserDefaults.standard.bool(forKey: DefaultsKey.vsync))
|
||||||
|
let debugStats = presentDebug ? PresentDebugStats() : nil
|
||||||
|
let vsyncClock = vsyncClock
|
||||||
|
let renderThread = Thread {
|
||||||
|
defer { renderStopped.signal() }
|
||||||
|
while !token.isStopped {
|
||||||
|
if renderSignal.wait(timeout: .now() + .milliseconds(100)) == .timedOut {
|
||||||
|
debugStats?.flushIfDue(ring: ring)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
guard !token.isStopped, let frame = ring.take() else {
|
||||||
|
debugStats?.emptyWake()
|
||||||
|
debugStats?.flushIfDue(ring: ring)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// V-Sync ON: flip on the next predicted vsync (< one period out, stale link ⇒
|
||||||
|
// immediate — see VsyncClock). OFF: flip as soon as the GPU finishes.
|
||||||
|
let presentAt = vsyncEnabled
|
||||||
|
? vsyncClock.nextVsync(after: CACurrentMediaTime()) : nil
|
||||||
|
let renderStarted = CACurrentMediaTime()
|
||||||
|
let rendered = presenter.render(
|
||||||
|
frame.pixelBuffer, isHDR: frame.isHDR, presentAtMediaTime: presentAt
|
||||||
|
) { presentedNs in
|
||||||
|
// Fallback stamp for a dropped drawable (no system presentedTime): "now" on
|
||||||
|
// the Metal callback, converted to the CLOCK_REALTIME the meters live in.
|
||||||
|
let atNs = presentedNs
|
||||||
|
?? Stage2Pipeline.realtimeNs(forDisplayLinkTimestamp: CACurrentMediaTime())
|
||||||
// End-to-end = capture→on-glass, measured directly (skew-corrected via the
|
// End-to-end = capture→on-glass, measured directly (skew-corrected via the
|
||||||
// connect-time clock offset) — the HUD headline.
|
// connect-time clock offset) — the HUD headline.
|
||||||
endToEndMeter?.record(ptsNs: frame.ptsNs, atNs: atNs, offsetNs: offsetNs)
|
endToEndMeter?.record(ptsNs: frame.ptsNs, atNs: atNs, offsetNs: offsetNs)
|
||||||
// Display stage = decoded → on-glass. Both instants are client CLOCK_REALTIME,
|
// Display stage = decoded → on-glass. Both instants are client CLOCK_REALTIME,
|
||||||
// so no skew offset applies.
|
// so no skew offset applies.
|
||||||
displayMeter?.record(ptsNs: UInt64(frame.decodedNs), atNs: atNs, offsetNs: 0)
|
displayMeter?.record(ptsNs: UInt64(frame.decodedNs), atNs: atNs, offsetNs: 0)
|
||||||
|
debugStats?.presented(atNs: presentedNs)
|
||||||
}
|
}
|
||||||
|
debugStats?.renderReturned(
|
||||||
|
ok: rendered, tookMs: (CACurrentMediaTime() - renderStarted) * 1000)
|
||||||
if !rendered { ring.putBack(frame) }
|
if !rendered { ring.putBack(frame) }
|
||||||
|
debugStats?.flushIfDue(ring: ring)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
renderThread.name = "punktfunk-stage2-render"
|
||||||
|
renderThread.qualityOfService = .userInteractive
|
||||||
|
renderJoinable = true
|
||||||
|
renderThread.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stop the pump (≤ one poll timeout) and drop the decode session. MAIN THREAD; idempotent. Does not
|
/// MAIN thread, once per display-link tick: refresh the vsync clock (V-Sync-mode scheduling)
|
||||||
/// close the connection. A restart needs a fresh Stage2Pipeline (the stop is permanent).
|
/// and nudge the render thread. The nudge is NOT the presentation trigger — frame arrival is
|
||||||
|
/// (see the header) — it only retries a frame a transient `nextDrawable` failure put back into
|
||||||
|
/// the ring, which matters under the host's infinite GOP where a static scene sends no
|
||||||
|
/// replacement frame.
|
||||||
|
public func renderTick(targetMediaTime: CFTimeInterval, period: CFTimeInterval) {
|
||||||
|
vsyncClock.set(target: targetMediaTime, period: period)
|
||||||
|
renderSignal.signal()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forward the layout-derived drawable pixel size to the presenter (MAIN thread — see
|
||||||
|
/// `MetalVideoPresenter.setDrawableTarget`).
|
||||||
|
public func setDrawableTarget(_ size: CGSize) {
|
||||||
|
presenter.setDrawableTarget(size)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop the pump + render thread (≤ one poll timeout each) and drop the decode session. MAIN
|
||||||
|
/// THREAD; idempotent. Does not close the connection. A restart needs a fresh Stage2Pipeline
|
||||||
|
/// (the stop is permanent).
|
||||||
public func stop() {
|
public func stop() {
|
||||||
token.stop()
|
token.stop()
|
||||||
// Join the pump (bounded: ≤ one nextAU poll + an in-flight decode) before resetting the decoder,
|
// Join the pump (bounded: ≤ one nextAU poll + an in-flight decode) before resetting the decoder,
|
||||||
@@ -213,11 +399,22 @@ public final class Stage2Pipeline {
|
|||||||
pumpJoinable = false
|
pumpJoinable = false
|
||||||
_ = pumpStopped.wait(timeout: .now() + 0.5)
|
_ = pumpStopped.wait(timeout: .now() + 0.5)
|
||||||
}
|
}
|
||||||
|
// Wake + join the render thread (bounded: it may sit in `nextDrawable` for up to ~a frame; a
|
||||||
|
// timed-out join is fine — the loop exits at its next stop-flag check, and a final present on
|
||||||
|
// the detached layer is harmless).
|
||||||
|
if renderJoinable {
|
||||||
|
renderJoinable = false
|
||||||
|
renderSignal.signal()
|
||||||
|
_ = renderStopped.wait(timeout: .now() + 0.5)
|
||||||
|
}
|
||||||
decoder.reset()
|
decoder.reset()
|
||||||
recovery.bind(nil) // stop requesting keyframes once the session is torn down
|
recovery.bind(nil) // stop requesting keyframes once the session is torn down
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit { token.stop() }
|
deinit {
|
||||||
|
token.stop()
|
||||||
|
renderSignal.signal() // wake the render thread so it can observe the stop and exit
|
||||||
|
}
|
||||||
|
|
||||||
/// Convert a `CADisplayLink.targetTimestamp` (CACurrentMediaTime basis) to a `CLOCK_REALTIME`
|
/// Convert a `CADisplayLink.targetTimestamp` (CACurrentMediaTime basis) to a `CLOCK_REALTIME`
|
||||||
/// nanosecond instant — the present clock the AU pts + skew offset live in. Projects to the target
|
/// nanosecond instant — the present clock the AU pts + skew offset live in. Projects to the target
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ final class StreamPump {
|
|||||||
|
|
||||||
guard let au = try connection.nextAU(timeoutMs: 100) else { continue }
|
guard let au = try connection.nextAU(timeoutMs: 100) else { continue }
|
||||||
onFrame?(au)
|
onFrame?(au)
|
||||||
let idrFormat = AnnexB.formatDescription(fromIDR: au.data, codec: connection.videoCodec)
|
let idrFormat = connection.videoCodec.formatDescription(fromKeyframe: au.data)
|
||||||
if let f = idrFormat {
|
if let f = idrFormat {
|
||||||
format = f // refreshed on every IDR (mode changes included)
|
format = f // refreshed on every IDR (mode changes included)
|
||||||
if awaitingIDR {
|
if awaitingIDR {
|
||||||
@@ -95,7 +95,7 @@ final class StreamPump {
|
|||||||
}
|
}
|
||||||
wasFailed = failed
|
wasFailed = failed
|
||||||
guard let f = format,
|
guard let f = format,
|
||||||
let sample = AnnexB.sampleBuffer(au: au, format: f, codec: connection.videoCodec),
|
let sample = connection.videoCodec.sampleBuffer(au: au, format: f),
|
||||||
!token.isStopped // don't enqueue a stale frame after a restart
|
!token.isStopped // don't enqueue a stale frame after a restart
|
||||||
else { continue }
|
else { continue }
|
||||||
layer.enqueue(sample)
|
layer.enqueue(sample)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
// Stage-2 presenter, decode half: explicit VideoToolbox decode of the host's HEVC AUs.
|
// Stage-2 presenter, decode half: explicit VideoToolbox decode of the host's AUs (H.264 /
|
||||||
|
// HEVC / AV1 — whatever the Welcome resolved).
|
||||||
//
|
//
|
||||||
// Stage-1 hands compressed samples to AVSampleBufferDisplayLayer, which decodes AND presents
|
// Stage-1 hands compressed samples to AVSampleBufferDisplayLayer, which decodes AND presents
|
||||||
// internally with no per-frame callback — so neither decode-completion nor present can be
|
// internally with no per-frame callback — so neither decode-completion nor present can be
|
||||||
@@ -61,8 +62,8 @@ public final class VideoDecoder: @unchecked Sendable {
|
|||||||
/// depth / HDR). Read inside `createSessionLocked` under `lock`.
|
/// depth / HDR). Read inside `createSessionLocked` under `lock`.
|
||||||
private var chroma444 = false
|
private var chroma444 = false
|
||||||
|
|
||||||
/// The negotiated codec (`connection.videoCodec`), set once at session start. Drives the AnnexB
|
/// The negotiated codec (`connection.videoCodec`), set once at session start. Drives the
|
||||||
/// NAL parsing (H.264 vs HEVC parameter sets). Read under `lock`.
|
/// bitstream framing (H.264/HEVC NAL parsing vs AV1 OBU repack). Read under `lock`.
|
||||||
private var codec: VideoCodec = .hevc
|
private var codec: VideoCodec = .hevc
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
@@ -84,8 +85,8 @@ public final class VideoDecoder: @unchecked Sendable {
|
|||||||
lock.unlock()
|
lock.unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Select the negotiated codec (H.264 vs HEVC). Call once at session start, before decoding,
|
/// Select the negotiated codec (H.264 / HEVC / AV1). Call once at session start, before
|
||||||
/// from `connection.videoCodec`. Thread-safe.
|
/// decoding, from `connection.videoCodec`. Thread-safe.
|
||||||
public func setCodec(_ c: VideoCodec) {
|
public func setCodec(_ c: VideoCodec) {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
codec = c
|
codec = c
|
||||||
@@ -93,8 +94,9 @@ public final class VideoDecoder: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Submit one AU for asynchronous decode, (re)creating the session if `format` changed. The
|
/// Submit one AU for asynchronous decode, (re)creating the session if `format` changed. The
|
||||||
/// caller resolves `format` from the IDR exactly as stage-1 does (`AnnexB.formatDescription`).
|
/// caller resolves `format` from the keyframe exactly as stage-1 does
|
||||||
/// Returns false if the session couldn't be created or the frame couldn't be submitted.
|
/// (`VideoCodec.formatDescription(fromKeyframe:)`). Returns false if the session couldn't be
|
||||||
|
/// created or the frame couldn't be submitted.
|
||||||
@discardableResult
|
@discardableResult
|
||||||
public func decode(au: AccessUnit, format newFormat: CMVideoFormatDescription) -> Bool {
|
public func decode(au: AccessUnit, format newFormat: CMVideoFormatDescription) -> Bool {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
@@ -112,7 +114,7 @@ public final class VideoDecoder: @unchecked Sendable {
|
|||||||
// invalidate the session between here and DecodeFrame. The VT output callback takes the
|
// invalidate the session between here and DecodeFrame. The VT output callback takes the
|
||||||
// ring lock, not this one, so there's no re-entrancy. DecodeFrame is async — non-blocking.
|
// ring lock, not this one, so there's no re-entrancy. DecodeFrame is async — non-blocking.
|
||||||
guard let session,
|
guard let session,
|
||||||
let sample = AnnexB.sampleBuffer(au: au, format: newFormat, codec: codec)
|
let sample = codec.sampleBuffer(au: au, format: newFormat)
|
||||||
else { lock.unlock(); return false }
|
else { lock.unlock(); return false }
|
||||||
var infoOut = VTDecodeInfoFlags()
|
var infoOut = VTDecodeInfoFlags()
|
||||||
let status = VTDecompressionSessionDecodeFrame(
|
let status = VTDecompressionSessionDecodeFrame(
|
||||||
@@ -199,13 +201,14 @@ public final class VideoDecoder: @unchecked Sendable {
|
|||||||
var callback = VTDecompressionOutputCallbackRecord(
|
var callback = VTDecompressionOutputCallbackRecord(
|
||||||
decompressionOutputCallback: decoderOutputCallback,
|
decompressionOutputCallback: decoderOutputCallback,
|
||||||
decompressionOutputRefCon: Unmanaged.passUnretained(self).toOpaque())
|
decompressionOutputRefCon: Unmanaged.passUnretained(self).toOpaque())
|
||||||
// 4:4:4 sessions REQUIRE a hardware decoder: we only advertise 4:4:4 when the hardware probe
|
// 4:4:4 and AV1 sessions REQUIRE a hardware decoder: both are only advertised when the
|
||||||
// passed, so a hardware-incapable mode (e.g. a resolution past the HW 4:4:4 ceiling) must fail
|
// hardware gate passed (the 4:4:4 probe / `AV1.hardwareDecodeSupported`), so a
|
||||||
// HERE, synchronously, letting the pump's backstop end the session — rather than silently
|
// hardware-incapable mode (e.g. a resolution past a HW ceiling) must fail HERE,
|
||||||
// falling back to a software 4:4:4 decoder far too slow for a real-time stream. 4:2:0 keeps the
|
// synchronously, letting the pump's backstop end the session — rather than silently
|
||||||
// software fallback (nil spec) as a robustness net.
|
// falling back to a software decoder far too slow for a real-time stream. 4:2:0
|
||||||
|
// H.264/HEVC keeps the software fallback (nil spec) as a robustness net.
|
||||||
let spec: CFDictionary? =
|
let spec: CFDictionary? =
|
||||||
chroma444
|
chroma444 || codec == .av1
|
||||||
? [kVTVideoDecoderSpecification_RequireHardwareAcceleratedVideoDecoder: true] as CFDictionary
|
? [kVTVideoDecoderSpecification_RequireHardwareAcceleratedVideoDecoder: true] as CFDictionary
|
||||||
: nil
|
: nil
|
||||||
var newSession: VTDecompressionSession?
|
var newSession: VTDecompressionSession?
|
||||||
|
|||||||
@@ -7,10 +7,11 @@
|
|||||||
//
|
//
|
||||||
// The view also owns the input-capture state machine (Moonlight-style): capture is a
|
// The view also owns the input-capture state machine (Moonlight-style): capture is a
|
||||||
// deliberate, reversible state — engaged when the stream starts and when the user clicks
|
// deliberate, reversible state — engaged when the stream starts and when the user clicks
|
||||||
// into the video, released by ⌘⎋ or focus loss, and NEVER engaged by mere app
|
// into the video, released by ⌃⌥⇧Q (the cross-client Ctrl+Alt+Shift+Q), ⌘⎋, or focus
|
||||||
// activation (the click that activates the window may be a title-bar drag or a resize —
|
// loss, and NEVER engaged by mere app activation (the click that activates the window may
|
||||||
// warping the cursor there is exactly the intrusiveness this design removes). While
|
// be a title-bar drag or a resize — warping the cursor there is exactly the intrusiveness
|
||||||
// released, nothing is forwarded to the host and the local cursor is free.
|
// this design removes). While released, nothing is forwarded to the host and the local
|
||||||
|
// cursor is free.
|
||||||
//
|
//
|
||||||
// macOS-first (NSViewRepresentable); the iOS variant is the same layer under
|
// macOS-first (NSViewRepresentable); the iOS variant is the same layer under
|
||||||
// UIViewRepresentable.
|
// UIViewRepresentable.
|
||||||
@@ -83,6 +84,7 @@ public struct StreamView: NSViewRepresentable {
|
|||||||
private let connection: PunktfunkConnection
|
private let connection: PunktfunkConnection
|
||||||
private let captureEnabled: Bool
|
private let captureEnabled: Bool
|
||||||
private let onCaptureChange: ((Bool) -> Void)?
|
private let onCaptureChange: ((Bool) -> Void)?
|
||||||
|
private let onDisconnectRequest: (() -> Void)?
|
||||||
private let onFrame: (@Sendable (AccessUnit) -> Void)?
|
private let onFrame: (@Sendable (AccessUnit) -> Void)?
|
||||||
private let onSessionEnd: (@Sendable () -> Void)?
|
private let onSessionEnd: (@Sendable () -> Void)?
|
||||||
private let endToEndMeter: LatencyMeter?
|
private let endToEndMeter: LatencyMeter?
|
||||||
@@ -93,14 +95,17 @@ public struct StreamView: NSViewRepresentable {
|
|||||||
/// `captureEnabled: false` disables input capture entirely while UI (e.g. a trust
|
/// `captureEnabled: false` disables input capture entirely while UI (e.g. a trust
|
||||||
/// prompt) is layered over the stream; flipping it to true auto-engages capture
|
/// prompt) is layered over the stream; flipping it to true auto-engages capture
|
||||||
/// once. `onCaptureChange` (main thread) reports engage/release — drive the HUD's
|
/// once. `onCaptureChange` (main thread) reports engage/release — drive the HUD's
|
||||||
/// "click to capture" / "⌘⎋ releases" hint with it. The meters record the unified latency
|
/// "click to capture" / "⌃⌥⇧Q releases" hint with it. `onDisconnectRequest` (main
|
||||||
/// stages when the stage-2 presenter is active (design/stats-unification.md):
|
/// thread) fires on the reserved ⌃⌥⇧D combo while captured — the owner ends the
|
||||||
/// `endToEndMeter` capture→on-glass, `decodeMeter` received→decoded, `displayMeter`
|
/// session (released, the same combo reaches the Stream menu instead). The meters
|
||||||
/// decoded→on-glass.
|
/// record the unified latency stages when the stage-2 presenter is active
|
||||||
|
/// (design/stats-unification.md): `endToEndMeter` capture→on-glass, `decodeMeter`
|
||||||
|
/// received→decoded, `displayMeter` decoded→on-glass.
|
||||||
public init(
|
public init(
|
||||||
connection: PunktfunkConnection,
|
connection: PunktfunkConnection,
|
||||||
captureEnabled: Bool = true,
|
captureEnabled: Bool = true,
|
||||||
onCaptureChange: ((Bool) -> Void)? = nil,
|
onCaptureChange: ((Bool) -> Void)? = nil,
|
||||||
|
onDisconnectRequest: (() -> Void)? = nil,
|
||||||
onFrame: (@Sendable (AccessUnit) -> Void)? = nil,
|
onFrame: (@Sendable (AccessUnit) -> Void)? = nil,
|
||||||
onSessionEnd: (@Sendable () -> Void)? = nil,
|
onSessionEnd: (@Sendable () -> Void)? = nil,
|
||||||
endToEndMeter: LatencyMeter? = nil,
|
endToEndMeter: LatencyMeter? = nil,
|
||||||
@@ -110,6 +115,7 @@ public struct StreamView: NSViewRepresentable {
|
|||||||
self.connection = connection
|
self.connection = connection
|
||||||
self.captureEnabled = captureEnabled
|
self.captureEnabled = captureEnabled
|
||||||
self.onCaptureChange = onCaptureChange
|
self.onCaptureChange = onCaptureChange
|
||||||
|
self.onDisconnectRequest = onDisconnectRequest
|
||||||
self.onFrame = onFrame
|
self.onFrame = onFrame
|
||||||
self.onSessionEnd = onSessionEnd
|
self.onSessionEnd = onSessionEnd
|
||||||
self.endToEndMeter = endToEndMeter
|
self.endToEndMeter = endToEndMeter
|
||||||
@@ -120,6 +126,7 @@ public struct StreamView: NSViewRepresentable {
|
|||||||
public func makeNSView(context: Context) -> StreamLayerView {
|
public func makeNSView(context: Context) -> StreamLayerView {
|
||||||
let view = StreamLayerView()
|
let view = StreamLayerView()
|
||||||
view.onCaptureChange = onCaptureChange
|
view.onCaptureChange = onCaptureChange
|
||||||
|
view.onDisconnectRequest = onDisconnectRequest
|
||||||
view.captureEnabled = captureEnabled
|
view.captureEnabled = captureEnabled
|
||||||
view.endToEndMeter = endToEndMeter
|
view.endToEndMeter = endToEndMeter
|
||||||
view.decodeMeter = decodeMeter
|
view.decodeMeter = decodeMeter
|
||||||
@@ -130,6 +137,7 @@ public struct StreamView: NSViewRepresentable {
|
|||||||
|
|
||||||
public func updateNSView(_ view: StreamLayerView, context: Context) {
|
public func updateNSView(_ view: StreamLayerView, context: Context) {
|
||||||
view.onCaptureChange = onCaptureChange
|
view.onCaptureChange = onCaptureChange
|
||||||
|
view.onDisconnectRequest = onDisconnectRequest
|
||||||
view.captureEnabled = captureEnabled
|
view.captureEnabled = captureEnabled
|
||||||
view.endToEndMeter = endToEndMeter
|
view.endToEndMeter = endToEndMeter
|
||||||
view.decodeMeter = decodeMeter
|
view.decodeMeter = decodeMeter
|
||||||
@@ -189,6 +197,10 @@ public final class StreamLayerView: NSView {
|
|||||||
/// Reports engage/release on the main thread.
|
/// Reports engage/release on the main thread.
|
||||||
public var onCaptureChange: ((Bool) -> Void)?
|
public var onCaptureChange: ((Bool) -> Void)?
|
||||||
|
|
||||||
|
/// Fired (main thread) when the captured-state ⌃⌥⇧D combo asks to end the session — the
|
||||||
|
/// view can't do that itself (the connection's owner disconnects).
|
||||||
|
public var onDisconnectRequest: (() -> Void)?
|
||||||
|
|
||||||
/// Main-thread only. False = input capture disabled outright (UI layered over the
|
/// Main-thread only. False = input capture disabled outright (UI layered over the
|
||||||
/// stream); flipping to true auto-engages once.
|
/// stream); flipping to true auto-engages once.
|
||||||
public var captureEnabled = true {
|
public var captureEnabled = true {
|
||||||
@@ -215,6 +227,16 @@ public final class StreamLayerView: NSView {
|
|||||||
) { [weak self] _ in
|
) { [weak self] _ in
|
||||||
self?.releaseCapture()
|
self?.releaseCapture()
|
||||||
})
|
})
|
||||||
|
// The Stream menu's "Release Mouse" item (⌃⌥⇧Q's discoverable menu-bar surface). Only
|
||||||
|
// the key window's stream may act — same ownership rule as the ⌘⎋ toggle. (While
|
||||||
|
// captured the combo never reaches the menu — InputCapture's monitor handles it — so
|
||||||
|
// in practice this fires only as a not-captured no-op; wired for honesty.)
|
||||||
|
appObservers.append(NotificationCenter.default.addObserver(
|
||||||
|
forName: .punktfunkReleaseCapture, object: nil, queue: .main
|
||||||
|
) { [weak self] _ in
|
||||||
|
guard let self, self.window?.isKeyWindow == true else { return }
|
||||||
|
self.releaseCapture()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
public required init?(coder: NSCoder) { fatalError("not used") }
|
public required init?(coder: NSCoder) { fatalError("not used") }
|
||||||
@@ -562,6 +584,24 @@ public final class StreamLayerView: NSView {
|
|||||||
// (see the cursorVisible resolution below): toggling it on under gamescope's relative-only
|
// (see the cursorVisible resolution below): toggling it on under gamescope's relative-only
|
||||||
// input traps the pointer. Restore this body when absolute/synthetic-cursor support lands.
|
// input traps the pointer. Restore this body when absolute/synthetic-cursor support lands.
|
||||||
capture.onToggleCursor = {}
|
capture.onToggleCursor = {}
|
||||||
|
// The cross-client combos (⌃⌥⇧Q/D/S — Ctrl+Alt+Shift on the other clients), delivered by
|
||||||
|
// the monitor only while captured; the same key-window ownership rule as ⌘⎋ throughout.
|
||||||
|
capture.onReleaseCapture = { [weak self] in
|
||||||
|
guard let self, self.window?.isKeyWindow == true else { return }
|
||||||
|
self.releaseCapture()
|
||||||
|
}
|
||||||
|
capture.onDisconnect = { [weak self] in
|
||||||
|
guard let self, self.window?.isKeyWindow == true else { return }
|
||||||
|
self.onDisconnectRequest?()
|
||||||
|
}
|
||||||
|
capture.onToggleStats = { [weak self] in
|
||||||
|
guard self?.window?.isKeyWindow == true else { return }
|
||||||
|
// Flip the shared setting directly — every @AppStorage reader (the HUD's visibility,
|
||||||
|
// the menu item's title) observes UserDefaults, so this is the same as the menu path.
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
let current = defaults.object(forKey: DefaultsKey.hudEnabled) as? Bool ?? true
|
||||||
|
defaults.set(!current, forKey: DefaultsKey.hudEnabled)
|
||||||
|
}
|
||||||
capture.start()
|
capture.start()
|
||||||
inputCapture = capture
|
inputCapture = capture
|
||||||
|
|
||||||
|
|||||||
@@ -54,10 +54,15 @@ public struct StreamView: UIViewControllerRepresentable {
|
|||||||
private let decodeMeter: LatencyMeter?
|
private let decodeMeter: LatencyMeter?
|
||||||
private let displayMeter: LatencyMeter?
|
private let displayMeter: LatencyMeter?
|
||||||
|
|
||||||
|
/// `onDisconnectRequest` exists for call-site parity with the macOS StreamView (the
|
||||||
|
/// captured-state ⌃⌥⇧D combo is detected by the macOS NSEvent monitor only); on iOS a
|
||||||
|
/// hardware keyboard reaches Disconnect through the Stream menu's key equivalent instead,
|
||||||
|
/// so the parameter is accepted and unused here.
|
||||||
public init(
|
public init(
|
||||||
connection: PunktfunkConnection,
|
connection: PunktfunkConnection,
|
||||||
captureEnabled: Bool = true,
|
captureEnabled: Bool = true,
|
||||||
onCaptureChange: ((Bool) -> Void)? = nil,
|
onCaptureChange: ((Bool) -> Void)? = nil,
|
||||||
|
onDisconnectRequest: (() -> Void)? = nil,
|
||||||
onFrame: (@Sendable (AccessUnit) -> Void)? = nil,
|
onFrame: (@Sendable (AccessUnit) -> Void)? = nil,
|
||||||
onSessionEnd: (@Sendable () -> Void)? = nil,
|
onSessionEnd: (@Sendable () -> Void)? = nil,
|
||||||
endToEndMeter: LatencyMeter? = nil,
|
endToEndMeter: LatencyMeter? = nil,
|
||||||
|
|||||||
@@ -0,0 +1,306 @@
|
|||||||
|
// Real-bitstream tests for the AV1 OBU → CoreMedia plumbing (AV1.swift): the OBU walk, the
|
||||||
|
// sequence-header parse, the `av1C` format description, the sample repack, the VideoCodec
|
||||||
|
// dispatch — and, on AV1-hardware devices, a real VTDecompressionSession decode of the blob
|
||||||
|
// (the AV1 counterpart of VideoToolboxRoundTripTests; there is no VT AV1 *encoder*, so the
|
||||||
|
// bitstream is generated offline).
|
||||||
|
//
|
||||||
|
// Blobs: the first two temporal units of an SVT-AV1 clip — a keyframe TU (temporal delimiter +
|
||||||
|
// sequence header + frame) and a delta TU (temporal delimiter + frame), exactly the wire shape
|
||||||
|
// the punktfunk host emits. Generated with:
|
||||||
|
// ffmpeg -f lavfi -i testsrc2=size=320x180:rate=30 -frames:v 2 \
|
||||||
|
// -c:v libsvtav1 -preset 12 -crf 63 -g 30 -f obu out.obu
|
||||||
|
// then split on the temporal-delimiter OBUs. 320×180 clears the hardware decoder's
|
||||||
|
// minimum-dimension floor (see Probe444Blobs). Ground truth (ffprobe + a reference parse):
|
||||||
|
// Main profile (0), level_idx 0, tier 0, 8-bit, 4:2:0, no color description (unspecified),
|
||||||
|
// studio range, max frame 320×180, chroma sample position 0.
|
||||||
|
|
||||||
|
import CoreMedia
|
||||||
|
import VideoToolbox
|
||||||
|
import XCTest
|
||||||
|
@testable import PunktfunkKit
|
||||||
|
|
||||||
|
/// Sendable holder for the values the (background-thread) decode callback writes.
|
||||||
|
private final class FrameBox: @unchecked Sendable {
|
||||||
|
let lock = NSLock()
|
||||||
|
var frame: ReadyFrame?
|
||||||
|
var error: OSStatus?
|
||||||
|
}
|
||||||
|
|
||||||
|
final class AV1Tests: XCTestCase {
|
||||||
|
// MARK: - OBU walk
|
||||||
|
|
||||||
|
func testOBUWalkKeyframe() {
|
||||||
|
var seen: [(type: UInt8, payloadCount: Int)] = []
|
||||||
|
var lastEnd = 0
|
||||||
|
AV1.forEachOBU(in: Data(Self.keyframeTU)) { _, header, payload, type in
|
||||||
|
XCTAssertEqual(header.lowerBound, lastEnd, "OBUs must be contiguous")
|
||||||
|
XCTAssertEqual(header.upperBound, payload.lowerBound)
|
||||||
|
lastEnd = payload.upperBound
|
||||||
|
seen.append((type, payload.count))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
XCTAssertEqual(lastEnd, Self.keyframeTU.count, "walk must cover the whole TU")
|
||||||
|
XCTAssertEqual(seen.map(\.type), [
|
||||||
|
AV1.OBUType.temporalDelimiter, AV1.OBUType.sequenceHeader, 6, // 6 = OBU_FRAME
|
||||||
|
])
|
||||||
|
XCTAssertEqual(seen[0].payloadCount, 0)
|
||||||
|
XCTAssertEqual(seen[1].payloadCount, 11)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOBUWalkStopsEarly() {
|
||||||
|
var calls = 0
|
||||||
|
AV1.forEachOBU(in: Data(Self.keyframeTU)) { _, _, _, _ in
|
||||||
|
calls += 1
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
XCTAssertEqual(calls, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOBUWalkRejectsGarbage() {
|
||||||
|
// 0x80 = forbidden bit set: not an OBU stream, the walk must not call the body.
|
||||||
|
AV1.forEachOBU(in: Data([0x80, 0x00, 0x01])) { _, _, _, _ in
|
||||||
|
XCTFail("garbage must not yield OBUs")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// A size field overrunning the buffer stops the walk at the previous OBU.
|
||||||
|
var truncated = Data([0x12, 0x00]) // valid TD
|
||||||
|
truncated.append(contentsOf: [0x0A, 0x7F, 0x01]) // seq header claiming 127 bytes, 1 present
|
||||||
|
var types: [UInt8] = []
|
||||||
|
AV1.forEachOBU(in: truncated) { _, _, _, type in
|
||||||
|
types.append(type)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
XCTAssertEqual(types, [AV1.OBUType.temporalDelimiter])
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sequence header
|
||||||
|
|
||||||
|
func testSequenceHeaderParse() throws {
|
||||||
|
// The sequence-header OBU payload sits at bytes 4..<15 (TD 2 bytes, header+size 2 bytes).
|
||||||
|
let payload = Data(Self.keyframeTU[4..<15])
|
||||||
|
let sh = try XCTUnwrap(AV1.parseSequenceHeader(payload))
|
||||||
|
XCTAssertEqual(sh.profile, 0) // Main
|
||||||
|
XCTAssertEqual(sh.levelIdx0, 0)
|
||||||
|
XCTAssertEqual(sh.tier0, 0)
|
||||||
|
XCTAssertFalse(sh.highBitdepth)
|
||||||
|
XCTAssertFalse(sh.twelveBit)
|
||||||
|
XCTAssertFalse(sh.monochrome)
|
||||||
|
XCTAssertTrue(sh.subsamplingX) // profile 0 ⇒ 4:2:0
|
||||||
|
XCTAssertTrue(sh.subsamplingY)
|
||||||
|
XCTAssertEqual(sh.chromaSamplePosition, 0)
|
||||||
|
XCTAssertEqual(sh.colorPrimaries, 2) // no color description ⇒ unspecified
|
||||||
|
XCTAssertEqual(sh.transferCharacteristics, 2)
|
||||||
|
XCTAssertEqual(sh.matrixCoefficients, 2)
|
||||||
|
XCTAssertFalse(sh.fullRange)
|
||||||
|
XCTAssertEqual(sh.maxWidth, 320)
|
||||||
|
XCTAssertEqual(sh.maxHeight, 180)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSequenceHeaderRejectsTruncation() {
|
||||||
|
// The parse consumes exactly 79 bits of this header (it stops after
|
||||||
|
// chroma_sample_position — the last field av1C needs), so 10 bytes suffice and the
|
||||||
|
// 11th only carries fields past the parse. Everything shorter must fail cleanly.
|
||||||
|
let payload = Data(Self.keyframeTU[4..<15])
|
||||||
|
for cut in 0..<10 {
|
||||||
|
XCTAssertNil(
|
||||||
|
AV1.parseSequenceHeader(payload.prefix(cut)),
|
||||||
|
"a header truncated to \(cut) bytes must not parse")
|
||||||
|
}
|
||||||
|
XCTAssertNotNil(AV1.parseSequenceHeader(payload.prefix(10)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Format description
|
||||||
|
|
||||||
|
func testFormatDescriptionFromKeyframe() throws {
|
||||||
|
let format = try XCTUnwrap(AV1.formatDescription(fromKeyframe: Data(Self.keyframeTU)))
|
||||||
|
XCTAssertEqual(CMFormatDescriptionGetMediaSubType(format), kCMVideoCodecType_AV1)
|
||||||
|
let dims = CMVideoFormatDescriptionGetDimensions(format)
|
||||||
|
XCTAssertEqual(dims.width, 320)
|
||||||
|
XCTAssertEqual(dims.height, 180)
|
||||||
|
|
||||||
|
// The av1C record: marker/version, profile+level, the packed flags byte (4:2:0, 8-bit,
|
||||||
|
// csp 0 → 0x0C), no presentation delay — then the sequence-header OBU verbatim.
|
||||||
|
let atoms = try XCTUnwrap(
|
||||||
|
CMFormatDescriptionGetExtension(
|
||||||
|
format,
|
||||||
|
extensionKey: kCMFormatDescriptionExtension_SampleDescriptionExtensionAtoms)
|
||||||
|
as? [String: Any])
|
||||||
|
let av1C = try XCTUnwrap(atoms["av1C"] as? Data)
|
||||||
|
XCTAssertEqual([UInt8](av1C.prefix(4)), [0x81, 0x00, 0x0C, 0x00])
|
||||||
|
XCTAssertEqual([UInt8](av1C.dropFirst(4)), [UInt8](Self.keyframeTU[2..<15]),
|
||||||
|
"configOBUs must be the size-fielded sequence-header OBU")
|
||||||
|
|
||||||
|
// Unspecified color codes fall back to BT.709 studio range — and the transfer-function
|
||||||
|
// extension is what keeps VideoDecoder.isHDRFormat working for AV1.
|
||||||
|
let transfer = CMFormatDescriptionGetExtension(
|
||||||
|
format, extensionKey: kCMFormatDescriptionExtension_TransferFunction) as? String
|
||||||
|
XCTAssertEqual(transfer, kCMFormatDescriptionTransferFunction_ITU_R_709_2 as String)
|
||||||
|
XCTAssertFalse(VideoDecoder.isHDRFormat(format))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDeltaTUYieldsNoFormat() {
|
||||||
|
XCTAssertNil(AV1.formatDescription(fromKeyframe: Data(Self.deltaTU)),
|
||||||
|
"a delta TU has no sequence header — the pumps must latch the previous one")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sample repack
|
||||||
|
|
||||||
|
func testSampleRepack() throws {
|
||||||
|
let format = try XCTUnwrap(AV1.formatDescription(fromKeyframe: Data(Self.keyframeTU)))
|
||||||
|
let au = AccessUnit(
|
||||||
|
data: Data(Self.keyframeTU), ptsNs: 1_000_000, frameIndex: 0, flags: 0, receivedNs: 0)
|
||||||
|
let sample = try XCTUnwrap(AV1.sampleBuffer(au: au, format: format))
|
||||||
|
|
||||||
|
// The blob is already fully size-fielded, so the repack is byte-identical minus the
|
||||||
|
// 2-byte temporal delimiter.
|
||||||
|
let block = try XCTUnwrap(CMSampleBufferGetDataBuffer(sample))
|
||||||
|
var length = 0
|
||||||
|
var ptr: UnsafeMutablePointer<CChar>?
|
||||||
|
XCTAssertEqual(CMBlockBufferGetDataPointer(
|
||||||
|
block, atOffset: 0, lengthAtOffsetOut: nil, totalLengthOut: &length,
|
||||||
|
dataPointerOut: &ptr), noErr)
|
||||||
|
let bytes = UnsafeRawBufferPointer(start: ptr, count: length)
|
||||||
|
XCTAssertEqual([UInt8](bytes), [UInt8](Self.keyframeTU[2...]))
|
||||||
|
|
||||||
|
// No temporal delimiter survives, and the pts round-trips at nanosecond scale.
|
||||||
|
AV1.forEachOBU(in: Data(bytes)) { _, _, _, type in
|
||||||
|
XCTAssertNotEqual(type, AV1.OBUType.temporalDelimiter)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
let pts = CMSampleBufferGetPresentationTimeStamp(sample)
|
||||||
|
XCTAssertEqual(pts.value, 1_000_000)
|
||||||
|
XCTAssertEqual(pts.timescale, 1_000_000_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSampleRepackDelimiterOnlyIsDropped() throws {
|
||||||
|
let format = try XCTUnwrap(AV1.formatDescription(fromKeyframe: Data(Self.keyframeTU)))
|
||||||
|
let au = AccessUnit(
|
||||||
|
data: Data([0x12, 0x00]), ptsNs: 0, frameIndex: 0, flags: 0, receivedNs: 0)
|
||||||
|
XCTAssertNil(AV1.sampleBuffer(au: au, format: format))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - VideoCodec dispatch
|
||||||
|
|
||||||
|
func testWireCodecResolution() {
|
||||||
|
XCTAssertEqual(VideoCodec(wire: 0x01), .h264)
|
||||||
|
XCTAssertEqual(VideoCodec(wire: 0x02), .hevc)
|
||||||
|
XCTAssertEqual(VideoCodec(wire: 0x04), .av1)
|
||||||
|
XCTAssertEqual(VideoCodec(wire: 0xFF), .hevc) // unknown → the default codec
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCodecDispatch() {
|
||||||
|
let au = Data(Self.keyframeTU)
|
||||||
|
XCTAssertNotNil(VideoCodec.av1.formatDescription(fromKeyframe: au))
|
||||||
|
// The same bytes through the NAL paths must not parse — proves the dispatch matters.
|
||||||
|
XCTAssertNil(VideoCodec.hevc.formatDescription(fromKeyframe: au))
|
||||||
|
XCTAssertNil(VideoCodec.h264.formatDescription(fromKeyframe: au))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Hardware decode (end to end)
|
||||||
|
|
||||||
|
/// The AV1 counterpart of VideoToolboxRoundTripTests' decode half: the keyframe blob through
|
||||||
|
/// the REAL VideoDecoder (format description → repack → hardware VTDecompressionSession).
|
||||||
|
/// Pixels out = the whole AV1 decode path is sound. Skipped on devices without AV1 hardware
|
||||||
|
/// (exactly the devices the client never advertises AV1 from).
|
||||||
|
func testHardwareDecodeEndToEnd() throws {
|
||||||
|
try XCTSkipUnless(
|
||||||
|
AV1.hardwareDecodeSupported, "no AV1 hardware decoder — AV1 is never advertised here")
|
||||||
|
|
||||||
|
let box = FrameBox()
|
||||||
|
let decoded = expectation(description: "decoded frame")
|
||||||
|
let decoder = VideoDecoder(
|
||||||
|
onDecoded: { frame in
|
||||||
|
box.lock.lock()
|
||||||
|
box.frame = frame
|
||||||
|
box.lock.unlock()
|
||||||
|
decoded.fulfill()
|
||||||
|
},
|
||||||
|
onDecodeError: { status in
|
||||||
|
box.lock.lock()
|
||||||
|
box.error = status
|
||||||
|
box.lock.unlock()
|
||||||
|
decoded.fulfill()
|
||||||
|
})
|
||||||
|
decoder.setCodec(.av1)
|
||||||
|
|
||||||
|
let format = try XCTUnwrap(AV1.formatDescription(fromKeyframe: Data(Self.keyframeTU)))
|
||||||
|
let au = AccessUnit(
|
||||||
|
data: Data(Self.keyframeTU), ptsNs: 42_000_000, frameIndex: 0, flags: 0, receivedNs: 1)
|
||||||
|
XCTAssertTrue(decoder.decode(au: au, format: format), "hardware session must accept the keyframe")
|
||||||
|
wait(for: [decoded], timeout: 5)
|
||||||
|
|
||||||
|
box.lock.lock()
|
||||||
|
let frame = box.frame
|
||||||
|
let error = box.error
|
||||||
|
box.lock.unlock()
|
||||||
|
XCTAssertNil(error.map { "decode error \($0)" })
|
||||||
|
let ready = try XCTUnwrap(frame)
|
||||||
|
XCTAssertEqual(ready.ptsNs, 42_000_000)
|
||||||
|
XCTAssertFalse(ready.isHDR)
|
||||||
|
XCTAssertEqual(CVPixelBufferGetWidth(ready.pixelBuffer), 320)
|
||||||
|
XCTAssertEqual(CVPixelBufferGetHeight(ready.pixelBuffer), 180)
|
||||||
|
XCTAssertEqual(
|
||||||
|
CVPixelBufferGetPixelFormatType(ready.pixelBuffer),
|
||||||
|
kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange, "SDR AV1 must decode to NV12")
|
||||||
|
decoder.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Blobs
|
||||||
|
|
||||||
|
/// Keyframe temporal unit (740 bytes).
|
||||||
|
static let keyframeTU: [UInt8] = [
|
||||||
|
0x12, 0x00, 0x0a, 0x0b, 0x00, 0x00, 0x00, 0x04, 0x3c, 0xfe, 0xcc, 0x4a, 0xf9, 0x00, 0x40, 0x32,
|
||||||
|
0xd2, 0x05, 0x10, 0x00, 0x9b, 0xa0, 0x8f, 0xbe, 0x7d, 0xf0, 0xdf, 0xbc, 0xf8, 0x00, 0xdd, 0x6d,
|
||||||
|
0x69, 0x7f, 0xb3, 0x26, 0x63, 0x5e, 0x79, 0xf4, 0xf5, 0xc5, 0x84, 0xda, 0xcf, 0xec, 0xd8, 0xa6,
|
||||||
|
0xc1, 0x56, 0x99, 0x43, 0xf0, 0xff, 0x31, 0xd5, 0x41, 0xd8, 0xbb, 0x07, 0x10, 0x5e, 0x42, 0xc5,
|
||||||
|
0x9b, 0x63, 0xc0, 0x14, 0xe7, 0x28, 0x73, 0xf3, 0x50, 0x12, 0x02, 0x8e, 0x2b, 0x91, 0xd7, 0x3d,
|
||||||
|
0xc5, 0x33, 0xc9, 0x9b, 0xd9, 0xea, 0xfb, 0xc3, 0x5a, 0xdd, 0xb3, 0x0b, 0x5d, 0xc6, 0xde, 0x1a,
|
||||||
|
0xca, 0x90, 0x61, 0x0a, 0x77, 0x83, 0xb5, 0x8f, 0x9a, 0x88, 0xf0, 0x3d, 0xa2, 0x78, 0x81, 0x6c,
|
||||||
|
0xb6, 0x8e, 0x85, 0x90, 0x44, 0xa1, 0xda, 0xa8, 0xf6, 0xcb, 0xa2, 0xf2, 0xb8, 0xb9, 0xac, 0x6b,
|
||||||
|
0xba, 0xfd, 0x8f, 0x7e, 0x04, 0x32, 0x0d, 0x90, 0xed, 0x5a, 0xe2, 0x1d, 0x8a, 0x03, 0x29, 0x47,
|
||||||
|
0xde, 0xf6, 0x9a, 0xd4, 0x45, 0x91, 0x17, 0x5c, 0x7b, 0xdf, 0xa3, 0xe2, 0x7a, 0xd9, 0x93, 0xfd,
|
||||||
|
0x55, 0xeb, 0xd9, 0x3f, 0xa6, 0xec, 0xdd, 0x2a, 0x00, 0xbc, 0x8d, 0x1d, 0x98, 0xa2, 0x39, 0x88,
|
||||||
|
0x3f, 0x32, 0x3e, 0x18, 0x85, 0x12, 0x46, 0x57, 0x1d, 0x25, 0x8e, 0xcc, 0x41, 0x37, 0xc3, 0x9f,
|
||||||
|
0xbd, 0x7b, 0xf9, 0xb9, 0xa4, 0x9d, 0x86, 0xf4, 0xcc, 0x2b, 0x5a, 0xf3, 0xbc, 0x77, 0x80, 0xcb,
|
||||||
|
0xc4, 0xf3, 0x02, 0x91, 0xf6, 0xb8, 0x59, 0x93, 0x33, 0xbe, 0xe2, 0x23, 0xac, 0xb9, 0xe2, 0x69,
|
||||||
|
0x67, 0xad, 0x63, 0x45, 0x35, 0x94, 0x9e, 0x2e, 0xfa, 0x2b, 0xb9, 0xc3, 0xf9, 0x39, 0x5e, 0xa8,
|
||||||
|
0x33, 0x4d, 0xf3, 0x5c, 0xbd, 0xe6, 0x5b, 0x50, 0x19, 0xe6, 0xd3, 0xf2, 0x01, 0xcf, 0x35, 0x09,
|
||||||
|
0xd6, 0x2a, 0x67, 0x17, 0xd2, 0xbd, 0x91, 0xc3, 0x91, 0x17, 0x4a, 0xbc, 0x29, 0xf0, 0xb8, 0xd4,
|
||||||
|
0xfc, 0x04, 0xac, 0x63, 0xfb, 0x2f, 0xc5, 0xe9, 0xb2, 0x06, 0xac, 0x3c, 0x79, 0x33, 0x5c, 0x73,
|
||||||
|
0x80, 0x95, 0x0f, 0xad, 0xff, 0xee, 0xed, 0x78, 0xaf, 0xc6, 0x1b, 0xb4, 0xc2, 0x96, 0x5f, 0x7f,
|
||||||
|
0x20, 0x5f, 0xb6, 0xdb, 0x70, 0xab, 0x60, 0x0b, 0xea, 0xd1, 0xaf, 0x57, 0x71, 0xeb, 0x3b, 0xef,
|
||||||
|
0xb1, 0x3c, 0x01, 0x72, 0x5b, 0x59, 0x6d, 0x36, 0xe3, 0x16, 0xda, 0x0a, 0x6b, 0xc7, 0x0b, 0xa0,
|
||||||
|
0xa6, 0x6f, 0x77, 0x4f, 0x0b, 0xe6, 0x62, 0x34, 0x0e, 0xdd, 0xfa, 0xbe, 0x4f, 0x67, 0x21, 0x40,
|
||||||
|
0xc2, 0xcd, 0xf3, 0x63, 0x9e, 0xb2, 0x28, 0xaf, 0x5b, 0x0e, 0x28, 0xba, 0x91, 0xec, 0xec, 0xf2,
|
||||||
|
0xf4, 0xdb, 0x9c, 0x51, 0x24, 0x67, 0x77, 0xe0, 0x67, 0x70, 0x51, 0xfb, 0x00, 0x61, 0x50, 0x9f,
|
||||||
|
0xae, 0x5e, 0x96, 0x2d, 0x1e, 0xa2, 0xab, 0x49, 0x90, 0x90, 0x1c, 0x04, 0x93, 0x8b, 0xc2, 0xee,
|
||||||
|
0x53, 0x61, 0x62, 0x19, 0x62, 0x5c, 0xff, 0x15, 0x84, 0x7a, 0x5c, 0x70, 0xaa, 0x6d, 0x39, 0xb2,
|
||||||
|
0xe9, 0x19, 0xd1, 0x9f, 0xf3, 0xb3, 0xb7, 0x05, 0xd2, 0xef, 0x5f, 0xe9, 0x2a, 0x25, 0x55, 0x0a,
|
||||||
|
0xf3, 0xd2, 0x95, 0xba, 0x22, 0x5f, 0x49, 0x9b, 0x5d, 0xae, 0xeb, 0x51, 0x63, 0x51, 0xa0, 0x85,
|
||||||
|
0xd6, 0xb6, 0x23, 0x6f, 0x92, 0xbf, 0x99, 0xf3, 0xf9, 0xbf, 0x07, 0xd4, 0x05, 0x1c, 0x6b, 0xe1,
|
||||||
|
0x42, 0x49, 0xfe, 0x99, 0x4c, 0x6f, 0x34, 0xea, 0x29, 0x14, 0xd5, 0x92, 0x17, 0xfa, 0xc4, 0x35,
|
||||||
|
0xef, 0x97, 0x31, 0x06, 0xdc, 0xc7, 0x57, 0xe7, 0x79, 0x3d, 0x64, 0xf3, 0x12, 0x0d, 0x60, 0x5d,
|
||||||
|
0x9d, 0xa5, 0x11, 0xb9, 0xf9, 0x75, 0x3a, 0x59, 0x42, 0x2a, 0xd0, 0xed, 0xb4, 0xa8, 0xee, 0x87,
|
||||||
|
0x9e, 0x3d, 0x52, 0x47, 0x6c, 0x8f, 0x43, 0x98, 0xd0, 0x72, 0x8b, 0x16, 0xef, 0x2a, 0xaa, 0x9c,
|
||||||
|
0x42, 0x91, 0xc8, 0x92, 0x85, 0x79, 0xc0, 0xf8, 0xc0, 0x12, 0x44, 0x38, 0x49, 0xdb, 0x5f, 0xfc,
|
||||||
|
0x7b, 0x6e, 0xac, 0xea, 0x38, 0x3c, 0x3f, 0x49, 0xc2, 0xe7, 0x82, 0xb3, 0x30, 0xf2, 0x7e, 0x31,
|
||||||
|
0xc2, 0xf8, 0xfc, 0x9a, 0x9e, 0x6d, 0x4c, 0x5f, 0xd4, 0xc0, 0x22, 0xd3, 0x40, 0xc9, 0x66, 0x59,
|
||||||
|
0x38, 0x14, 0x64, 0x24, 0xc0, 0x0d, 0x56, 0x88, 0x6d, 0x9f, 0x80, 0x71, 0xc8, 0x26, 0x3e, 0x2f,
|
||||||
|
0xd1, 0xd2, 0x6d, 0x8a, 0xf2, 0x2c, 0x01, 0xbf, 0x89, 0x15, 0xc7, 0x66, 0x3d, 0x19, 0x9f, 0xb8,
|
||||||
|
0x4c, 0xb9, 0x6f, 0xd7, 0xe8, 0x59, 0xf3, 0xe7, 0xdd, 0x14, 0x3e, 0x99, 0x37, 0x90, 0xb5, 0x2d,
|
||||||
|
0x49, 0xcc, 0x40, 0xd6, 0xe1, 0x29, 0x4e, 0x31, 0x7c, 0xef, 0xdb, 0x74, 0xf0, 0x9f, 0xa6, 0xdd,
|
||||||
|
0xbc, 0xd9, 0x65, 0x0f, 0xf7, 0x22, 0xa8, 0xd0, 0xfb, 0x78, 0x49, 0x40, 0xbf, 0x96, 0xa1, 0x5a,
|
||||||
|
0x7b, 0xc6, 0x60, 0x63, 0x22, 0xad, 0x5d, 0x97, 0xa2, 0x77, 0x3f, 0x58, 0x3b, 0x94, 0xa4, 0xd9,
|
||||||
|
0xe3, 0xe5, 0xae, 0x0c, 0x30, 0x91, 0xdd, 0x5a, 0xbb, 0xfd, 0x10, 0x6a, 0x22, 0x8f, 0xbd, 0x8c,
|
||||||
|
0x41, 0xf5, 0xa2, 0x29, 0xd7, 0xad, 0x4e, 0x58, 0xfb, 0x1e, 0x9a, 0x0e, 0x98, 0x54, 0xc1, 0xd7,
|
||||||
|
0xfb, 0xdf, 0xcc, 0x1d, 0x5d, 0xe3, 0x25, 0x6b, 0x57, 0x69, 0x67, 0x80, 0x0c, 0xb5, 0xcb, 0x01,
|
||||||
|
0x5a, 0x56, 0x56, 0x01, 0x47, 0xad, 0x6b, 0x26, 0x28, 0x30, 0x36, 0x79, 0x91, 0x62, 0x52, 0x93,
|
||||||
|
0xa4, 0xe8, 0x52, 0x30,
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Delta temporal unit (27 bytes).
|
||||||
|
static let deltaTU: [UInt8] = [
|
||||||
|
0x12, 0x00, 0x32, 0x17, 0x30, 0x02, 0x04, 0x09, 0x24, 0x92, 0x22, 0x7f, 0x80, 0x00, 0x01, 0x9f,
|
||||||
|
0x00, 0x00, 0x00, 0x8b, 0x07, 0x27, 0x7a, 0x64, 0x4c, 0xec, 0xf4,
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -43,10 +43,12 @@ read-only, so the flatpak (which bundles libadwaita/SDL3) is the canonical clien
|
|||||||
**Install Plugin from URL**, paste:
|
**Install Plugin from URL**, paste:
|
||||||
|
|
||||||
```
|
```
|
||||||
https://git.unom.io/api/packages/unom/generic/punktfunk-decky/latest/punktfunk.zip
|
https://unom.io/pf-decky
|
||||||
```
|
```
|
||||||
|
|
||||||
(or a pinned `.../punktfunk-decky/<version>/punktfunk.zip`). The plugin then **self-updates** without
|
(short link for `https://git.unom.io/api/packages/unom/generic/punktfunk-decky/latest/punktfunk.zip`;
|
||||||
|
for a pinned version use `https://git.unom.io/api/packages/unom/generic/punktfunk-decky/<version>/punktfunk.zip`
|
||||||
|
directly). The plugin then **self-updates** without
|
||||||
the Decky store — when a newer build exists, an **Update** button appears and drives Decky
|
the Decky store — when a newer build exists, an **Update** button appears and drives Decky
|
||||||
Loader's own (SHA-256-verified) install. Installs and updates can take a couple of minutes on some
|
Loader's own (SHA-256-verified) install. Installs and updates can take a couple of minutes on some
|
||||||
networks: Decky's installer also contacts its plugin store first, which may be slow or blackholed
|
networks: Decky's installer also contacts its plugin store first, which may be slow or blackholed
|
||||||
|
|||||||
+214
-2
@@ -104,7 +104,7 @@ def _parse_library_tsv(stdout: str) -> list[dict]:
|
|||||||
def _classify_library_error(stderr: str) -> str:
|
def _classify_library_error(stderr: str) -> str:
|
||||||
"""Map the client's ``library: <LibraryError Display>`` stderr line to a stable error
|
"""Map the client's ``library: <LibraryError Display>`` stderr line to a stable error
|
||||||
code for the UI. Substring-matched against the Display strings in
|
code for the UI. Substring-matched against the Display strings in
|
||||||
``clients/linux/src/library.rs`` — a wording change degrades to ``client-error``
|
``crates/pf-client-core/src/library.rs`` — a wording change degrades to ``client-error``
|
||||||
(generic copy), never a crash."""
|
(generic copy), never a crash."""
|
||||||
s = stderr.lower()
|
s = stderr.lower()
|
||||||
if "didn't recognize this device" in s:
|
if "didn't recognize this device" in s:
|
||||||
@@ -300,6 +300,98 @@ async def _flatpak_capture(args: list[str], timeout: float = 20.0) -> tuple[int,
|
|||||||
return -1, ""
|
return -1, ""
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_client(client_args: list[str], timeout: float = 20.0) -> tuple[int, str, str]:
|
||||||
|
"""Run the flatpak CLIENT headlessly (``flatpak run … io.unom.Punktfunk <client_args>``) with
|
||||||
|
the user-session env, returning ``(returncode, stdout, stderr)`` with SEPARATE pipes so a JSON
|
||||||
|
payload on stdout stays clean of the client's log lines on stderr. ``(-1, "", "")`` when
|
||||||
|
flatpak is missing or the call errors/times out. This is the single entry point for the
|
||||||
|
headless host-store modes (``--list-hosts`` / ``--add-host`` / ``--set-host`` /
|
||||||
|
``--forget-host`` / ``--reset`` / ``--reachable``), which mutate the SAME
|
||||||
|
``client-known-hosts.json`` the desktop client reads — so state is shared, not duplicated."""
|
||||||
|
flatpak = _flatpak()
|
||||||
|
if not flatpak:
|
||||||
|
return -1, "", ""
|
||||||
|
argv = [flatpak, "run", "--arch=x86_64", APP_ID, *client_args]
|
||||||
|
proc = None
|
||||||
|
try:
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
*argv,
|
||||||
|
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
||||||
|
env=_flatpak_env(),
|
||||||
|
)
|
||||||
|
out, err = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
||||||
|
rc = proc.returncode if proc.returncode is not None else -1
|
||||||
|
return (
|
||||||
|
rc,
|
||||||
|
(out or b"").decode("utf-8", "replace"),
|
||||||
|
(err or b"").decode("utf-8", "replace"),
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
decky.logger.warning("client %s timed out", " ".join(client_args))
|
||||||
|
if proc:
|
||||||
|
try:
|
||||||
|
proc.kill()
|
||||||
|
except ProcessLookupError:
|
||||||
|
pass
|
||||||
|
return -1, "", ""
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
decky.logger.exception("client %s failed", " ".join(client_args))
|
||||||
|
return -1, "", ""
|
||||||
|
|
||||||
|
|
||||||
|
# The QAM panel and the full page each mount their own hosts view, and Gaming Mode remounts the
|
||||||
|
# QAM often — every mount calls list_hosts, which spawns a flatpak cold-start plus a reachability
|
||||||
|
# probe. Cache the last result briefly so back-to-back opens reuse it instead of re-probing; any
|
||||||
|
# mutation (add/edit/forget/reset/pair) invalidates it so a change shows up immediately.
|
||||||
|
_HOSTS_TTL_S = 12.0
|
||||||
|
_hosts_cache: dict = {"at": 0.0, "probed": None, "data": None}
|
||||||
|
|
||||||
|
|
||||||
|
def _invalidate_hosts_cache() -> None:
|
||||||
|
_hosts_cache["data"] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _read_known_hosts() -> list[dict]:
|
||||||
|
"""The saved-hosts store read straight off disk — the fallback for a client too old to have
|
||||||
|
``--list-hosts``. Same file the desktop client owns; `online` is left ``None`` (unknown)
|
||||||
|
because a direct read has no reachability signal."""
|
||||||
|
try:
|
||||||
|
data = json.loads((_client_config_dir() / "client-known-hosts.json").read_text())
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return []
|
||||||
|
hosts = data.get("hosts", []) if isinstance(data, dict) else []
|
||||||
|
out: list[dict] = []
|
||||||
|
for h in hosts:
|
||||||
|
if not isinstance(h, dict) or not h.get("addr"):
|
||||||
|
continue
|
||||||
|
out.append({
|
||||||
|
"name": str(h.get("name") or h.get("addr", "")),
|
||||||
|
"addr": str(h.get("addr", "")),
|
||||||
|
"port": int(h.get("port", 9777) or 9777),
|
||||||
|
"fp_hex": str(h.get("fp_hex", "")),
|
||||||
|
"paired": bool(h.get("paired", False)),
|
||||||
|
"mac": h.get("mac") if isinstance(h.get("mac"), list) else [],
|
||||||
|
"last_used": h.get("last_used"),
|
||||||
|
"online": None,
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _mutation_result(rc: int, err: str, op: str) -> dict:
|
||||||
|
"""Map a headless host-store mutation's exit status to a UI-stable result. ``rc == -1`` means
|
||||||
|
the flatpak call never ran (missing/timed out); a nonzero rc from a client that PREDATES the
|
||||||
|
mode falls through to GTK init and fails headless — classified ``client-outdated`` so the UI
|
||||||
|
can prompt an update instead of showing a cryptic error."""
|
||||||
|
if rc == 0:
|
||||||
|
return {"ok": True}
|
||||||
|
if rc == -1:
|
||||||
|
return {"ok": False, "error": "client-unavailable"}
|
||||||
|
code = _classify_library_error(err)
|
||||||
|
detail = (err.strip().splitlines() or [f"{op} failed"])[-1]
|
||||||
|
decky.logger.warning("%s failed (rc=%s): %s", op, rc, detail)
|
||||||
|
return {"ok": False, "error": code, "detail": detail}
|
||||||
|
|
||||||
|
|
||||||
def _field_from(text: str, name: str) -> str:
|
def _field_from(text: str, name: str) -> str:
|
||||||
"""Pull ``<name>: value`` out of ``flatpak info`` / ``remote-info`` output (e.g. ``Commit``,
|
"""Pull ``<name>: value`` out of ``flatpak info`` / ``remote-info`` output (e.g. ``Commit``,
|
||||||
``Origin``)."""
|
``Origin``)."""
|
||||||
@@ -483,6 +575,7 @@ class Plugin:
|
|||||||
if tok.startswith("fp="):
|
if tok.startswith("fp="):
|
||||||
fp = tok[3:]
|
fp = tok[3:]
|
||||||
decky.logger.info("paired %s:%s", host, port)
|
decky.logger.info("paired %s:%s", host, port)
|
||||||
|
_invalidate_hosts_cache() # the store gained a paired entry — reflect it next list
|
||||||
return {"ok": True, "fp": fp}
|
return {"ok": True, "fp": fp}
|
||||||
decky.logger.warning("pairing failed (rc=%s): %s", proc.returncode, err.strip() or out.strip())
|
decky.logger.warning("pairing failed (rc=%s): %s", proc.returncode, err.strip() or out.strip())
|
||||||
# Surface the client's own one-line reason (wrong PIN / not armed) to the UI.
|
# Surface the client's own one-line reason (wrong PIN / not armed) to the UI.
|
||||||
@@ -669,7 +762,7 @@ class Plugin:
|
|||||||
# The client's own defaults (native display, host-default bitrate, auto pad).
|
# The client's own defaults (native display, host-default bitrate, auto pad).
|
||||||
return {
|
return {
|
||||||
"width": 0, "height": 0, "refresh_hz": 0, "bitrate_kbps": 0,
|
"width": 0, "height": 0, "refresh_hz": 0, "bitrate_kbps": 0,
|
||||||
"gamepad": "auto", "compositor": "auto",
|
"codec": "auto", "gamepad": "auto", "compositor": "auto",
|
||||||
"inhibit_shortcuts": True, "mic_enabled": False,
|
"inhibit_shortcuts": True, "mic_enabled": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -684,6 +777,125 @@ class Plugin:
|
|||||||
decky.logger.exception("could not write settings")
|
decky.logger.exception("could not write settings")
|
||||||
return {"ok": False, "error": str(exc)}
|
return {"ok": False, "error": str(exc)}
|
||||||
|
|
||||||
|
# ---- Shared known-hosts store (the SAME file the desktop client reads/writes) ----
|
||||||
|
|
||||||
|
async def list_hosts(self, probe: bool = True) -> dict:
|
||||||
|
"""The saved-hosts store as a list — the SAME ``client-known-hosts.json`` the desktop
|
||||||
|
client owns, so a host added/renamed/paired in either surface shows in both. With
|
||||||
|
``probe`` each host carries a live ``online`` bool from a mDNS-INDEPENDENT reachability
|
||||||
|
probe (a Tailscale/VPN host is no longer shown offline just because it doesn't advertise);
|
||||||
|
``online`` is ``None`` when reachability is unknown. Prefers the client's ``--list-hosts``
|
||||||
|
mode; falls back to reading the JSON directly when the installed client predates it."""
|
||||||
|
now = time.monotonic()
|
||||||
|
cache = _hosts_cache
|
||||||
|
if (
|
||||||
|
cache["data"] is not None
|
||||||
|
and cache["probed"] == bool(probe)
|
||||||
|
and (now - cache["at"]) < _HOSTS_TTL_S
|
||||||
|
):
|
||||||
|
return cache["data"]
|
||||||
|
|
||||||
|
args = ["--list-hosts"] + (["--probe"] if probe else [])
|
||||||
|
rc, out, err = await _run_client(args, timeout=30.0)
|
||||||
|
result: dict | None = None
|
||||||
|
if rc == 0:
|
||||||
|
try:
|
||||||
|
data = json.loads(out)
|
||||||
|
hosts = data.get("hosts", []) if isinstance(data, dict) else []
|
||||||
|
result = {"ok": True, "hosts": hosts, "probed": bool(probe)}
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
decky.logger.warning("list-hosts: unparseable output: %s", out[:200])
|
||||||
|
elif rc != -1:
|
||||||
|
decky.logger.info(
|
||||||
|
"list-hosts unavailable (%s); reading store directly",
|
||||||
|
_classify_library_error(err),
|
||||||
|
)
|
||||||
|
if result is None:
|
||||||
|
# Fallback: read the store off disk (old client / no --list-hosts) — no reachability.
|
||||||
|
result = {"ok": True, "hosts": _read_known_hosts(), "probed": False, "fallback": True}
|
||||||
|
cache.update(at=now, probed=bool(probe), data=result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def add_host(self, target: str, name: str = "", fp: str = "") -> dict:
|
||||||
|
"""Save a host by address so it can be paired/streamed even when mDNS never sees it (a
|
||||||
|
Tailscale/VPN box). Without ``fp`` it's an unpaired placeholder the user pairs next; a
|
||||||
|
later pair replaces it with the fingerprinted entry. Returns ``{ok, error?, detail?}``."""
|
||||||
|
args = ["--add-host", target.strip()]
|
||||||
|
if name.strip():
|
||||||
|
args += ["--host-label", name.strip()]
|
||||||
|
if fp.strip():
|
||||||
|
args += ["--fp", fp.strip()]
|
||||||
|
rc, _out, err = await _run_client(args, timeout=20.0)
|
||||||
|
_invalidate_hosts_cache()
|
||||||
|
return _mutation_result(rc, err, "add-host")
|
||||||
|
|
||||||
|
async def edit_host(
|
||||||
|
self, selector: str, name: str = "", addr: str = "", port: int = 0
|
||||||
|
) -> dict:
|
||||||
|
"""Edit a saved host — rename and/or re-point its address. ``selector`` is the host's
|
||||||
|
cert fingerprint (survives IP changes) or its current ``addr[:port]``. Empty fields are
|
||||||
|
left untouched. Returns ``{ok, error?, detail?}``."""
|
||||||
|
args = ["--set-host", selector]
|
||||||
|
if name.strip():
|
||||||
|
args += ["--host-label", name.strip()]
|
||||||
|
if addr.strip():
|
||||||
|
args += ["--addr", addr.strip()]
|
||||||
|
if port:
|
||||||
|
args += ["--port", str(int(port))]
|
||||||
|
rc, _out, err = await _run_client(args, timeout=20.0)
|
||||||
|
_invalidate_hosts_cache()
|
||||||
|
return _mutation_result(rc, err, "set-host")
|
||||||
|
|
||||||
|
async def forget_host(self, selector: str) -> dict:
|
||||||
|
"""Remove a saved host (by fingerprint or ``addr[:port]``) — drops the pinned
|
||||||
|
fingerprint, so a later connect must re-pair/trust. Idempotent."""
|
||||||
|
rc, _out, err = await _run_client(["--forget-host", selector], timeout=20.0)
|
||||||
|
_invalidate_hosts_cache()
|
||||||
|
return _mutation_result(rc, err, "forget-host")
|
||||||
|
|
||||||
|
async def reset_config(self) -> dict:
|
||||||
|
"""Reset this device's Punktfunk state: saved hosts, stream settings, and the plugin's
|
||||||
|
pinned games. The client's persistent IDENTITY (client-cert/key.pem) is KEPT so the box
|
||||||
|
isn't seen as brand-new everywhere (re-pairing re-adds hosts). Prefers the client's
|
||||||
|
``--reset``; if that's unavailable (old client / no flatpak) it clears the shared JSON
|
||||||
|
stores directly. The plugin-owned pins file is always cleared here (``--reset`` never
|
||||||
|
touches it)."""
|
||||||
|
rc, _out, _err = await _run_client(["--reset"], timeout=20.0)
|
||||||
|
_invalidate_hosts_cache()
|
||||||
|
errors: list[str] = []
|
||||||
|
if rc != 0:
|
||||||
|
decky.logger.info("reset: --reset unavailable (rc=%s); clearing stores directly", rc)
|
||||||
|
for name in ("client-known-hosts.json", "client-gtk-settings.json"):
|
||||||
|
try:
|
||||||
|
(_client_config_dir() / name).unlink()
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
except OSError as exc:
|
||||||
|
errors.append(f"{name}: {exc}")
|
||||||
|
try:
|
||||||
|
_pins_path().unlink() # plugin-owned; the client's --reset leaves it alone
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
except OSError as exc:
|
||||||
|
errors.append(f"pins: {exc}")
|
||||||
|
if errors:
|
||||||
|
return {"ok": False, "error": "; ".join(errors)}
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
async def probe_host(self, target: str) -> dict:
|
||||||
|
"""Reachability of one ``host[:port]`` via the client's mDNS-independent QUIC probe —
|
||||||
|
for a "test this address" check. ``{ok: True, online: bool}`` when determined, else
|
||||||
|
``{ok: False, error}`` (flatpak missing / client too old)."""
|
||||||
|
rc, _out, err = await _run_client(["--reachable", target.strip()], timeout=8.0)
|
||||||
|
if rc == 0:
|
||||||
|
return {"ok": True, "online": True}
|
||||||
|
if rc == 1:
|
||||||
|
return {"ok": True, "online": False}
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"error": _classify_library_error(err) if err.strip() else "client-unavailable",
|
||||||
|
}
|
||||||
|
|
||||||
async def kill_stream(self) -> dict:
|
async def kill_stream(self) -> dict:
|
||||||
"""Force-stop a wedged stream client (``flatpak kill``)."""
|
"""Force-stop a wedged stream client (``flatpak kill``)."""
|
||||||
flatpak = _flatpak()
|
flatpak = _flatpak()
|
||||||
|
|||||||
@@ -54,6 +54,38 @@ export interface PairResult {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A host in the SHARED saved-hosts store (client-known-hosts.json) — the same file the desktop
|
||||||
|
// client reads/writes, so add/rename/pair in either surface shows up in both. `online` comes
|
||||||
|
// from a mDNS-INDEPENDENT reachability probe (a Tailscale/VPN host isn't shown offline just
|
||||||
|
// because it doesn't advertise); `null` means reachability is unknown (probe skipped or a client
|
||||||
|
// too old for `--list-hosts`, which then also can't probe).
|
||||||
|
export interface SavedHost {
|
||||||
|
name: string;
|
||||||
|
addr: string;
|
||||||
|
port: number;
|
||||||
|
fp_hex: string; // host cert fingerprint (lowercase hex); "" for a not-yet-paired manual entry
|
||||||
|
paired: boolean;
|
||||||
|
mac: string[];
|
||||||
|
last_used: number | null;
|
||||||
|
online: boolean | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HostsResult {
|
||||||
|
ok: boolean;
|
||||||
|
hosts: SavedHost[];
|
||||||
|
probed: boolean;
|
||||||
|
fallback?: boolean; // true when read straight off disk (client too old for --list-hosts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The result of a host-store mutation (add/edit/forget). `error` is a stable code:
|
||||||
|
// "client-unavailable" (flatpak missing) | "client-outdated" (client predates the mode) |
|
||||||
|
// "unreachable"/"http"/… (from the client) | "client-error" (generic; see `detail`).
|
||||||
|
export interface MutationResult {
|
||||||
|
ok: boolean;
|
||||||
|
error?: string;
|
||||||
|
detail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RunnerInfo {
|
export interface RunnerInfo {
|
||||||
runner: string; // absolute path to bin/punktfunkrun.sh
|
runner: string; // absolute path to bin/punktfunkrun.sh
|
||||||
app_id: string; // flatpak app id
|
app_id: string; // flatpak app id
|
||||||
@@ -61,13 +93,14 @@ export interface RunnerInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The slice of the flatpak client's settings JSON this UI surfaces. The file can hold more
|
// The slice of the flatpak client's settings JSON this UI surfaces. The file can hold more
|
||||||
// keys (codec, decoder, … set from the desktop client's own UI) — they round-trip untouched
|
// keys (decoder, … set from the desktop client's own UI) — they round-trip untouched
|
||||||
// because get_settings returns the whole parsed file and patches are object spreads.
|
// because get_settings returns the whole parsed file and patches are object spreads.
|
||||||
export interface StreamSettings {
|
export interface StreamSettings {
|
||||||
width: number; // 0 = native
|
width: number; // 0 = native
|
||||||
height: number; // 0 = native
|
height: number; // 0 = native
|
||||||
refresh_hz: number; // 0 = native
|
refresh_hz: number; // 0 = native
|
||||||
bitrate_kbps: number; // 0 = host default
|
bitrate_kbps: number; // 0 = host default
|
||||||
|
codec?: string; // "auto" | "hevc" | "h264" | "av1" — soft preference (absent in pre-codec files)
|
||||||
gamepad: string; // "auto" | "xbox360" | "xboxone" | "dualsense" | "dualshock4" | "steamdeck"
|
gamepad: string; // "auto" | "xbox360" | "xboxone" | "dualsense" | "dualshock4" | "steamdeck"
|
||||||
compositor: string; // "auto" | "kwin" | "wlroots" | "mutter" | "gamescope"
|
compositor: string; // "auto" | "kwin" | "wlroots" | "mutter" | "gamescope"
|
||||||
inhibit_shortcuts: boolean;
|
inhibit_shortcuts: boolean;
|
||||||
@@ -128,6 +161,32 @@ export const killStream = callable<[], { ok: boolean }>("kill_stream");
|
|||||||
export const wake = callable<[host: string, port: number], { ok: boolean; error?: string }>(
|
export const wake = callable<[host: string, port: number], { ok: boolean; error?: string }>(
|
||||||
"wake",
|
"wake",
|
||||||
);
|
);
|
||||||
|
// ---- Shared saved-hosts store (the SAME client-known-hosts.json the desktop client owns) ----
|
||||||
|
// The saved hosts, each annotated with a live (mDNS-independent) `online` probe when `probe` is
|
||||||
|
// true. Falls back to a direct JSON read (no reachability) on a client too old for --list-hosts.
|
||||||
|
export const listHosts = callable<[probe: boolean], HostsResult>("list_hosts");
|
||||||
|
// Save a host by address (survives mDNS-blind networks). `fp` empty = unpaired placeholder to
|
||||||
|
// pair next; a later pair replaces it with the fingerprinted entry.
|
||||||
|
export const addHost = callable<[target: string, name: string, fp: string], MutationResult>(
|
||||||
|
"add_host",
|
||||||
|
);
|
||||||
|
// Rename and/or re-point a saved host. `selector` = its fingerprint (survives IP change) or
|
||||||
|
// current addr[:port]; empty fields are left untouched.
|
||||||
|
export const editHost = callable<
|
||||||
|
[selector: string, name: string, addr: string, port: number],
|
||||||
|
MutationResult
|
||||||
|
>("edit_host");
|
||||||
|
// Remove a saved host by fingerprint or addr[:port] (idempotent).
|
||||||
|
export const forgetHost = callable<[selector: string], MutationResult>("forget_host");
|
||||||
|
// Reset this device's Punktfunk state (saved hosts + stream settings + pins); KEEPS the client
|
||||||
|
// identity so the box isn't seen as new everywhere (re-pairing re-adds hosts).
|
||||||
|
export const resetConfig = callable<[], { ok: boolean; error?: string }>("reset_config");
|
||||||
|
// Reachability of one host[:port] via the client's mDNS-independent QUIC probe (a "test address"
|
||||||
|
// check). `{ ok: true, online }` when determined, else `{ ok: false, error }`.
|
||||||
|
export const probeHost = callable<
|
||||||
|
[target: string],
|
||||||
|
{ ok: boolean; online?: boolean; error?: string }
|
||||||
|
>("probe_host");
|
||||||
export const checkUpdate = callable<[force: boolean], UpdateInfo>("check_update");
|
export const checkUpdate = callable<[force: boolean], UpdateInfo>("check_update");
|
||||||
// Update the flatpak client in the user installation (`flatpak update --user -y io.unom.Punktfunk`).
|
// Update the flatpak client in the user installation (`flatpak update --user -y io.unom.Punktfunk`).
|
||||||
export const updateClient = callable<
|
export const updateClient = callable<
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user