Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
504284af5b | ||
|
|
4d43b8c446 | ||
|
|
addd5215e5 | ||
|
|
9dd4283af5 | ||
|
|
906276ca4d | ||
|
|
e0306de3d0 | ||
|
|
e9883e8f5a | ||
|
|
ec9799e7e4 | ||
|
|
9c6668d0ea | ||
|
|
5c7aa5673f | ||
|
|
6833640385 | ||
|
|
ce95b7eb6b | ||
|
|
70871b3e4c | ||
|
|
fbbd9d7c4e | ||
|
|
caad1138c5 | ||
|
|
99a452b3cf | ||
|
|
7be9e371ec | ||
|
|
f0dfaa5e95 | ||
|
|
2431892b28 | ||
|
|
f579cf3f3b | ||
|
|
c9144dc1d9 | ||
|
|
2d2a5ab3f4 | ||
|
|
207ee7bad9 | ||
|
|
f9ec1c7df9 | ||
|
|
35025443ad | ||
|
|
eddb901c03 | ||
|
|
779de3d555 | ||
|
|
a559c47885 | ||
|
|
623387b802 | ||
|
|
dc725f2a10 | ||
|
|
ee9f33c801 | ||
|
|
55aaf5ed42 | ||
|
|
5089f653be | ||
|
|
39909adfb4 | ||
|
|
6bb29755c0 | ||
|
|
82aef92a45 | ||
|
|
ff66861e2c | ||
|
|
feef698940 | ||
|
|
85a0913747 | ||
|
|
c1a20857e7 | ||
|
|
eb3fac60df | ||
|
|
2faa4cc47f | ||
|
|
5c87459407 | ||
|
|
cab0399a11 | ||
|
|
d1e635f710 | ||
|
|
a96aab1245 | ||
|
|
c8aca22292 | ||
|
|
ffa48632fc | ||
|
|
daffc70dd0 | ||
|
|
4a2187c6c4 | ||
|
|
818a65452f | ||
|
|
4c9ad90cd7 | ||
|
|
f8deb4d0f4 | ||
|
|
e98caa1bef | ||
|
|
438ed5dfa1 | ||
|
|
e89bcb4c0e | ||
|
|
495efd1a13 |
No files matched your search
@@ -0,0 +1,3 @@
|
||||
* @Cuteminded @LexiMallow
|
||||
|
||||
.py @Cuteminded
|
||||
@@ -95,7 +95,7 @@ jobs:
|
||||
|
||||
- name: Verify APK signature
|
||||
run: |
|
||||
"$ANDROID_HOME/build-tools/36.1.0/apksigner" verify --verbose dist/jellyframe-android.apk
|
||||
"$ANDROID_HOME/build-tools/36.1.0/apksigner" verify --verbose dist/jellyxr-android.apk
|
||||
|
||||
- name: Set artifact date
|
||||
id: date
|
||||
@@ -104,8 +104,8 @@ jobs:
|
||||
- name: Upload APK
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ steps.date.outputs.date }}-jellyframe-android
|
||||
path: dist/jellyframe-android.apk
|
||||
name: ${{ steps.date.outputs.date }}-jellyxr-android
|
||||
path: dist/jellyxr-android.apk
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
archive: false
|
||||
@@ -25,16 +25,13 @@ jobs:
|
||||
include:
|
||||
- arch: x86_64
|
||||
runner: ubuntu-24.04
|
||||
preset: Linux x64 OpenXR
|
||||
vulkan_preset: Linux x64 Vulkan
|
||||
preset: Linux x64
|
||||
- arch: arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
preset: Linux ARM64 OpenXR
|
||||
vulkan_preset: Linux ARM64 Vulkan
|
||||
preset: Linux ARM64
|
||||
env:
|
||||
ARCH: ${{ matrix.arch }}
|
||||
EXPORT_PRESET: ${{ matrix.preset }}
|
||||
VULKAN_EXPORT_PRESET: ${{ matrix.vulkan_preset }}
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
@@ -47,8 +44,7 @@ jobs:
|
||||
- name: Install build dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install --no-install-recommends -y \
|
||||
build-essential cmake pkg-config python3 curl unzip libmpv-dev patchelf
|
||||
sudo apt-get install --no-install-recommends -y build-essential cmake pkg-config python3 curl unzip libmpv-dev patchelf libavcodec-dev libavformat-dev libavutil-dev libswscale-dev
|
||||
|
||||
- name: Build PC video plugin
|
||||
run: |
|
||||
@@ -79,40 +75,40 @@ jobs:
|
||||
run: |
|
||||
output="dist/linux-${ARCH}"
|
||||
mkdir -p "$output"
|
||||
godot --headless --xr-mode off --path . --export-release "$EXPORT_PRESET" "$output/jellyframe-opengl.${ARCH}"
|
||||
godot --headless --xr-mode off --path . --export-release "$VULKAN_EXPORT_PRESET" "$output/jellyframe-vulkan.${ARCH}"
|
||||
godot --headless --xr-mode off --path . --export-release "$EXPORT_PRESET" "$output/jellyxr.${ARCH}"
|
||||
|
||||
- name: Verify Linux export files
|
||||
run: |
|
||||
output="dist/linux-${ARCH}"
|
||||
test -s "$output/jellyframe-opengl.${ARCH}"
|
||||
test -s "$output/jellyframe-vulkan.${ARCH}"
|
||||
test -s "$output/jellyframe_mpv.linux.${ARCH}.so"
|
||||
chmod +x "$output/jellyframe-opengl.${ARCH}" "$output/jellyframe-vulkan.${ARCH}"
|
||||
test -s "$output/jellyxr.${ARCH}"
|
||||
test -s "$output/jellyxr_mpv.linux.${ARCH}.so"
|
||||
chmod +x "$output/jellyxr.${ARCH}"
|
||||
|
||||
- name: Verify exported renderer settings
|
||||
run: timeout 30 godot --headless --xr-mode off --main-pack "dist/linux-${ARCH}/jellyxr.${ARCH}" --script tests/test_export_renderer.gd
|
||||
|
||||
- name: Copy Linux licenses and installation guide
|
||||
run: |
|
||||
output="dist/linux-${ARCH}"
|
||||
cp -r "build/linux/${ARCH}/licenses" "$output/"
|
||||
cp "build/linux/${ARCH}/build-info.txt" "$output/"
|
||||
cp LICENSE "$output/licenses/jellyframe.txt"
|
||||
cp INSTALLATION.md "$output/README.md"
|
||||
cp LICENSE "$output/licenses/jellyxr.txt"
|
||||
cp LICENSING.md "$output/licenses/"
|
||||
|
||||
- name: Check Linux decoder dependencies
|
||||
run: |
|
||||
output="dist/linux-${ARCH}"
|
||||
ldd "$output/jellyframe_mpv.linux.${ARCH}.so" | tee "$RUNNER_TEMP/dependencies.log"
|
||||
ldd "$output/jellyxr_mpv.linux.${ARCH}.so" | tee "$RUNNER_TEMP/dependencies.log"
|
||||
if grep -q 'not found' "$RUNNER_TEMP/dependencies.log"; then
|
||||
echo 'The exported decoder has missing shared libraries.' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Package OpenGL and Vulkan AppImages
|
||||
- name: Package Linux AppImage
|
||||
run: bash tools/package-linux-appimages.sh "$ARCH"
|
||||
|
||||
- name: Package Linux build with system libraries
|
||||
run: |
|
||||
tar -czf "dist/jellyframe-linux-${ARCH}.tar.gz" -C "dist/linux-${ARCH}" .
|
||||
tar -czf "dist/jellyxr-linux-${ARCH}.tar.gz" -C "dist/linux-${ARCH}" .
|
||||
|
||||
- name: Set artifact date
|
||||
id: date
|
||||
@@ -121,26 +117,17 @@ jobs:
|
||||
- name: Upload Linux build with system libraries
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ steps.date.outputs.date }}-jellyframe-linux-${{ matrix.arch }}-system-libs
|
||||
path: dist/jellyframe-linux-${{ matrix.arch }}.tar.gz
|
||||
name: ${{ steps.date.outputs.date }}-jellyxr-linux-${{ matrix.arch }}-system-libs
|
||||
path: dist/jellyxr-linux-${{ matrix.arch }}.tar.gz
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
archive: false
|
||||
|
||||
- name: Upload OpenGL AppImage
|
||||
- name: Upload Linux AppImage
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ steps.date.outputs.date }}-jellyframe-linux-${{ matrix.arch }}-opengl-appimage
|
||||
path: dist/jellyframe-linux-${{ matrix.arch }}-opengl.AppImage
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
archive: false
|
||||
|
||||
- name: Upload Vulkan AppImage
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ steps.date.outputs.date }}-jellyframe-linux-${{ matrix.arch }}-vulkan-appimage
|
||||
path: dist/jellyframe-linux-${{ matrix.arch }}-vulkan.AppImage
|
||||
name: ${{ steps.date.outputs.date }}-jellyxr-linux-${{ matrix.arch }}-appimage
|
||||
path: dist/jellyxr-linux-${{ matrix.arch }}.AppImage
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
archive: false
|
||||
@@ -57,25 +57,31 @@ jobs:
|
||||
- name: Export Windows build
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force dist/windows | Out-Null
|
||||
& $env:GODOT --headless --xr-mode off --path . --export-release 'Windows SteamVR' dist/windows/jellyframe.exe
|
||||
& $env:GODOT --headless --xr-mode off --path . --export-release 'Windows' dist/windows/jellyxr.exe
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Windows export failed.' }
|
||||
|
||||
- name: Verify Windows export files
|
||||
run: |
|
||||
foreach ($file in 'jellyframe.exe', 'jellyframe.pck', 'jellyframe_mpv.dll', 'libmpv-2.dll') {
|
||||
foreach ($file in 'jellyxr.exe', 'jellyxr.pck', 'jellyxr_mpv.dll', 'libmpv-2.dll', 'avcodec-62.dll', 'avformat-62.dll', 'avutil-60.dll', 'swscale-9.dll', 'swresample-6.dll') {
|
||||
if (-not (Test-Path "dist/windows/$file")) { throw "Export is missing $file." }
|
||||
}
|
||||
|
||||
- name: Verify exported renderer settings
|
||||
run: |
|
||||
& $env:GODOT --headless --xr-mode off --main-pack dist/windows/jellyxr.pck --script tests/test_export_renderer.gd
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Windows renderer settings check failed.' }
|
||||
|
||||
- name: Copy Windows licenses
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force dist/windows/licenses | Out-Null
|
||||
Copy-Item build/desktop/deps/godot-cpp-godot-4.5-stable/LICENSE.md dist/windows/licenses/godot-cpp.txt
|
||||
Copy-Item native/pc/THIRD_PARTY.md dist/windows/licenses/
|
||||
Copy-Item build/desktop/deps/godot-cpp-godot-4.5-stable/LICENSE.md dist/windows/licenses/godot-cpp.md
|
||||
Copy-Item LICENSING.md dist/windows/licenses/
|
||||
Copy-Item build/desktop/deps/ffmpeg-n8.1.3-18-g46d899d7a0-win64-gpl-shared-8.1/LICENSE.txt dist/windows/licenses/ffmpeg.txt
|
||||
|
||||
- name: Upload Windows build
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: jellyframe-windows
|
||||
name: jellyxr-windows
|
||||
path: dist/windows/
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
@@ -14,8 +14,8 @@ android/build/
|
||||
android/.build_version
|
||||
native/local.properties
|
||||
native/**/build/
|
||||
addons/jellyframe_mpv/
|
||||
addons/jellyframe_video/bin/
|
||||
addons/jellyxr_mpv/
|
||||
addons/jellyxr_video/bin/
|
||||
addons/godotopenxrvendors/
|
||||
/tests/__pycache__
|
||||
/tools/__pycache__
|
||||
@@ -1,6 +1,6 @@
|
||||
# Contributing to Jellyframe
|
||||
# Contributing to jellyXR
|
||||
|
||||
You can contribute bug fixes, playback improvements, VR controls, tests, or documentation, or report problems you find while using Jellyframe.
|
||||
Contributions can include bug fixes, playback improvements, VR controls, tests, and documentation. Reports of problems you find while using jellyXR are also welcome.
|
||||
|
||||
Read the [README](README.md) for the project's scope and current limitations, and [Build from source](DEVELOPMENT.md) for setup, build commands, tests, and the code layout.
|
||||
|
||||
@@ -8,33 +8,34 @@ Read the [README](README.md) for the project's scope and current limitations, an
|
||||
|
||||
Search the repository's issues before opening a new one. Include:
|
||||
|
||||
- The Jellyframe build or commit you used.
|
||||
- The jellyXR build or commit you used.
|
||||
- Your operating system, headset, and OpenXR runtime, where applicable.
|
||||
- Your GPU and the OpenGL or Vulkan renderer used for PC playback.
|
||||
- Your Jellyfin server version.
|
||||
- Steps to reproduce the problem, what you expected, and what happened.
|
||||
- Relevant logs or screenshots.
|
||||
|
||||
For playback problems, include the container format, video and audio codecs, resolution, and whether the video is 2D or side-by-side 3D, along with whether Jellyfin played the file directly or converted it during playback, if you know.
|
||||
For playback problems, include the container format, video and audio codecs, resolution, frame rate, HDR format, and stereo or panoramic layout. If you know whether Jellyfin played the file directly or converted it, include that too.
|
||||
|
||||
Remove passwords, access tokens, and private server details from logs before posting them. A short sample you have permission to share can help reproduce a playback problem.
|
||||
|
||||
## Propose a feature
|
||||
|
||||
Before implementing a large change, open an issue explaining what you want to do in the app, what gets in the way, and how the change would help, including any platform or headset requirements.
|
||||
Before implementing a large change, open an issue. Explain what you want to do in the app, what gets in the way, and how the change would help. Include any platform or headset requirements.
|
||||
|
||||
Small fixes and documentation corrections can go straight into a pull request.
|
||||
|
||||
## Make a change
|
||||
|
||||
Fork the repository and create a branch for your change, keeping each pull request focused on one problem so reviewers can understand and test it.
|
||||
Fork the repository and create a branch for your change. Keep each pull request focused on one problem so reviewers can understand and test it.
|
||||
|
||||
Follow the style of the surrounding code, use clear names, and keep functions small, adding comments only when the code cannot explain the reason for a decision. Write code, comments, and documentation in English.
|
||||
Follow the style of the surrounding code. Use clear names and keep functions small. Add comments when the code cannot explain the reason for a decision. Write code, comments, and documentation in English.
|
||||
|
||||
Update the documentation when you change setup steps, controls, or app behaviour. Keep generated builds, downloaded dependencies, credentials, and local editor settings out of your commits.
|
||||
|
||||
## Test your change
|
||||
|
||||
Run the checks that cover the code you changed, using the [development guide](DEVELOPMENT.md) to find the core, HTTP, decoder, streaming, and headset tests. Add a regression test when it can reproduce the bug or check the new behaviour.
|
||||
Run the checks that cover the code you changed. The [development guide](DEVELOPMENT.md) lists the core, HTTP, decoder, streaming, and headset tests. Add a regression test when it can reproduce the bug or check the new behaviour.
|
||||
|
||||
Desktop preview with `--xr-mode off` can check layout and library navigation, but cannot verify VR comfort, tracking, controller behaviour, stereo output, or video playback. Decoder and streaming tests check playback without a headset and cannot verify how it looks or behaves in VR.
|
||||
|
||||
@@ -52,6 +53,4 @@ Explain the problem and what your change does, link any related issue, and list
|
||||
|
||||
## Licence
|
||||
|
||||
Submit code contributions under the project's GPL-3.0-only licence, and include the licence, attribution, and source of any new assets or dependencies. See [Licence and attribution](LICENSING.md) and the full [licence text](LICENSE).
|
||||
|
||||
Thank you for contributing to Jellyframe.
|
||||
Submit code contributions under the project's GPL-3.0-only licence. Include the licence, attribution, and source of any new assets or dependencies. See [Licence and attribution](LICENSING.md) and the full [licence text](LICENSE).
|
||||
@@ -1,28 +1,18 @@
|
||||
# Build from source
|
||||
# Development
|
||||
|
||||
Jellyframe uses Godot and OpenXR, with an Android video plugin and a shared libmpv video plugin for Windows and Linux.
|
||||
jellyXR uses Godot 4.7.2 and OpenXR. Install Godot and its matching export templates before building. Run the commands below from the project root, with `godot` on your `PATH`.
|
||||
|
||||
## Android build requirements
|
||||
Build the native video plugin for your platform before importing the project. Close jellyXR before rebuilding the plugin.
|
||||
|
||||
## Build the Quest APK
|
||||
|
||||
### Requirements
|
||||
|
||||
- Godot 4.7.2 with matching Android export templates.
|
||||
- JDK 17.
|
||||
- Android SDK platforms 35 and 36, with build tools 36.1.0.
|
||||
- Gradle 8.10.2.
|
||||
|
||||
The Android plugin compiles against Godot's 4.5.1 Android API and uses the Compatibility renderer because video playback requires an external OpenGL texture. Jellyframe uses a fixed Android codec profile and does not check which codecs the headset can decode, while Windows and Linux use libmpv for PC VR playback.
|
||||
|
||||
## Build the Quest APK
|
||||
|
||||
To build a Quest 3 debug APK, open **Actions > Build Android APK > Run workflow**, which builds the native video plugin, exports the APK, and checks its signature. The workflow needs no repository secrets and keeps the APK artifact for 14 days.
|
||||
|
||||
Each workflow run creates a new debug signing key, so an installed APK signed with a different key must be uninstalled before you can install the new build. Uninstalling removes saved settings and sign-in, and these APKs are test builds rather than store releases.
|
||||
|
||||
To build locally:
|
||||
|
||||
1. Install the build requirements and accept the Android SDK licences.
|
||||
2. Set the Android SDK and Java SDK paths in Godot's editor settings.
|
||||
3. Select Project > Install Android Build Template.
|
||||
4. Run the build script with the paths to your installed tools:
|
||||
Set the Android SDK and Java SDK paths in Godot's editor settings, accept the Android SDK licences, and select **Project > Install Android Build Template**.
|
||||
|
||||
On Linux, install `curl`, `unzip`, and `sha256sum`, then run:
|
||||
|
||||
@@ -32,7 +22,7 @@ export ANDROID_HOME='/path/to/android-sdk'
|
||||
GODOT='/path/to/godot' GRADLE='/path/to/gradle' bash tools/build-android.sh
|
||||
```
|
||||
|
||||
If `godot` and `gradle` are on your `PATH`, omit the `GODOT` and `GRADLE` overrides. The script also accepts `ANDROID_SDK_ROOT` when `ANDROID_HOME` is unset. The Android workflow uses this Bash script.
|
||||
Omit `GODOT` and `GRADLE` if both tools are on your `PATH`. The script also accepts `ANDROID_SDK_ROOT` when `ANDROID_HOME` is unset.
|
||||
|
||||
On Windows, use PowerShell:
|
||||
|
||||
@@ -42,93 +32,89 @@ On Windows, use PowerShell:
|
||||
-AndroidSdk 'C:/tools/android-sdk' -JavaHome 'C:/tools/jdk-17'
|
||||
```
|
||||
|
||||
The script creates `dist/jellyframe-android.apk` for installation on your headset and downloads Godot OpenXR Vendors 5.1.0 for Quest passthrough support. It checks the archive's SHA-256 checksum and includes the vendor licences in the APK, keeping the downloaded addon in the ignored `addons/godotopenxrvendors` directory.
|
||||
The scripts build the Android plugin and export `dist/jellyxr-android.apk`. They download and verify Godot OpenXR Vendors for Quest passthrough support.
|
||||
|
||||
## Build and test on Windows
|
||||
To build through GitHub Actions, select **Actions > Build Android APK > Run workflow**. Each run creates a new debug signing key. If it differs from the installed APK's key, uninstall the old build first. Uninstalling removes saved settings and sign-in.
|
||||
|
||||
To build the Windows SteamVR app locally, install Godot 4.7.2 with matching export templates and Python 3.12. Build the PC plugin before importing or exporting the project:
|
||||
## Build on Windows
|
||||
|
||||
Install Python 3.12, then build the PC plugin and export the app:
|
||||
|
||||
```powershell
|
||||
./tools/build-pc-video.ps1 -Python 'C:/path/to/python.exe'
|
||||
New-Item -ItemType Directory -Force dist/windows
|
||||
godot --headless --xr-mode off --path . --editor --import --quit
|
||||
godot --headless --xr-mode off --path . --export-release 'Windows SteamVR' dist/windows/jellyframe.exe
|
||||
godot --headless --xr-mode off --path . --export-release 'Windows' dist/windows/jellyxr.exe
|
||||
```
|
||||
|
||||
The plugin build script downloads pinned CMake, LLVM-MinGW, godot-cpp, and libmpv archives into `build/desktop`, verifies their checksums, and generates `addons/jellyframe_mpv` without requiring a system-wide compiler installation. Close running Jellyframe instances before rebuilding; Git ignores the generated plugin, and Android exports exclude it.
|
||||
The plugin script downloads pinned build tools and libraries into `build/desktop`, verifies their checksums, and generates `addons/jellyxr_mpv`. You do not need a system-wide compiler installation.
|
||||
|
||||
The Windows workflow builds the plugin, exports the app, verifies the output files, and uploads the build. Tests run separately using the commands below. Build commands disable OpenXR, while the exported app keeps it enabled. The workflow needs no repository secrets, and GitHub keeps its build artifacts for 14 days.
|
||||
The output is in `dist/windows`. Keep all exported files together. Include the third-party licences when distributing a build, following the copy steps in the [Windows workflow](.github/workflows/build-windows.yml).
|
||||
|
||||
After building the PC plugin, run its decoder tests without a headset:
|
||||
## Build on Linux x64 or ARM64
|
||||
|
||||
```powershell
|
||||
godot --headless --xr-mode off --path . --script tests/test_pc_video.gd
|
||||
python tests/test_pc_streaming.py --godot 'C:/path/to/godot_console.exe'
|
||||
```
|
||||
|
||||
With SteamVR and your headset running, test the stereo screen and controllers:
|
||||
|
||||
```powershell
|
||||
godot --path . --script tests/test_steamvr_video.gd
|
||||
```
|
||||
|
||||
The headset test loops a labelled stereo clip for two minutes so you can close one eye at a time to check the LEFT and RIGHT labels, then press A or B to open the playback controls. The decoder tests cover resume, pause, seeking, completion, reopening, and errors.
|
||||
|
||||
Import `project.godot` into Godot, or run:
|
||||
|
||||
```powershell
|
||||
godot --path . --xr-mode off
|
||||
```
|
||||
|
||||
Connect to your Jellyfin server to browse your library in desktop mode, which does not save sessions or play videos.
|
||||
|
||||
## Build Linux x64 or ARM64
|
||||
|
||||
Build natively on the target architecture with Godot 4.7.2 and matching export templates. On Ubuntu, install the build dependencies:
|
||||
Build natively on the target architecture. On Ubuntu 24.04, install the dependencies and build the PC plugin:
|
||||
|
||||
```sh
|
||||
sudo apt-get install build-essential cmake pkg-config python3 curl unzip libmpv-dev
|
||||
sudo apt-get install build-essential cmake pkg-config python3 curl unzip libmpv-dev libavcodec-dev libavformat-dev libavutil-dev libswscale-dev
|
||||
bash tools/build-pc-video.sh
|
||||
godot --headless --xr-mode off --path . --editor --import --quit
|
||||
```
|
||||
|
||||
The script detects x64 or ARM64, downloads and verifies the godot-cpp bindings, and builds the matching plugin in `addons/jellyframe_mpv` using the system libmpv development package. Close Jellyframe before rebuilding its plugin.
|
||||
The script detects x64 or ARM64 and generates `addons/jellyxr_mpv`. It downloads and verifies godot-cpp, and uses the system libmpv and FFmpeg development packages. FFmpeg 6 or newer is required.
|
||||
|
||||
For x64:
|
||||
Export for x64:
|
||||
|
||||
```sh
|
||||
mkdir -p dist/linux-x86_64
|
||||
godot --headless --xr-mode off --path . --export-release 'Linux x64 OpenXR' dist/linux-x86_64/jellyframe-opengl.x86_64
|
||||
godot --headless --xr-mode off --path . --export-release 'Linux x64 Vulkan' dist/linux-x86_64/jellyframe-vulkan.x86_64
|
||||
godot --headless --xr-mode off --path . --export-release 'Linux x64' dist/linux-x86_64/jellyxr.x86_64
|
||||
```
|
||||
|
||||
For ARM64:
|
||||
Export for ARM64:
|
||||
|
||||
```sh
|
||||
mkdir -p dist/linux-arm64
|
||||
godot --headless --xr-mode off --path . --export-release 'Linux ARM64 OpenXR' dist/linux-arm64/jellyframe-opengl.arm64
|
||||
godot --headless --xr-mode off --path . --export-release 'Linux ARM64 Vulkan' dist/linux-arm64/jellyframe-vulkan.arm64
|
||||
godot --headless --xr-mode off --path . --export-release 'Linux ARM64' dist/linux-arm64/jellyxr.arm64
|
||||
```
|
||||
|
||||
The OpenXR presets select OpenGL Compatibility. The Vulkan presets select the Mobile renderer and Vulkan through custom feature tags. Both executables embed their own resources and share the native decoder library in the output folder.
|
||||
Keep the executable and native decoder `.so` file together.
|
||||
|
||||
**Build Linux** uses native `ubuntu-24.04` and `ubuntu-24.04-arm` runners. Each job builds its plugin, exports the OpenGL and Vulkan apps, checks output files and decoder dependencies, and packages a system-libraries archive plus separate OpenGL and Vulkan AppImages. Tests run separately using the commands below. The system-libraries artifact is a tar archive that preserves Linux executable permissions; GitHub keeps them for 14 days, and the workflow needs no repository secrets.
|
||||
### Package an AppImage
|
||||
|
||||
To package AppImages after exporting and copying the licenses into `dist/linux-<arch>`, install `patchelf` and run:
|
||||
Install `patchelf`. Copy the licences into the export folder as shown below for x64. Replace `x86_64` with `arm64` for an ARM64 build.
|
||||
|
||||
```sh
|
||||
cp -r build/linux/x86_64/licenses dist/linux-x86_64/
|
||||
cp LICENSE dist/linux-x86_64/licenses/jellyxr.txt
|
||||
cp LICENSING.md dist/linux-x86_64/licenses/
|
||||
bash tools/package-linux-appimages.sh x86_64
|
||||
# On an ARM64 build machine:
|
||||
bash tools/package-linux-appimages.sh arm64
|
||||
```
|
||||
|
||||
The packager reuses the decoder library bundler, includes its license notices and package manifest, and leaves graphics drivers and OpenXR to the host. It downloads appimagetool 1.9.1 for the selected architecture, verifies its SHA-256 checksum, and runs it without requiring FUSE. appimagetool downloads its AppImage runtime during packaging, so packaging requires network access. Outputs are `dist/jellyframe-linux-<arch>-opengl.AppImage` and `dist/jellyframe-linux-<arch>-vulkan.AppImage`.
|
||||
The packager bundles decoder libraries and licence notices. It needs network access to download appimagetool and its AppImage runtime. The output is `dist/jellyxr-linux-<arch>.AppImage`. The host supplies graphics drivers and OpenXR.
|
||||
|
||||
The decoder and headset tests in [Build and test on Windows](#build-and-test-on-windows) also work on Linux, where you should use `python3` for the HTTP streaming test and pass the path to your Linux Godot executable.
|
||||
## Run the app during development
|
||||
|
||||
Import `project.godot` into Godot, or launch the desktop library preview:
|
||||
|
||||
```sh
|
||||
godot --path . --xr-mode off
|
||||
```
|
||||
|
||||
Desktop preview supports sign-in and library browsing. Use a headset for video playback in the app.
|
||||
|
||||
Windows and Linux default to Vulkan and fall back to OpenGL if Vulkan cannot initialize. To select OpenGL manually:
|
||||
|
||||
```sh
|
||||
godot --path . --rendering-method gl_compatibility
|
||||
```
|
||||
|
||||
For VR, connect your headset and start its OpenXR runtime before launching. See [installation](INSTALLATION.md) for runtime setup and [usage](USAGE.md) for controls.
|
||||
|
||||
## Run the core and HTTP tests
|
||||
|
||||
```powershell
|
||||
Build the native plugin and import the project before running tests:
|
||||
|
||||
```sh
|
||||
godot --headless --xr-mode off --path . --editor --import --quit
|
||||
godot --headless --xr-mode off --path . --script tests/test_core.gd
|
||||
godot --headless --xr-mode off --path . --script tests/test_360_video.gd
|
||||
@@ -136,17 +122,97 @@ godot --headless --xr-mode off --path . --script tests/test_http.gd
|
||||
godot --headless --xr-mode off --path . --script tests/test_autoplay.gd
|
||||
godot --headless --xr-mode off --path . --script tests/test_player_return.gd
|
||||
godot --headless --xr-mode off --path . --script tests/test_player_tooltips.gd
|
||||
godot --headless --xr-mode off --path . --script tests/test_tracks.gd
|
||||
godot --headless --xr-mode off --path . --script tests/test_player_loading.gd
|
||||
godot --headless --xr-mode off --path . --script tests/test_photos.gd
|
||||
```
|
||||
|
||||
## Test PC video
|
||||
|
||||
These tests work on Windows and Linux. The examples use Windows paths. On Linux, use `python3` and replace the executable paths. The Python video tests need FFmpeg. Keep `ffprobe` beside the FFmpeg executable for the buffering tests.
|
||||
|
||||
### Decoder and streaming
|
||||
|
||||
```powershell
|
||||
godot --headless --xr-mode off --path . --script tests/test_pc_video.gd
|
||||
python tests/test_pc_streaming.py --godot 'C:/path/to/godot_console.exe'
|
||||
```
|
||||
|
||||
### GPU playback and transitions
|
||||
|
||||
Run these with a graphics context:
|
||||
|
||||
```powershell
|
||||
godot --xr-mode off --rendering-method gl_compatibility --path . --script tests/test_pc_gpu_video.gd
|
||||
godot --xr-mode off --rendering-method mobile --rendering-driver vulkan --path . --script tests/test_pc_gpu_video.gd
|
||||
godot --xr-mode off --rendering-method gl_compatibility --path . --script tests/test_pc_video_transitions.gd
|
||||
godot --xr-mode off --rendering-method mobile --rendering-driver vulkan --path . --script tests/test_pc_video_transitions.gd
|
||||
godot --xr-mode off --rendering-method mobile --rendering-driver vulkan --path . --script tests/test_pc_yuv_material.gd
|
||||
python tests/test_pc_vulkan.py --godot 'C:/path/to/godot_console.exe' --ffmpeg 'C:/path/to/ffmpeg.exe'
|
||||
python tests/test_pc_hdr.py --godot 'C:/path/to/godot_console.exe' --ffmpeg 'C:/path/to/ffmpeg.exe'
|
||||
python tests/test_pc_streaming.py --godot 'C:/path/to/godot_console.exe' --vulkan
|
||||
```
|
||||
|
||||
The transition test checks the black screen before playback and after stopping, then checks that a reopened video becomes visible. The Vulkan and HDR tests generate video fixtures and check color conversion, including HDR-to-SDR tone mapping.
|
||||
|
||||
PC Vulkan playback uploads the decoded YUV planes and samples them directly in the video material. Color conversion and HDR tone mapping run while drawing the screen or panorama, without a full-resolution RGBA intermediate texture. Hardware frames still pass through system memory before upload. OpenGL and software playback use the RGB material path. The YUV material test checks decoder padding, rotation, and stereo eye boundaries.
|
||||
|
||||
### Performance
|
||||
|
||||
Use a video long enough for the sample plus one second of warm-up:
|
||||
|
||||
```powershell
|
||||
godot --xr-mode off --rendering-method mobile --rendering-driver vulkan --path . --script tests/test_pc_video_performance.gd -- --video='C:/videos/8k.mp4' --seconds=20 --minimum-fps=57
|
||||
```
|
||||
|
||||
The test reports presentation rate, dropped frames, decoder and transfer timings, and main-loop frame times. It does not measure SteamVR performance.
|
||||
|
||||
### Buffering and recovery
|
||||
|
||||
Supply a video with audio and at least 20 seconds of content:
|
||||
|
||||
```powershell
|
||||
python tests/test_pc_video_recovery.py --godot 'C:/path/to/godot_console.exe' --ffmpeg 'C:/path/to/ffmpeg.exe' --video 'C:/videos/8k-with-audio.mp4'
|
||||
python tests/test_pc_video_recovery.py --godot 'C:/path/to/godot_console.exe' --ffmpeg 'C:/path/to/ffmpeg.exe' --video 'C:/videos/8k-with-audio.mp4' --expect-buffer-free
|
||||
```
|
||||
|
||||
The first command checks recovery, audio synchronization, and a manual pause during interrupted delivery. The second checks that read-ahead absorbs a short interruption without buffering. Omitting `--video` generates a 4K HEVC fixture and requires FFmpeg's NVIDIA NVENC encoder.
|
||||
|
||||
### Headset playback
|
||||
|
||||
Start SteamVR and connect your headset, then run:
|
||||
|
||||
```sh
|
||||
godot --rendering-method mobile --rendering-driver vulkan --path . --script tests/test_steamvr_video.gd
|
||||
```
|
||||
|
||||
The test loops a labelled stereo clip for two minutes. Close one eye at a time to check the LEFT and RIGHT labels. Press A to open playback controls. When signed in, press B to browse the library during playback. Replace the Vulkan arguments with `--rendering-method gl_compatibility` to test OpenGL.
|
||||
|
||||
Desktop and headless tests do not verify headset tracking, controller behavior, stereo output, or playback performance in VR.
|
||||
|
||||
### Playback logs
|
||||
|
||||
To record a Vulkan playback log, run the exported app with SteamVR active:
|
||||
|
||||
```powershell
|
||||
New-Item -ItemType Directory -Force logs | Out-Null
|
||||
$env:JELLYXR_VIDEO_STATS = '1'
|
||||
.\dist\windows\jellyxr.exe --rendering-method mobile --rendering-driver vulkan --log-file (Join-Path $PWD 'logs/vulkan-playback.log')
|
||||
```
|
||||
|
||||
Set `JELLYXR_PC_RENDERER=software` to force the software fallback for troubleshooting. Remove access tokens and private server details before sharing logs.
|
||||
|
||||
## Check 180° and 360° rendering
|
||||
|
||||
To render synthetic panoramas, check top-bottom eye selection, and check that the view behind a 180° video stays dark on desktop:
|
||||
|
||||
```powershell
|
||||
```sh
|
||||
godot --xr-mode off --path . --script tests/capture_360.gd
|
||||
```
|
||||
|
||||
The check tests the PC shader with a generated texture and saves panorama and settings previews in `dist`, without enabling desktop video playback. It cannot validate headset tracking, Android textures, or stereo output in a headset.
|
||||
This renders synthetic panoramas to check top-bottom eye selection and the dark area behind a 180° video. It saves previews in `dist` and does not test Android textures or headset stereo output.
|
||||
|
||||
## GitHub Actions
|
||||
|
||||
The [Android](.github/workflows/build-android.yml), [Windows](.github/workflows/build-windows.yml), and [Linux](.github/workflows/build-linux.yml) workflows build and upload their platform packages. They need no repository secrets and keep artifacts for 14 days. Run the playback tests separately with the commands above.
|
||||
|
||||
## Code layout
|
||||
|
||||
@@ -154,5 +220,5 @@ The check tests the PC shader with a generated texture and saves panorama and se
|
||||
- `scripts/vr` handles environments, controller pointing, and recentering.
|
||||
- `scripts/player` selects Android playback or PC VR playback.
|
||||
- `native/android` contains the Android decoder.
|
||||
- `native/pc` contains the libmpv PC decoder.
|
||||
- `native/pc` contains the libmpv OpenGL renderer and FFmpeg Vulkan decoder.
|
||||
- `scripts/core` contains screen settings and stereo geometry.
|
||||
@@ -1,59 +1,67 @@
|
||||
# Install and start Jellyframe
|
||||
# Install and start jellyXR
|
||||
|
||||
## What you need
|
||||
Download the package for your platform from **Assets** on the [latest release page](https://github.com/Cuteminded/jellyframe/releases/latest).
|
||||
|
||||
- Access to a Jellyfin server and an account on that server.
|
||||
- For Quest, a Quest 3 with developer mode and USB debugging enabled, plus a computer to install the app.
|
||||
- For Windows or Linux, a computer and a headset with a compatible OpenXR runtime.
|
||||
You need a Jellyfin server and an account to use the app. To build jellyXR yourself, follow the [build instructions](DEVELOPMENT.md).
|
||||
|
||||
The initial Jellyfin compatibility target is version 10.11.6. Use HTTPS when connecting to a server outside your home network.
|
||||
## Quest 3
|
||||
|
||||
## Try it on Quest 3
|
||||
Enable developer mode and USB debugging on your headset. Install Android's `adb` tool on your computer and download `jellyxr-android.apk`.
|
||||
|
||||
Download a test APK from a successful **Build Android APK** run in the repository's **Actions** tab by opening **Artifacts**, downloading `jellyframe-android-debug-<run number>`, and extracting the ZIP. To build the APK yourself, follow the [build instructions](DEVELOPMENT.md).
|
||||
|
||||
After downloading or building the APK, connect your headset to your computer, approve USB debugging in the headset, and install it with Android's `adb` tool:
|
||||
Connect your headset to your computer by USB and approve USB debugging in the headset. Run these commands from the folder containing the APK:
|
||||
|
||||
```powershell
|
||||
adb devices
|
||||
adb install -r dist/jellyframe-android.apk
|
||||
adb install -r jellyxr-android.apk
|
||||
```
|
||||
|
||||
Open Jellyframe from Unknown Sources in your headset, enter your Jellyfin server address, and sign in with your account or Quick Connect. Select a library and choose a title to watch.
|
||||
Open jellyXR from **Unknown Sources** in your headset.
|
||||
|
||||
## Try it on Windows with SteamVR
|
||||
## Windows with SteamVR
|
||||
|
||||
Open **Actions > Build Windows > Run workflow** in the repository, then download `jellyframe-windows-<run number>` from **Artifacts** after the build succeeds. Extract the entire ZIP and keep the executable, PCK, and any DLLs together in the same folder.
|
||||
Download the Windows ZIP and extract the entire archive. Keep all files together in the extracted folder.
|
||||
|
||||
Install SteamVR, connect your headset, and set SteamVR as the active OpenXR runtime in SteamVR's **Settings > OpenXR**. Start SteamVR and run `jellyframe.exe`; Godot uses the active OpenXR runtime, as described in the [Godot Windows PCVR guide](https://www.khronos.org/assets/uploads/developers/presentations/Godot_guide_to_OpenXR_for_Windows_PCVR.pdf).
|
||||
Install SteamVR and connect your headset. In SteamVR's **Settings > OpenXR**, set SteamVR as the active OpenXR runtime. Start SteamVR, then run `jellyxr.exe`.
|
||||
|
||||
Sign in to Jellyfin and select a title to play it in VR, using the same pause, seek, resume, and side-by-side 3D controls as the Quest build. Keep `jellyframe_mpv.dll` and `libmpv-2.dll` beside the executable.
|
||||
If jellyXR cannot start with Vulkan, try OpenGL from PowerShell in the extracted folder:
|
||||
|
||||
## Try it on Linux x64 or ARM64
|
||||
```powershell
|
||||
.\jellyxr.exe --rendering-method gl_compatibility
|
||||
```
|
||||
|
||||
Open **Actions > Build Linux > Run workflow** and download an OpenGL or Vulkan AppImage for your computer's architecture, `x86_64` or `arm64`. AppImages replace the bundled-libraries archives and include libmpv and the bundled decoder libraries.
|
||||
## Linux x64 or ARM64
|
||||
|
||||
Make the downloaded AppImage executable and launch it. For x64 OpenGL:
|
||||
Download the AppImage for your computer's architecture, `x86_64` or `arm64`. The builds target Ubuntu 24.04 and need compatible system libraries, graphics drivers, and an OpenXR runtime.
|
||||
|
||||
Connect your headset and start your OpenXR runtime. On x64, set SteamVR as the active runtime. Make the AppImage executable and launch it:
|
||||
|
||||
```sh
|
||||
chmod +x jellyframe-linux-x86_64-opengl.AppImage
|
||||
./jellyframe-linux-x86_64-opengl.AppImage
|
||||
chmod +x jellyxr-linux-x86_64.AppImage
|
||||
./jellyxr-linux-x86_64.AppImage
|
||||
```
|
||||
|
||||
If OpenGL shows a black screen, download and run `jellyframe-linux-x86_64-vulkan.AppImage`. Use the Vulkan AppImage when adding Jellyframe to Steam on SteamOS. For ARM64, use the corresponding `jellyframe-linux-arm64-opengl.AppImage` or `jellyframe-linux-arm64-vulkan.AppImage`.
|
||||
For ARM64, replace the filename with `jellyxr-linux-arm64.AppImage`. On SteamOS, add the AppImage to Steam as a non-Steam game.
|
||||
|
||||
If jellyXR cannot start with Vulkan, try OpenGL:
|
||||
|
||||
```sh
|
||||
./jellyxr-linux-x86_64.AppImage --rendering-method gl_compatibility
|
||||
```
|
||||
|
||||
If mounting an AppImage fails because FUSE is unavailable, run it with `--appimage-extract-and-run`.
|
||||
|
||||
The builds target Ubuntu 24.04. AppImages still need compatible system libraries, graphics drivers, and an OpenXR runtime; they do not guarantee compatibility with older Linux distributions. Start your OpenXR runtime with the headset connected before opening Jellyframe, and set SteamVR as the active runtime on x64. The runtime must support the selected graphics API. SteamVR on ARM64 and standalone Steam Frame support still need headset testing.
|
||||
### Use system libraries instead of an AppImage
|
||||
|
||||
The `system-libs` archive remains available if you prefer to install libmpv yourself. It contains both renderers. On Ubuntu x64:
|
||||
Download the `system-libs` archive for your architecture. On Ubuntu x64, install libmpv, extract the archive, and launch jellyXR:
|
||||
|
||||
```sh
|
||||
sudo apt-get install libmpv2
|
||||
tar -xzf jellyframe-linux-x86_64-system-libs.tar.gz
|
||||
./jellyframe-opengl.x86_64
|
||||
tar -xzf jellyxr-linux-x86_64-system-libs.tar.gz
|
||||
./jellyxr.x86_64
|
||||
```
|
||||
|
||||
Keep both executables and the `.so` file together. For ARM64, use `jellyframe-linux-arm64-system-libs.tar.gz` and launch `./jellyframe-opengl.arm64` or `./jellyframe-vulkan.arm64`.
|
||||
Keep the executable and the `.so` file together. For ARM64, use `jellyxr-linux-arm64-system-libs.tar.gz` and launch `./jellyxr.arm64`.
|
||||
|
||||
Sign in and play a title using the same VR controls as Windows; desktop preview with `--xr-mode off` does not play video.
|
||||
## After installation
|
||||
|
||||
Follow the [usage guide](USAGE.md) to sign in and play a video. See the [current limitations](README.md#current-limitations) for platform and headset compatibility.
|
||||
@@ -1,9 +1,42 @@
|
||||
# Licence and attribution
|
||||
# License and attribution
|
||||
|
||||
Copyright (c) 2026 Cuteminded.
|
||||
Copyright © 2026 Cuteminded.
|
||||
|
||||
Jellyframe's code is licensed under the [GNU General Public License, version 3 only](LICENSE), identified as `GPL-3.0-only`, while Godot and Android components retain their own licences.
|
||||
jellyXR's source code is licensed under the [GNU General Public License v3.0 only](LICENSE) (`GPL-3.0-only`).
|
||||
|
||||
The player and library buttons use Google's Material Symbols Rounded icons, and the Cinema environment uses Comicaroid's cinema interior under CC BY 4.0.
|
||||
## Third-party components
|
||||
|
||||
Library artwork comes from your Jellyfin server and is not bundled with Jellyframe.
|
||||
jellyXR uses third-party software and assets under their own licenses:
|
||||
|
||||
- Godot and Android components retain their original licenses and copyright notices.
|
||||
- Icons in the player and library come from [Google's Material Symbols Rounded](https://github.com/google/material-design-icons).
|
||||
- The virtual cinema is based on [Comicaroid's Cinema/Movie Theater Interior](https://sketchfab.com/3d-models/cinemamovie-theater-interior-dcaf6cdebcad4f03879c24186da257ee), licensed under [Creative Commons Attribution 4.0 International (CC BY 4.0)](https://creativecommons.org/licenses/by/4.0/).
|
||||
|
||||
## Media artwork
|
||||
|
||||
jellyXR retrieves movie posters, backdrops, and other media artwork from the user's Jellyfin server. It does not include these assets in its source code or distribute them with the app.
|
||||
|
||||
# PC playback dependencies
|
||||
|
||||
The Windows build includes third-party libraries for video playback and integration with Godot.
|
||||
|
||||
The build bundles libmpv from [shinchiro's Windows builds](https://github.com/shinchiro/mpv-winbuild-cmake/releases/tag/20261010), release `20261010`, based on mpv revision `b2c255c13e`. It also links against godot-cpp version `godot-4.5-stable`.
|
||||
|
||||
The Vulkan video backend also links against FFmpeg. Windows builds bundle the shared libraries from [BtbN's release of October 19, 2026](https://github.com/BtbN/FFmpeg-Builds/releases/tag/autobuild-2026-10-19-13-06), archive `ffmpeg-n8.1.3-18-g46d899d7a0-win64-gpl-shared-8.1.zip`.
|
||||
|
||||
This archive uses FFmpeg revision `46d899d7a0` with GPL components enabled. The exported app includes its GPLv3 license in `licenses/ffmpeg.txt`.
|
||||
|
||||
The [build scripts](https://github.com/BtbN/FFmpeg-Builds) describe the configuration and dependency sources. The [FFmpeg source](https://github.com/FFmpeg/FFmpeg/tree/46d899d7a0) is available separately.
|
||||
|
||||
Linux builds use distribution FFmpeg libraries. Packaging includes the notices for the bundled libraries.
|
||||
|
||||
## Sources and attribution
|
||||
|
||||
Sources, build configurations, and license information:
|
||||
|
||||
- [libmpv Windows binary release, 20261010](https://github.com/shinchiro/mpv-winbuild-cmake/releases/tag/20261010)
|
||||
- [mpv source code and copyright information, b2c255c13e](https://github.com/mpv-player/mpv/tree/b2c255c13e)
|
||||
- [Windows decoder build scripts and dependency sources](https://github.com/shinchiro/mpv-winbuild-cmake)
|
||||
- [godot-cpp source code, Godot 4.5 stable](https://github.com/godotengine/godot-cpp/tree/godot-4.5-stable)
|
||||
|
||||
Each component keeps its own license and copyright notices.
|
||||
@@ -1,45 +1,43 @@
|
||||
<img src="assets/logo/jellyframe-wordmark.png" alt="Jellyframe" style="width:400px;"/>
|
||||
<img src="assets/logo/jellyxr-wordmark.png" alt="jellyXR" style="width:400px;"/>
|
||||
|
||||
Watch your Jellyfin movies and shows on a virtual screen in VR.
|
||||
|
||||
Jellyframe is a free, open source app that connects to your Jellyfin server so you can browse your library, choose a title, and position the screen where you want it. Your server supplies the movies and shows.
|
||||
jellyXR is a free, open source app for browsing your Jellyfin library and watching videos in VR. Choose a title and position the screen where you want it. Your Jellyfin server supplies the movies and shows.
|
||||
|
||||
## From JellyQuest to Jellyframe
|
||||
## Hardware status
|
||||
|
||||
This project started as JellyQuest, a Jellyfin VR app for the Quest 3. I renamed it Jellyframe because I plan to switch to Steam Frame, which is now the main target for future development. I do not have a Steam Frame yet, so I cannot test it on the headset, but I am still developing the Quest version under the Jellyframe name.
|
||||
| Device | Status | Build | Notes |
|
||||
|---|---|---|---|
|
||||
| [Quest 1](https://www.meta.com/quest/) | Pending | Android | Installed but not fully tested |
|
||||
| [Quest 3](https://www.meta.com/quest/) | Working | Android | |
|
||||
| [Xreal aura](https://www.xreal.com/aura) | Waiting for device | Android | Not yet released in Europe |
|
||||
| [Steam Frame](https://store.steampowered.com/hardware/steamframe) | Waiting for device | Linux ARM64 | On reservation queue |
|
||||
| [SteamVR](https://store.steampowered.com/steamvr) | Partially Working | Linux X64 | Needs further testing with Vulkan |
|
||||
| [SteamVR](https://store.steampowered.com/steamvr) | Working | Windows | |
|
||||
|
||||
## What you can do
|
||||
> **Note:** If you have a device that isn't listed here, or can test jellyXR on any of the listed devices, we'd love to hear from you!
|
||||
>
|
||||
> Please [open an issue](https://github.com/Cuteminded/jellyframe/issues) to share your results and help us keep the hardware compatibility list accurate and up to date.
|
||||
|
||||
Browse and search your Jellyfin libraries, watch 2D, side-by-side 3D, 180° and 360° video, and use your controllers to select titles and control playback. Choose a dark background, a cinema, or passthrough on supported devices, and let episodes play automatically in series order.
|
||||
|
||||
See [Using Jellyframe](USAGE.md) for controls, screen settings, environments, and sign-in behaviour.
|
||||
## From JellyQuest to jellyXR
|
||||
|
||||
## Get started
|
||||
This project started as **JellyQuest**, a Jellyfin VR app for the Quest. I renamed it **jellyXR** because I want the app to work across multiple VR and AR devices, without tying its name to a single headset or platform.
|
||||
|
||||
You need a Jellyfin server and an account on that server; the initial compatibility target is Jellyfin 10.11.6.
|
||||
The goal is to support standalone headsets and devices connected to a PC. Support depends on each device's capabilities and testing. The hardware status table above shows what works today and what still needs testing.
|
||||
|
||||
Follow the [installation guide](INSTALLATION.md) to download a build, install the app, and sign in on Quest 3, Windows with SteamVR, or Linux x64 and ARM64 with OpenXR.
|
||||
## Will it be available in a store?
|
||||
|
||||
If we think jellyXR is ready for a wider release, then yes. I'd be happy to put in the work to make it available on Steam or the Meta Store.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Install and start](INSTALLATION.md)
|
||||
- [Using Jellyframe](USAGE.md)
|
||||
- [Using jellyXR](USAGE.md)
|
||||
- [Build from source, tests, and code layout](DEVELOPMENT.md)
|
||||
- [Contributing](CONTRIBUTING.md)
|
||||
- [Licence and attribution](LICENSING.md)
|
||||
|
||||
## Current limitations
|
||||
|
||||
- 180° projection and top-bottom 3D still need headset validation.
|
||||
- Steam Frame installation and playback remain untested.
|
||||
- Linux VR playback has been tested on a Steam Machine running SteamOS, using Vulkan and SteamVR. ARM64 and other Linux systems still need headset testing.
|
||||
- CI playback checks run without a headset and do not verify tracking, controller input, or stereo output.
|
||||
- Desktop mode only shows the interface. PC video playback requires an active OpenXR session with a connected headset.
|
||||
- PC OpenGL builds render video on a worker thread into buffered GPU textures, with software video rendering as a fallback. Decoding can still copy frames through system memory, and Vulkan builds use the software video renderer. High-resolution playback needs performance testing, especially with 8K and full-SBS files.
|
||||
- Specific 4K HEVC and HDR videos still need testing; HDR playback relies on your Jellyfin server to convert the video to standard dynamic range with correct colours.
|
||||
- Some files need your Jellyfin server to convert the video during playback, which can fail if the server cannot handle the conversion.
|
||||
- Audio and subtitle selection, favourites, and collections are not available yet.
|
||||
- Marking titles as watched manually and hiding playback controls automatically are planned but not available yet.
|
||||
|
||||
## Licence
|
||||
|
||||
Jellyframe uses GPL-3.0-only; see [Licence and attribution](LICENSING.md) for licence terms and third-party credits.
|
||||
jellyXR uses GPL-3.0-only; see [Licence and attribution](LICENSING.md) for licence terms and third-party credits.
|
||||
@@ -0,0 +1,113 @@
|
||||
# Jellyfin feature checklist
|
||||
|
||||
This checklist compares jellyXR with Jellyfin's user-facing features.
|
||||
Implementation does not mean a feature has been tested on every headset.
|
||||
|
||||
- Checked items are implemented in the current source.
|
||||
- Unchecked items are missing or incomplete.
|
||||
- Hardware validation is tracked separately below.
|
||||
|
||||
## Sign-in and accounts
|
||||
|
||||
- [x] Connect to a custom Jellyfin server address.
|
||||
- [x] Sign in with a username and password.
|
||||
- [x] Sign in with Quick Connect after approval on another device.
|
||||
- [x] Restore a saved session without storing the password.
|
||||
- [x] Sign out and clear the saved session.
|
||||
- [ ] Save multiple servers and switch between them.
|
||||
- [ ] Switch between saved user accounts without signing out.
|
||||
- [ ] Discover Jellyfin servers on the local network.
|
||||
- [ ] Manage account settings and audio/subtitle language preferences.
|
||||
|
||||
## Libraries and discovery
|
||||
|
||||
- [x] Browse movies, series, and video items with posters and backdrops.
|
||||
- [x] Select individual server libraries from the sidebar.
|
||||
- [x] Show recently added titles on Home.
|
||||
- [x] Show a Continue watching row on Home.
|
||||
- [x] Search titles globally or within the selected library.
|
||||
- [x] Load more titles while scrolling.
|
||||
- [x] Sort libraries by name, rating, date, play count, or runtime, with ascending and descending order.
|
||||
- [x] Browse titles in random order.
|
||||
- [x] Browse series by season and episode.
|
||||
- [x] Display watched indicators and saved playback progress supplied by Jellyfin.
|
||||
- [X] Show a Next up row for series. Automatic playback of the next episode already exists.
|
||||
- [ ] Filter by watched status, genre, year, or other metadata.
|
||||
- [ ] Add, remove, and browse favourites.
|
||||
- [ ] Browse collections.
|
||||
- [ ] Browse, create, and play playlists or a user-managed playback queue.
|
||||
- [ ] Show recommendations and similar titles.
|
||||
- [ ] Open actor or crew pages and browse their other titles.
|
||||
|
||||
## Title details and watch history
|
||||
|
||||
- [x] Show descriptions, release years, runtimes, ratings, and genres.
|
||||
- [x] Show cast and crew portraits, names, and roles.
|
||||
- [x] Show studio and video/audio stream information when available.
|
||||
- [x] Resume a movie or episode from its saved position.
|
||||
- [x] Start a partially watched title from the beginning.
|
||||
- [x] Report playback start, progress, pause state, and stop events to Jellyfin.
|
||||
- [x] Manually mark a movie or episode as watched or unwatched.
|
||||
- [x] Manually mark a season as watched or unwatched.
|
||||
- [x] Manually mark a series as watched or unwatched.
|
||||
- [x] Select between multiple media versions
|
||||
- [ ] Browse and play trailers and extras.
|
||||
|
||||
## Video playback
|
||||
|
||||
- [x] Play supported original video streams through Direct Play.
|
||||
- [x] Request server transcoding and play HLS streams.
|
||||
- [x] Retry with transcoding after a playback failure in Automatic mode.
|
||||
- [x] Choose Automatic, Original full resolution, or Transcoded playback.
|
||||
- [x] Choose 720p, 1080p, or 4K transcoding limits before starting playback.
|
||||
- [x] Pause, resume, stop, seek on the timeline, and skip backward or forward.
|
||||
- [x] Show loading and buffering feedback.
|
||||
- [x] Browse the library while the current video continues playing.
|
||||
- [x] Play the next available episode automatically after a cancellable countdown.
|
||||
- [x] Manually skip intros, credits, recaps, and previews when the server supplies media segments. Requires the Jellyfin 10.10+ API and a segment provider such as Intro Skipper.
|
||||
- [x] Enable Direct Stream/remuxing in Automatic playback.
|
||||
- [x] Select audio tracks and languages through server streaming.
|
||||
- [x] Select embedded or external text subtitles, or use server burn-in for picture subtitles.
|
||||
- [x] Adjust text subtitle size, color, vertical position, and timing.
|
||||
- [ ] Browse chapters and jump to a selected chapter.
|
||||
- [ ] Change playback speed.
|
||||
- [ ] Change playback quality during an active session.
|
||||
- [ ] Automatically hide playback controls after inactivity.
|
||||
- [ ] Download media for offline playback.
|
||||
|
||||
## Other Jellyfin features
|
||||
|
||||
These extend beyond jellyXR's current movie and series focus.
|
||||
|
||||
- [x] View photos.
|
||||
- [x] Show loading feedback when opening photos from the library and switching photos.
|
||||
- [ ] View slideshows.
|
||||
- [ ] Read books and comics.
|
||||
- [ ] Watch live TV and browse the programme guide.
|
||||
- [ ] Join or host SyncPlay sessions.
|
||||
- [ ] Receive remote playback commands from other Jellyfin clients.
|
||||
|
||||
## jellyXR VR features
|
||||
|
||||
These are jellyXR features rather than Jellyfin feature gaps.
|
||||
|
||||
- [x] Browse and control playback with VR controllers and an on-screen keyboard.
|
||||
- [x] Adjust screen width, distance, height, and curvature in supported environments.
|
||||
- [x] Choose Void, Cinema, Theatre, or supported-device Passthrough environments.
|
||||
- [x] Select 2D, half-SBS, full-SBS, and top-bottom layouts, with an eye-swap option.
|
||||
- [x] Select flat-screen, 180-degree, or 360-degree projection, with automatic detection for supported metadata and filenames.
|
||||
- [x] Save screen, environment, library sorting, and playback quality settings.
|
||||
- [ ] Implement native HDR output and display calibration.
|
||||
|
||||
## Hardware validation
|
||||
|
||||
- [ ] Verify installation and playback on Steam Frame.
|
||||
- [ ] Verify installation and playback on Android XR.
|
||||
- [x] Confirm Quest 3 HLS seeking and resume after the timeline fix.
|
||||
- [x] Verify top-bottom stereo and eye swapping on Quest 3.
|
||||
- [x] Verify stereoscopic 180-degree projection and recentering on Quest 3.
|
||||
- [x] Verify Quest 3 HDR content playback.
|
||||
- [x] Verify PC OpenGL HDR playback in a headset.
|
||||
- [x] Verify photo loading feedback on Quest 3, both from the library and between photos.
|
||||
- [ ] Verify ARM64 and other Linux configurations on headsets.
|
||||
- [ ] Verify the new native Vulkan decoder, HDR conversion, and 4K/8K playback on Linux with a headset.
|
||||
@@ -1,50 +1,193 @@
|
||||
# Using Jellyframe
|
||||
# Using jellyXR
|
||||
|
||||
## Browsing and playback
|
||||
1. Enter the address you normally use to open Jellyfin, such as `https://jellyfin.example.com`. If someone else runs the server, ask them for the address.
|
||||
2. Enter your Jellyfin username and password.
|
||||
3. Select **Sign in**.
|
||||
|
||||
- Sign in with your Jellyfin username and password or Quick Connect.
|
||||
- Browse and search your libraries, view title details, and continue watching.
|
||||
- Watch 2D movies and side-by-side 3D videos.
|
||||
- Watch equirectangular 180° and 360° videos in 2D, side-by-side 3D, or top-bottom 3D.
|
||||
- Resize the screen and adjust its distance, height, and curvature.
|
||||
- Recenter the screen in your viewing direction.
|
||||
- Watch against a dark background, in a cinema, or with your room visible through passthrough.
|
||||
- Use your controllers to select titles, type, pause, and skip through a video, or point at a player control to see its label beneath the buttons.
|
||||
- Use the Library button to return to the selected movie or series, keeping the current season open for a series. Selecting the active title returns to playback.
|
||||
- When an episode ends, a 5-second countdown appears before the next available episode starts in series order, including across seasons. Cancel the countdown to return to your library.
|
||||
To use **Quick Connect**, select it and note the code jellyXR displays. Open Jellyfin on your phone or computer, sign in, and approve that code using Quick Connect.
|
||||
|
||||
Jellyframe supports one server and one user at a time, saves your screen settings, and remembers your sign-in without storing your password. On PC, it stores the sign-in token in `session.cfg` in Godot's local user-data folder.
|
||||
jellyXR remembers your sign-in without saving your password. Select **Sign out** before switching accounts or servers. You can use one server and account at a time.
|
||||
|
||||
Default PC session locations:
|
||||
## Reset your saved sign-in on PC
|
||||
|
||||
| Platform | File |
|
||||
If you cannot sign out through the app, remove the saved sign-in file:
|
||||
|
||||
1. Close jellyXR completely.
|
||||
2. Open the folder listed below. On Windows, paste `%APPDATA%\jellyXR` into the address bar in File Explorer and press Enter.
|
||||
3. Delete `session.cfg`.
|
||||
4. Open jellyXR again and sign in.
|
||||
|
||||
| Computer | Folder |
|
||||
| --- | --- |
|
||||
| Windows | `%APPDATA%\Jellyframe\session.cfg` |
|
||||
| Linux | `~/.local/share/Jellyframe/session.cfg` |
|
||||
| Windows | `%APPDATA%\jellyXR` |
|
||||
| Linux | `~/.local/share/jellyXR` |
|
||||
|
||||
Jellyframe reads the 3D format from Jellyfin's metadata or the filename, but you can select half or full side-by-side mode and swap the left and right views in Settings if the picture looks wrong. Top-bottom uses the upper half for the left eye and the lower half for the right eye; format and eye-swap changes are not saved per title.
|
||||
To reset the saved app settings as well, delete `settings.cfg` from the same folder while jellyXR is closed. You will need to enter your server address and choose your settings again. This does not delete movies, shows, or your Jellyfin account.
|
||||
|
||||
## 180° and 360° videos
|
||||
## Choose something to watch
|
||||
|
||||
Start a video, open Settings > Screen, and choose **180°** or **360°** under Video projection, then select **2D**, **Half-SBS**, **Full-SBS**, or **Top-bottom** under Video format to match the file. 180° fills the view in front of you and leaves the view behind you dark, while 360° surrounds you.
|
||||
1. Select **Home** to see recently added movies and shows, or choose one of your libraries from the menu.
|
||||
2. Browse the titles, or enter a name in the search box and select **Search**.
|
||||
3. Select a movie to open its details, then choose **Play movie**. For a series, choose a season and select **Play episode** beside the episode you want.
|
||||
4. If you have watched part of a title before, choose **Resume** or **Resume episode** to continue from where you left off.
|
||||
|
||||
Look around with your headset to see the scene, use the headset's recenter command to set the forward direction, and press A or B to show the playback controls in front of you.
|
||||
On a title's details page, **Play from start** appears beside **Resume** when saved progress is available. It starts the video at the beginning, even if that title is already playing.
|
||||
|
||||
Jellyframe selects 360° automatically for filenames with a separate `360`, `360deg`, or `VR360` marker, such as `Beach.360.mp4`, and selects 180° for a separate `180`, `180deg`, or `VR180` marker. It does not infer the projection from video dimensions; files with a separate `TB`, `OU`, `top-bottom`, or `over-under` marker select top-bottom 3D.
|
||||
Play and Resume use the first available version. If a movie or episode has several versions, select **Choose version** to pick the one you want to watch.
|
||||
|
||||
180° and 360° modes support equirectangular video, but not cubemap or fisheye layouts. The video replaces the chosen environment during playback, and screen size and position controls do not affect it. Select **Screen** to return to the virtual screen; projection overrides apply to the current playback and are not saved per title, so a new title uses automatic detection again.
|
||||
In VR, point your controller at a button and squeeze the trigger to select it. Point at a text box to open the keyboard. Use the thumbstick to scroll through a list.
|
||||
|
||||
## Settings and environments
|
||||
## View photos
|
||||
|
||||
Use the separate settings pages to change library sorting, screen controls, and environments. For library sorting, choose a field and ascending or descending order; Home always shows the newest additions first.
|
||||
Select a photo in your library to open it. A "Loading photo..." indicator appears while the image loads. Use the previous and next controls to browse photos; the same indicator appears while another photo is loading. If loading fails, the app returns to the library with an error message so you can try again.
|
||||
|
||||
Choose an environment:
|
||||
## Control playback
|
||||
|
||||
- Void, the default, gives you a dark background.
|
||||
- Cinema uses the detailed cinema interior.
|
||||
- Theatre uses the original simple cinema.
|
||||
- Passthrough shows your room around the screen and is disabled on unsupported devices.
|
||||
Press **A** to show the playback controls.
|
||||
Press **B** to open or close the library during playback.
|
||||
Press **Y** on the left controller to open or close settings.
|
||||
|
||||
Cinema and Theatre have a fixed front screen with black bars to preserve the video's proportions, while Void and Passthrough let you adjust the screen's size, distance, height, and curvature.
|
||||
| Control | What it does |
|
||||
| --- | --- |
|
||||
| Play / Pause | Pause the video or continue watching. |
|
||||
| Stop | Stop the video and return to your library. |
|
||||
| Back 10 seconds | Go back a little to watch something again. |
|
||||
| Forward 30 seconds | Skip ahead. |
|
||||
| Time bar | Choose a different point in the video. |
|
||||
| Settings | Change the screen, background, or playback quality. |
|
||||
| Audio and subtitles | Choose a language, turn subtitles on or off, and adjust how they look. |
|
||||
| Hide controls | Remove the controls from your view. Press A to show them again. |
|
||||
| Statistics for nerds | Use the chart button at the far right to inspect playback details and live statistics. Select it again to dismiss the panel. |
|
||||
|
||||
Reset to defaults restores the screen settings and keeps your chosen environment.
|
||||
Press **B** to browse your library while playback continues. Select the title that is already playing to return to the video.
|
||||
|
||||
When an episode finishes, a five-second countdown appears before the next available episode starts. Choose **Cancel** during the countdown to return to your library.
|
||||
|
||||
### Skip intros and credits
|
||||
|
||||
If Jellyfin knows where an intro or the credits begin, a **Skip intro** or **Skip credits** button appears near the bottom-right of the video. Point at it and press the trigger to skip. You can use it without opening the playback controls. Some videos also have buttons to skip a recap or preview.
|
||||
|
||||
This needs setup in Jellyfin, for example with [Intro Skipper](https://github.com/intro-skipper/intro-skipper). If someone else manages your Jellyfin server, ask them to enable it. Skip buttons appear only after Jellyfin has checked the video and found the parts it can skip.
|
||||
|
||||
## Audio and subtitles
|
||||
|
||||
Press **A** to open the playback controls, then select **Audio and subtitles**.
|
||||
|
||||
### Choose a language
|
||||
|
||||
Under **Audio**, select the language or version you want to hear. The choices depend on the video. Some videos also include commentary.
|
||||
|
||||
The video briefly reloads when you change the audio, then continues from the same point. If you paused it first, it stays paused.
|
||||
|
||||
If the choices are unavailable, select **Use Automatic for this video**. This lets Jellyfin prepare the video for your chosen audio or subtitles. It does not change your saved playback preference.
|
||||
|
||||
Select **Back to video** to continue watching, or press **B** to open your library.
|
||||
|
||||
### Turn subtitles on or off
|
||||
|
||||
Under **Subtitles**, choose one of the available options. Choose **Off** to hide them.
|
||||
|
||||
Subtitles may take a little while to load. If loading fails, select the same option again to retry.
|
||||
|
||||
### Make subtitles easier to read
|
||||
|
||||
Under **Text subtitle appearance**, you can:
|
||||
|
||||
- Change **Text size** to make the letters larger or smaller.
|
||||
- Change **Vertical position** to move the subtitles up or down.
|
||||
- Choose white, yellow, or green text.
|
||||
- Change **Delay** if the words appear at the wrong time.
|
||||
|
||||
If the words appear too early, increase Delay with **+**. If they appear too late, decrease it with **-**. Select **Reset timing** to undo your timing changes.
|
||||
|
||||
The app remembers your size, position, and color choices. Timing changes apply only to the current video. Subtitles move above the playback controls while those controls are visible, then return to your chosen position when you hide them.
|
||||
|
||||
Some subtitles are saved as pictures. Choosing these briefly reloads the video, and you cannot change their size, color, position, or timing. The app tells you when those adjustments do not apply. Other subtitles may look simpler than they do in another player, without their original fonts or placement.
|
||||
|
||||
## Make the screen comfortable
|
||||
|
||||
Open **Settings**, then choose **Screen**. If your chosen background allows screen adjustments, you can change:
|
||||
|
||||
- **Width** to make the screen larger or smaller.
|
||||
- **Distance** to move it closer or farther away.
|
||||
- **Height** to move it up or down.
|
||||
- **Curvature** to make it flat or curved around you.
|
||||
|
||||
If the screen is facing the wrong way, look in the direction you want to watch and use your headset's recenter command.
|
||||
|
||||
**Cinema** and **Theatre** have fixed screens. To move or resize the screen yourself, choose **Void** or **Passthrough** under **Settings > Environment**.
|
||||
|
||||
Select **Reset to defaults** on the Screen page to restore the usual screen size and position. This keeps your chosen background and video format. Settings are saved as you change them.
|
||||
|
||||
## Choose your surroundings
|
||||
|
||||
Open **Settings > Environment** and choose:
|
||||
|
||||
| Background | What you see |
|
||||
| --- | --- |
|
||||
| Void | A dark background with an adjustable screen. This is the default. |
|
||||
| Cinema | A detailed cinema with a fixed screen at the front. |
|
||||
| Theatre | A simpler cinema with a fixed screen. |
|
||||
| Passthrough | Your real room around the video screen, if your headset supports it. |
|
||||
|
||||
If your device does not support Passthrough, you cannot select it. In Cinema and Theatre, black bars keep the picture from looking stretched.
|
||||
|
||||
## Watch a 3D video
|
||||
|
||||
jellyXR usually chooses the right picture layout automatically. If you see two pictures side by side or one above the other, open **Settings > Screen** and look for **Video format**.
|
||||
|
||||
| Option | When to use it |
|
||||
| --- | --- |
|
||||
| 2D | Ordinary movies and shows without 3D. |
|
||||
| Half-SBS | A 3D video that stores the left-eye and right-eye pictures side by side. |
|
||||
| Full-SBS | Another side-by-side 3D format, with a wider picture for each eye. |
|
||||
| Top-bottom | A 3D video that stores one eye's picture above the other. |
|
||||
|
||||
SBS means "side by side". If you do not know which version your video uses, try Half-SBS first. If the picture looks too wide or too narrow, try Full-SBS.
|
||||
|
||||
If the depth looks wrong, try **Swap left and right eyes**. These choices are not remembered separately for each title, so another video may need a different setting.
|
||||
|
||||
## Watch a 180° or 360° video
|
||||
|
||||
These videos let you look around inside the scene instead of watching a normal screen.
|
||||
|
||||
1. Start the video.
|
||||
2. Open **Settings > Screen**.
|
||||
3. Under **Video projection**, choose **180°** for a scene in front of you, or **360°** for a scene that surrounds you.
|
||||
4. If the video is also in 3D, choose the matching **Video format** as described above. Otherwise, leave it on **2D**.
|
||||
|
||||
Move your head to look around. For a 180° video, the space behind you stays dark. Use your headset's recenter command if the scene faces the wrong direction. Press A to bring up the controls.
|
||||
|
||||
The video replaces your chosen background while you watch. Screen width, distance, height, and curvature do not affect these videos. Select **Screen** under Video projection to return to a normal video screen.
|
||||
|
||||
jellyXR can recognize some 180° and 360° videos automatically, but you can change the setting if it chooses incorrectly. A new title uses automatic detection again. Some videos use a picture layout the app cannot show correctly. Changing this setting will not fix those videos.
|
||||
|
||||
## Change playback quality
|
||||
|
||||
Open **Settings > Playback**. The choice applies to the next video you start.
|
||||
|
||||
- **Automatic** lets Jellyfin choose how to play the video on your device. Start with this option if you are unsure.
|
||||
- **Original full resolution** keeps the original video quality. Your headset or PC must be able to play the file.
|
||||
- **Transcoded** asks Jellyfin to prepare a version that is easier for your device to play. Choose **720p**, **1080p**, or **4K** under **Transcoded resolution**. This can help with files that will not play smoothly at their original quality.
|
||||
|
||||
Start with **1080p**. Try **720p** if playback keeps stopping to load, or **4K** for a sharper picture if the video was recorded at that quality. Choosing 4K cannot add detail to a lower-quality video. Higher quality needs a faster connection and a computer running Jellyfin that can keep up. The app remembers your choice for the next video you start.
|
||||
|
||||
Picture quality also depends on the video itself and your headset. For help getting started on a PC, see the [Windows launch instructions](INSTALLATION.md#try-it-on-windows-with-steamvr).
|
||||
|
||||
## Change the order of your library
|
||||
|
||||
Open **Settings > Library sorting** and choose how you want titles arranged. **Ascending** means A to Z, lowest to highest, or oldest first. **Descending** reverses that order.
|
||||
|
||||
Home always shows the newest additions first. The sort order does not affect Random.
|
||||
|
||||
## If something does not look right
|
||||
|
||||
- If the controls disappear, press A to show them again.
|
||||
- If you cannot resize the screen, choose Void or Passthrough under Settings > Environment, and select Screen under Video projection.
|
||||
- If you see two copies of the picture, check Video format under Settings > Screen.
|
||||
- If the scene surrounds you when you wanted a normal screen, select Screen under Video projection.
|
||||
- If the video pauses with a loading message, it is waiting for more video data. Give it a moment. If this keeps happening, try Automatic or Transcoded under Settings > Playback, then start the video again.
|
||||
- If subtitles appear too early or too late, adjust Delay under Audio and subtitles.
|
||||
- If audio or subtitle choices are unavailable, select Use Automatic for this video on that page.
|
||||
|
||||
For setup problems, see [Install and start jellyXR](INSTALLATION.md). The [README](README.md#current-limitations) lists the remaining limitations.
|
||||
@@ -2,13 +2,13 @@
|
||||
extends EditorExportPlugin
|
||||
|
||||
func _get_name() -> String:
|
||||
return "JellyframeVideo"
|
||||
return "JellyXRVideo"
|
||||
|
||||
func _supports_platform(platform: EditorExportPlatform) -> bool:
|
||||
return platform is EditorExportPlatformAndroid
|
||||
|
||||
func _get_android_libraries(_platform: EditorExportPlatform, _debug: bool) -> PackedStringArray:
|
||||
return PackedStringArray(["res://addons/jellyframe_video/bin/jellyframe-video-release.aar"])
|
||||
return PackedStringArray(["res://addons/jellyxr_video/bin/jellyxr-video-release.aar"])
|
||||
|
||||
func _get_android_manifest_activity_element_contents(_platform: EditorExportPlatform, _debug: bool) -> String:
|
||||
return '<intent-filter><action android:name="android.intent.action.MAIN"/><category android:name="com.oculus.intent.category.VR"/></intent-filter>'
|
||||
@@ -1,5 +1,5 @@
|
||||
[plugin]
|
||||
name="Jellyframe Android video"
|
||||
name="jellyXR Android video"
|
||||
description="Android video decoding and secure session storage."
|
||||
author="Cuteminded"
|
||||
version="0.1.0"
|
||||
@@ -4,7 +4,7 @@ extends EditorPlugin
|
||||
var exporter: EditorExportPlugin
|
||||
|
||||
func _enter_tree() -> void:
|
||||
exporter = preload("res://addons/jellyframe_video/export.gd").new()
|
||||
exporter = preload("res://addons/jellyxr_video/export.gd").new()
|
||||
add_export_plugin(exporter)
|
||||
|
||||
func _exit_tree() -> void:
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48"><path d="M29 12 17 24l12 12" fill="none" stroke="#fff" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
|
After Width: | Height: | Size: 210 B |
@@ -0,0 +1,43 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://8g8da6x17oy"
|
||||
path="res://.godot/imported/chevron_left.svg-10348879aad5ca00a7c102d754890d86.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/icons/material/chevron_left.svg"
|
||||
dest_files=["res://.godot/imported/chevron_left.svg-10348879aad5ca00a7c102d754890d86.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
svg/scale=1.0
|
||||
editor/scale_with_editor_scale=false
|
||||
editor/convert_colors_with_editor_theme=false
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48"><path d="m19 12 12 12-12 12" fill="none" stroke="#fff" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
|
After Width: | Height: | Size: 210 B |
@@ -0,0 +1,43 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://b3euoqp2obfod"
|
||||
path="res://.godot/imported/chevron_right.svg-c99d4ba07ab61f6b832110c3459f3770.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/icons/material/chevron_right.svg"
|
||||
dest_files=["res://.godot/imported/chevron_right.svg-c99d4ba07ab61f6b832110c3459f3770.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
svg/scale=1.0
|
||||
editor/scale_with_editor_scale=false
|
||||
editor/convert_colors_with_editor_theme=false
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24"><path fill="#ffffff" d="M4 19h4V9H4v10zm6 0h4V5h-4v14zm6 0h4v-7h-4v7z"/></svg>
|
||||
|
After Width: | Height: | Size: 162 B |
@@ -0,0 +1,43 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://cte3e27ekhfhy"
|
||||
path="res://.godot/imported/statistics.svg-cb4795c754d515713a37969b809a039d.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/icons/material/statistics.svg"
|
||||
dest_files=["res://.godot/imported/statistics.svg-cb4795c754d515713a37969b809a039d.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
svg/scale=1.0
|
||||
editor/scale_with_editor_scale=false
|
||||
editor/convert_colors_with_editor_theme=false
|
||||
@@ -0,0 +1 @@
|
||||
<svg fill="#ffffff" xmlns="http://www.w3.org/2000/svg" height="48" viewBox="0 -960 960 960" width="48"><!-- Google Material Symbols Rounded, Apache-2.0. Modified display size and fill for Godot. --><path d="M160-160q-33 0-56.5-23.5T80-240v-480q0-33 23.5-56.5T160-800h640q33 0 56.5 23.5T880-720v480q0 33-23.5 56.5T800-160H160Zm0-80h640v-480H160v480Zm0 0v-480 480Zm120-80h240q17 0 28.5-11.5T560-360q0-17-11.5-28.5T520-400H280q-17 0-28.5 11.5T240-360q0 17 11.5 28.5T280-320Zm160-160h240q17 0 28.5-11.5T720-520q0-17-11.5-28.5T680-560H440q-17 0-28.5 11.5T400-520q0 17 11.5 28.5T440-480Zm-160 0q17 0 28.5-11.5T320-520q0-17-11.5-28.5T280-560q-17 0-28.5 11.5T240-520q0 17 11.5 28.5T280-480Zm400 160q17 0 28.5-11.5T720-360q0-17-11.5-28.5T680-400q-17 0-28.5 11.5T640-360q0 17 11.5 28.5T680-320Z"/></svg>
|
||||
|
After Width: | Height: | Size: 794 B |
@@ -0,0 +1,43 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://by17fi2gifls"
|
||||
path="res://.godot/imported/subtitles.svg-b6bec42b0b1d98546183a5273689fcc2.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/icons/material/subtitles.svg"
|
||||
dest_files=["res://.godot/imported/subtitles.svg-b6bec42b0b1d98546183a5273689fcc2.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
svg/scale=1.0
|
||||
editor/scale_with_editor_scale=false
|
||||
editor/convert_colors_with_editor_theme=false
|
||||
|
Before Width: | Height: | Size: 360 KiB |
|
After Width: | Height: | Size: 351 KiB |
@@ -2,16 +2,16 @@
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://dmce1ivmbk2fp"
|
||||
path="res://.godot/imported/banner.jpg-6d02bc55ca4bbeee115ce9d14947211e.ctex"
|
||||
uid="uid://dxevvv5ord8pe"
|
||||
path="res://.godot/imported/banner.png-bd23af29f76d71fc9bfdb42c682b72e4.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/logo/banner.jpg"
|
||||
dest_files=["res://.godot/imported/banner.jpg-6d02bc55ca4bbeee115ce9d14947211e.ctex"]
|
||||
source_file="res://assets/logo/banner.png"
|
||||
dest_files=["res://.godot/imported/banner.png-bd23af29f76d71fc9bfdb42c682b72e4.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
|
Before Width: | Height: | Size: 775 KiB |
@@ -3,15 +3,15 @@
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://ck4x8pltfn58j"
|
||||
path="res://.godot/imported/jellyframe-logo-transparent.png-0ef10f99e02b93d9ea3384102e1c4911.ctex"
|
||||
path="res://.godot/imported/jellyxr-logo-transparent.png-2dfee7dc41b0d39b9daa681e6e7e02f7.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/logo/jellyframe-logo-transparent.png"
|
||||
dest_files=["res://.godot/imported/jellyframe-logo-transparent.png-0ef10f99e02b93d9ea3384102e1c4911.ctex"]
|
||||
source_file="res://assets/logo/jellyxr-logo-transparent.png"
|
||||
dest_files=["res://.godot/imported/jellyxr-logo-transparent.png-2dfee7dc41b0d39b9daa681e6e7e02f7.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
@@ -3,15 +3,15 @@
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://hm54bsfvy7o4"
|
||||
path="res://.godot/imported/jellyframe-logo.png-c3465f8a3b44d0216b2085b1f0539f96.ctex"
|
||||
path="res://.godot/imported/jellyxr-logo.png-80e79f5d43d1747dd0f89e3817de72a9.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/logo/jellyframe-logo.png"
|
||||
dest_files=["res://.godot/imported/jellyframe-logo.png-c3465f8a3b44d0216b2085b1f0539f96.ctex"]
|
||||
source_file="res://assets/logo/jellyxr-logo.png"
|
||||
dest_files=["res://.godot/imported/jellyxr-logo.png-80e79f5d43d1747dd0f89e3817de72a9.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
|
After Width: | Height: | Size: 937 KiB |
@@ -3,15 +3,15 @@
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://b6yvltew548ey"
|
||||
path="res://.godot/imported/jellyframe-wordmark.png-8b4f6ce94c0d01410ac5982a545ac1fd.ctex"
|
||||
path="res://.godot/imported/jellyxr-wordmark.png-d0e48d005fe6a57844383d26386949aa.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/logo/jellyframe-wordmark.png"
|
||||
dest_files=["res://.godot/imported/jellyframe-wordmark.png-8b4f6ce94c0d01410ac5982a545ac1fd.ctex"]
|
||||
source_file="res://assets/logo/jellyxr-wordmark.png"
|
||||
dest_files=["res://.godot/imported/jellyxr-wordmark.png-d0e48d005fe6a57844383d26386949aa.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
|
Before Width: | Height: | Size: 355 KiB |
@@ -1,40 +0,0 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://crg1x2ohwbgbs"
|
||||
path="res://.godot/imported/repository-open-graph-template.jpg-073b42b68ea80d7e5f867a4b731e7acc.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/logo/repository-open-graph-template.jpg"
|
||||
dest_files=["res://.godot/imported/repository-open-graph-template.jpg-073b42b68ea80d7e5f867a4b731e7acc.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
@@ -6,9 +6,9 @@ advanced_options=false
|
||||
dedicated_server=false
|
||||
custom_features="quest"
|
||||
export_filter="all_resources"
|
||||
include_filter="addons/godotopenxrvendors/*LICENSE*,assets/icons/material/LICENSE.txt,assets/icons/material/ATTRIBUTION.md,assets/theatre/ATTRIBUTION.md"
|
||||
exclude_filter="assets/cinema.glb,assets/theatre/materials.tres,native/*,android/*,docs/*,tests/*,tools/*,dist/*,build/*,addons/jellyframe_mpv/*,shaders/pc_video.gdshader"
|
||||
export_path="dist/jellyframe-android.apk"
|
||||
include_filter="addons/godotopenxrvendors/*LICENSE*"
|
||||
exclude_filter="assets/cinema.glb,assets/theatre/materials.tres,native/*,android/*,docs/*,tests/*,tools/*,dist/*,build/*,addons/jellyxr_mpv/*"
|
||||
export_path="dist/jellyxr-android.apk"
|
||||
script_export_mode=2
|
||||
|
||||
[preset.0.options]
|
||||
@@ -24,10 +24,10 @@ architectures/armeabi-v7a=false
|
||||
architectures/arm64-v8a=true
|
||||
architectures/x86=false
|
||||
architectures/x86_64=false
|
||||
version/code=1
|
||||
version/name="0.1.0"
|
||||
package/unique_name="nu.lizzy.jellyframe"
|
||||
package/name="Jellyframe"
|
||||
version/code=4
|
||||
version/name="0.1.3"
|
||||
package/unique_name="nu.lizzy.jellyxr"
|
||||
package/name="jellyXR"
|
||||
package/signed=true
|
||||
package/retain_data_on_uninstall=false
|
||||
package/exclude_from_recents=false
|
||||
@@ -46,16 +46,16 @@ screen/support_xlarge=true
|
||||
user_data_backup/allow=false
|
||||
|
||||
[preset.1]
|
||||
name="Windows SteamVR"
|
||||
name="Windows"
|
||||
platform="Windows Desktop"
|
||||
runnable=true
|
||||
advanced_options=false
|
||||
dedicated_server=false
|
||||
custom_features=""
|
||||
export_filter="all_resources"
|
||||
include_filter="assets/icons/material/LICENSE.txt,assets/icons/material/ATTRIBUTION.md,assets/theatre/ATTRIBUTION.md"
|
||||
exclude_filter="assets/cinema.glb,assets/theatre/materials.tres,native/*,android/*,docs/*,tests/*,tools/*,dist/*,build/*,addons/jellyframe_video/bin/*"
|
||||
export_path="dist/windows/jellyframe.exe"
|
||||
include_filter=""
|
||||
exclude_filter="assets/cinema.glb,assets/theatre/materials.tres,native/*,android/*,docs/*,tests/*,tools/*,dist/*,build/*,addons/jellyxr_video/bin/*"
|
||||
export_path="dist/windows/jellyxr.exe"
|
||||
script_export_mode=2
|
||||
|
||||
[preset.1.options]
|
||||
@@ -69,16 +69,16 @@ codesign/enable=false
|
||||
application/modify_resources=false
|
||||
|
||||
[preset.2]
|
||||
name="Linux x64 OpenXR"
|
||||
name="Linux x64"
|
||||
platform="Linux"
|
||||
runnable=true
|
||||
advanced_options=false
|
||||
dedicated_server=false
|
||||
custom_features="linux_opengl"
|
||||
custom_features=""
|
||||
export_filter="all_resources"
|
||||
include_filter="assets/icons/material/LICENSE.txt,assets/icons/material/ATTRIBUTION.md,assets/theatre/ATTRIBUTION.md"
|
||||
exclude_filter="assets/cinema.glb,assets/theatre/materials.tres,native/*,android/*,docs/*,tests/*,tools/*,dist/*,build/*,addons/jellyframe_video/bin/*"
|
||||
export_path="dist/linux-x86_64/jellyframe-opengl.x86_64"
|
||||
include_filter=""
|
||||
exclude_filter="assets/cinema.glb,assets/theatre/materials.tres,native/*,android/*,docs/*,tests/*,tools/*,dist/*,build/*,addons/jellyxr_video/bin/*"
|
||||
export_path="dist/linux-x86_64/jellyxr.x86_64"
|
||||
script_export_mode=2
|
||||
|
||||
[preset.2.options]
|
||||
@@ -90,16 +90,16 @@ texture_format/s3tc_bptc=true
|
||||
texture_format/etc2_astc=false
|
||||
|
||||
[preset.3]
|
||||
name="Linux ARM64 OpenXR"
|
||||
name="Linux ARM64"
|
||||
platform="Linux"
|
||||
runnable=true
|
||||
advanced_options=false
|
||||
dedicated_server=false
|
||||
custom_features="linux_opengl"
|
||||
custom_features=""
|
||||
export_filter="all_resources"
|
||||
include_filter="assets/icons/material/LICENSE.txt,assets/icons/material/ATTRIBUTION.md,assets/theatre/ATTRIBUTION.md"
|
||||
exclude_filter="assets/cinema.glb,assets/theatre/materials.tres,native/*,android/*,docs/*,tests/*,tools/*,dist/*,build/*,addons/jellyframe_video/bin/*"
|
||||
export_path="dist/linux-arm64/jellyframe-opengl.arm64"
|
||||
include_filter=""
|
||||
exclude_filter="assets/cinema.glb,assets/theatre/materials.tres,native/*,android/*,docs/*,tests/*,tools/*,dist/*,build/*,addons/jellyxr_video/bin/*"
|
||||
export_path="dist/linux-arm64/jellyxr.arm64"
|
||||
script_export_mode=2
|
||||
|
||||
[preset.3.options]
|
||||
@@ -109,45 +109,3 @@ binary_format/architecture="arm64"
|
||||
binary_format/embed_pck=true
|
||||
texture_format/s3tc_bptc=true
|
||||
texture_format/etc2_astc=true
|
||||
|
||||
[preset.4]
|
||||
name="Linux x64 Vulkan"
|
||||
platform="Linux"
|
||||
runnable=false
|
||||
advanced_options=false
|
||||
dedicated_server=false
|
||||
custom_features="linux_vulkan"
|
||||
export_filter="all_resources"
|
||||
include_filter="assets/icons/material/LICENSE.txt,assets/icons/material/ATTRIBUTION.md,assets/theatre/ATTRIBUTION.md"
|
||||
exclude_filter="assets/cinema.glb,assets/theatre/materials.tres,native/*,android/*,docs/*,tests/*,tools/*,dist/*,build/*,addons/jellyframe_video/bin/*"
|
||||
export_path="dist/linux-x86_64/jellyframe-vulkan.x86_64"
|
||||
script_export_mode=2
|
||||
|
||||
[preset.4.options]
|
||||
custom_template/debug=""
|
||||
custom_template/release=""
|
||||
binary_format/architecture="x86_64"
|
||||
binary_format/embed_pck=true
|
||||
texture_format/s3tc_bptc=true
|
||||
texture_format/etc2_astc=false
|
||||
|
||||
[preset.5]
|
||||
name="Linux ARM64 Vulkan"
|
||||
platform="Linux"
|
||||
runnable=false
|
||||
advanced_options=false
|
||||
dedicated_server=false
|
||||
custom_features="linux_vulkan"
|
||||
export_filter="all_resources"
|
||||
include_filter="assets/icons/material/LICENSE.txt,assets/icons/material/ATTRIBUTION.md,assets/theatre/ATTRIBUTION.md"
|
||||
exclude_filter="assets/cinema.glb,assets/theatre/materials.tres,native/*,android/*,docs/*,tests/*,tools/*,dist/*,build/*,addons/jellyframe_video/bin/*"
|
||||
export_path="dist/linux-arm64/jellyframe-vulkan.arm64"
|
||||
script_export_mode=2
|
||||
|
||||
[preset.5.options]
|
||||
custom_template/debug=""
|
||||
custom_template/release=""
|
||||
binary_format/architecture="arm64"
|
||||
binary_format/embed_pck=true
|
||||
texture_format/s3tc_bptc=true
|
||||
texture_format/etc2_astc=true
|
||||
@@ -1,7 +1,7 @@
|
||||
plugins { id 'com.android.library' }
|
||||
|
||||
android {
|
||||
namespace 'nu.lizzy.jellyframe.video'
|
||||
namespace 'nu.lizzy.jellyxr.video'
|
||||
compileSdk 35
|
||||
defaultConfig { minSdk 29 }
|
||||
compileOptions {
|
||||
@@ -17,6 +17,6 @@ dependencies {
|
||||
tasks.register('stagePlugin', Copy) {
|
||||
dependsOn 'assembleRelease'
|
||||
from layout.buildDirectory.file('outputs/aar/android-release.aar')
|
||||
into '../../addons/jellyframe_video/bin'
|
||||
rename { 'jellyframe-video-release.aar' }
|
||||
into '../../addons/jellyxr_video/bin'
|
||||
rename { 'jellyxr-video-release.aar' }
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-feature android:name="android.hardware.vr.headtracking" android:version="1" android:required="true" />
|
||||
<application android:usesCleartextTraffic="true" android:allowBackup="false">
|
||||
<meta-data android:name="org.godotengine.plugin.v2.JellyframeVideo"
|
||||
android:value="nu.lizzy.jellyframe.video.JellyframeVideo" />
|
||||
<meta-data android:name="org.godotengine.plugin.v2.JellyXRVideo"
|
||||
android:value="nu.lizzy.jellyxr.video.JellyXRVideo" />
|
||||
<meta-data android:name="com.oculus.supportedDevices" android:value="quest3|questpro|quest2" />
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -1,4 +1,4 @@
|
||||
package nu.lizzy.jellyframe.video;
|
||||
package nu.lizzy.jellyxr.video;
|
||||
|
||||
import android.graphics.SurfaceTexture;
|
||||
import android.media.MediaPlayer;
|
||||
@@ -17,9 +17,10 @@ import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import javax.microedition.khronos.opengles.GL10;
|
||||
|
||||
public final class JellyframeVideo extends GodotPlugin {
|
||||
public final class JellyXRVideo extends GodotPlugin {
|
||||
private final Handler main = new Handler(Looper.getMainLooper());
|
||||
private final AtomicBoolean frameAvailable = new AtomicBoolean();
|
||||
private final float[] transform = new float[16];
|
||||
@@ -31,17 +32,20 @@ public final class JellyframeVideo extends GodotPlugin {
|
||||
private MediaPlayer player;
|
||||
private volatile int position;
|
||||
private volatile int duration;
|
||||
private volatile int bufferedPercent = -1;
|
||||
private volatile boolean prepared;
|
||||
private volatile boolean buffering;
|
||||
private volatile boolean seeking;
|
||||
private int generation;
|
||||
private final AtomicInteger generation = new AtomicInteger();
|
||||
private volatile int readyGeneration = -1;
|
||||
private int textureGeneration = -1;
|
||||
|
||||
public JellyframeVideo(Godot godot) {
|
||||
public JellyXRVideo(Godot godot) {
|
||||
super(godot);
|
||||
Matrix.setIdentityM(transform, 0);
|
||||
}
|
||||
|
||||
@Override public String getPluginName() { return "JellyframeVideo"; }
|
||||
@Override public String getPluginName() { return "JellyXRVideo"; }
|
||||
|
||||
@Override public Set<SignalInfo> getPluginSignals() {
|
||||
Set<SignalInfo> result = new HashSet<>();
|
||||
@@ -53,29 +57,34 @@ public final class JellyframeVideo extends GodotPlugin {
|
||||
}
|
||||
|
||||
@UsedByGodot public void open_video(int textureId, String url, String authorization, int startMs) {
|
||||
Log.i("JellyframeVideo", "Opening video texture " + textureId);
|
||||
int opening = generation.incrementAndGet();
|
||||
main.post(() -> {
|
||||
if (opening != generation.get()) return;
|
||||
releasePlayer();
|
||||
int opening = generation;
|
||||
runOnRenderThread(() -> {
|
||||
if (opening != generation.get()) return;
|
||||
releaseSurface();
|
||||
texture = new SurfaceTexture(textureId);
|
||||
Log.i("JellyframeVideo", "SurfaceTexture created");
|
||||
textureGeneration = opening;
|
||||
texture.setOnFrameAvailableListener(ignored -> {
|
||||
if (ignored != texture) return;
|
||||
if (ignored != texture || opening != generation.get()) return;
|
||||
lastFrame = SystemClock.elapsedRealtime();
|
||||
frameAvailable.set(true);
|
||||
});
|
||||
surface = new Surface(texture);
|
||||
Surface output = surface;
|
||||
main.post(() -> {
|
||||
if (opening == generation) prepare(output, url, authorization, startMs);
|
||||
if (opening == generation.get()) prepare(output, url, authorization, startMs, opening);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private void prepare(Surface output, String url, String authorization, int startMs) {
|
||||
private boolean isCurrent(MediaPlayer current, int opening) {
|
||||
return current == player && opening == generation.get();
|
||||
}
|
||||
|
||||
private void prepare(Surface output, String url, String authorization, int startMs, int opening) {
|
||||
try {
|
||||
player = new MediaPlayer();
|
||||
player.setSurface(output);
|
||||
@@ -84,8 +93,7 @@ public final class JellyframeVideo extends GodotPlugin {
|
||||
headers.put("android-allow-cross-domain-redirect", "0");
|
||||
player.setDataSource(getContext(), Uri.parse(url), headers);
|
||||
player.setOnPreparedListener(current -> {
|
||||
if (current != player) return;
|
||||
Log.i("JellyframeVideo", "Decoder prepared: " + current.getVideoWidth() + "x" + current.getVideoHeight());
|
||||
if (!isCurrent(current, opening)) return;
|
||||
prepared = true;
|
||||
watchdog.reset(SystemClock.elapsedRealtime());
|
||||
duration = Math.max(0, current.getDuration());
|
||||
@@ -94,32 +102,35 @@ public final class JellyframeVideo extends GodotPlugin {
|
||||
main.post(pollPlayback);
|
||||
emitSignal("prepared");
|
||||
});
|
||||
player.setOnVideoSizeChangedListener((current, width, height) -> emitSignal("video_size", width, height));
|
||||
player.setOnVideoSizeChangedListener((current, width, height) -> {
|
||||
if (isCurrent(current, opening)) emitSignal("video_size", width, height);
|
||||
});
|
||||
player.setOnBufferingUpdateListener((current, percent) -> {
|
||||
if (isCurrent(current, opening)) bufferedPercent = Math.max(0, Math.min(100, percent));
|
||||
});
|
||||
player.setOnInfoListener((current, what, extra) -> {
|
||||
if (current != player) return true;
|
||||
if (!isCurrent(current, opening)) return true;
|
||||
if (what == MediaPlayer.MEDIA_INFO_BUFFERING_START) {
|
||||
buffering = true;
|
||||
Log.i("JellyframeVideo", "Buffering started at " + position + " ms");
|
||||
}
|
||||
if (what == MediaPlayer.MEDIA_INFO_BUFFERING_END) {
|
||||
buffering = false;
|
||||
Log.i("JellyframeVideo", "Buffering ended at " + position + " ms");
|
||||
}
|
||||
if (what == MediaPlayer.MEDIA_INFO_VIDEO_TRACK_LAGGING) Log.w("JellyframeVideo", "Video decoder is falling behind");
|
||||
if (what == MediaPlayer.MEDIA_INFO_VIDEO_TRACK_LAGGING) Log.w("JellyXRVideo", "Video decoder is falling behind");
|
||||
return false;
|
||||
});
|
||||
player.setOnSeekCompleteListener(current -> {
|
||||
if (current != player) return;
|
||||
if (!isCurrent(current, opening)) return;
|
||||
seeking = false;
|
||||
watchdog.setPaused(!current.isPlaying(), SystemClock.elapsedRealtime());
|
||||
});
|
||||
player.setOnCompletionListener(current -> {
|
||||
if (current != player) return;
|
||||
if (!isCurrent(current, opening)) return;
|
||||
main.removeCallbacks(pollPlayback);
|
||||
emitSignal("completed");
|
||||
});
|
||||
player.setOnErrorListener((current, what, extra) -> {
|
||||
if (current != player) return true;
|
||||
if (!isCurrent(current, opening)) return true;
|
||||
prepared = false;
|
||||
main.removeCallbacks(pollPlayback);
|
||||
emitSignal("failed", "Android could not decode this stream (" + what + ", " + extra + ").");
|
||||
@@ -136,6 +147,7 @@ public final class JellyframeVideo extends GodotPlugin {
|
||||
if (texture != null && frameAvailable.compareAndSet(true, false)) {
|
||||
texture.updateTexImage();
|
||||
synchronized (transform) { texture.getTransformMatrix(transform); }
|
||||
if (textureGeneration == generation.get()) readyGeneration = textureGeneration;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,7 +157,7 @@ public final class JellyframeVideo extends GodotPlugin {
|
||||
position = player.getCurrentPosition();
|
||||
if (watchdog.stalled(SystemClock.elapsedRealtime(), lastFrame, buffering || seeking)) {
|
||||
prepared = false;
|
||||
Log.w("JellyframeVideo", "No video frames at " + position + " ms, buffering=" + buffering + ", seeking=" + seeking);
|
||||
Log.w("JellyXRVideo", "No video frames at " + position + " ms, buffering=" + buffering + ", seeking=" + seeking);
|
||||
emitSignal("failed", "Video playback stalled.");
|
||||
return;
|
||||
}
|
||||
@@ -163,17 +175,13 @@ public final class JellyframeVideo extends GodotPlugin {
|
||||
|
||||
@UsedByGodot public int position_ms() { return position; }
|
||||
@UsedByGodot public int duration_ms() { return duration; }
|
||||
@UsedByGodot public int buffered_position_ms() {
|
||||
return prepared && !seeking && bufferedPercent >= 0 && duration > 0
|
||||
? (int) ((long) duration * bufferedPercent / 100) : -1;
|
||||
}
|
||||
@UsedByGodot public boolean has_video_frame() { return readyGeneration >= 0 && readyGeneration == generation.get(); }
|
||||
@UsedByGodot public boolean is_buffering() { return prepared && (buffering || seeking); }
|
||||
|
||||
@UsedByGodot public String calibration_url() {
|
||||
int resource = getContext().getResources().getIdentifier("stereo_test", "raw", getContext().getPackageName());
|
||||
return "android.resource://" + getContext().getPackageName() + "/" + resource;
|
||||
}
|
||||
|
||||
@UsedByGodot public boolean calibration_requested() {
|
||||
return getActivity() != null && getActivity().getIntent().getBooleanExtra("jellyframe_calibration", false);
|
||||
}
|
||||
|
||||
@UsedByGodot public void set_paused(boolean paused) {
|
||||
main.post(() -> {
|
||||
if (player == null || !prepared) return;
|
||||
@@ -186,26 +194,31 @@ public final class JellyframeVideo extends GodotPlugin {
|
||||
main.post(() -> {
|
||||
if (player != null && prepared) {
|
||||
seeking = true;
|
||||
bufferedPercent = -1;
|
||||
watchdog.setPaused(!player.isPlaying(), SystemClock.elapsedRealtime());
|
||||
player.seekTo(Math.max(0, Math.min(milliseconds, player.getDuration())), MediaPlayer.SEEK_CLOSEST);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@UsedByGodot public void stop_video() { main.post(this::releasePlayer); }
|
||||
@UsedByGodot public void stop_video() {
|
||||
generation.incrementAndGet();
|
||||
main.post(this::releasePlayer);
|
||||
}
|
||||
|
||||
private void releasePlayer() {
|
||||
main.removeCallbacks(pollPlayback);
|
||||
generation++;
|
||||
prepared = false;
|
||||
buffering = false;
|
||||
seeking = false;
|
||||
position = 0;
|
||||
duration = 0;
|
||||
bufferedPercent = -1;
|
||||
if (player != null) { player.release(); player = null; }
|
||||
}
|
||||
|
||||
private void releaseSurface() {
|
||||
readyGeneration = -1;
|
||||
lastFrame = 0;
|
||||
frameAvailable.set(false);
|
||||
if (surface != null) { surface.release(); surface = null; }
|
||||
@@ -215,6 +228,7 @@ public final class JellyframeVideo extends GodotPlugin {
|
||||
@Override public void onMainPause() { set_paused(true); }
|
||||
|
||||
@Override public void onMainDestroy() {
|
||||
generation.incrementAndGet();
|
||||
releasePlayer();
|
||||
runOnRenderThread(this::releaseSurface);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package nu.lizzy.jellyframe.video;
|
||||
package nu.lizzy.jellyxr.video;
|
||||
|
||||
final class PlaybackWatchdog {
|
||||
private static final long FRAME_TIMEOUT_MS = 20000;
|
||||
@@ -1,4 +1,4 @@
|
||||
package nu.lizzy.jellyframe.video;
|
||||
package nu.lizzy.jellyxr.video;
|
||||
|
||||
import android.content.Context;
|
||||
import android.security.keystore.KeyGenParameterSpec;
|
||||
@@ -12,7 +12,7 @@ import javax.crypto.spec.GCMParameterSpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
final class SessionStore {
|
||||
private static final String ALIAS = "jellyframe-session";
|
||||
private static final String ALIAS = "jellyxr-session";
|
||||
private final Context context;
|
||||
|
||||
SessionStore(Context context) { this.context = context; }
|
||||
@@ -1,10 +1,11 @@
|
||||
cmake_minimum_required(VERSION 3.20)
|
||||
project(jellyframe_mpv LANGUAGES CXX)
|
||||
project(jellyxr_mpv LANGUAGES CXX)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(GODOT_CPP_PATH "" CACHE PATH "Path to godot-cpp godot-4.5-stable")
|
||||
set(MPV_ROOT "" CACHE PATH "Path to the libmpv development archive")
|
||||
set(FFMPEG_ROOT "" CACHE PATH "Path to the FFmpeg shared development archive on Windows")
|
||||
if(NOT GODOT_CPP_PATH)
|
||||
message(FATAL_ERROR "Set GODOT_CPP_PATH. See tools/build-pc-video.ps1 or tools/build-pc-video.sh.")
|
||||
endif()
|
||||
@@ -20,37 +21,50 @@ if(WIN32)
|
||||
endif()
|
||||
find_path(MPV_INCLUDE_DIR mpv/client.h PATHS "${MPV_ROOT}/include" REQUIRED NO_DEFAULT_PATH)
|
||||
find_library(MPV_LIBRARY NAMES mpv mpv.dll libmpv.dll PATHS "${MPV_ROOT}" "${MPV_ROOT}/lib" REQUIRED NO_DEFAULT_PATH)
|
||||
add_library(jellyframe_mpv_dependency UNKNOWN IMPORTED)
|
||||
set_target_properties(jellyframe_mpv_dependency PROPERTIES
|
||||
add_library(jellyxr_mpv_dependency UNKNOWN IMPORTED)
|
||||
set_target_properties(jellyxr_mpv_dependency PROPERTIES
|
||||
IMPORTED_LOCATION "${MPV_LIBRARY}"
|
||||
INTERFACE_INCLUDE_DIRECTORIES "${MPV_INCLUDE_DIR}")
|
||||
set(MPV_TARGET jellyframe_mpv_dependency)
|
||||
set(MPV_TARGET jellyxr_mpv_dependency)
|
||||
find_path(FFMPEG_INCLUDE_DIR libavcodec/avcodec.h PATHS "${FFMPEG_ROOT}/include" REQUIRED NO_DEFAULT_PATH)
|
||||
foreach(component avcodec avformat avutil swscale)
|
||||
find_library(FFMPEG_${component}_LIBRARY NAMES ${component} PATHS "${FFMPEG_ROOT}/lib" REQUIRED NO_DEFAULT_PATH)
|
||||
list(APPEND FFMPEG_LIBRARIES "${FFMPEG_${component}_LIBRARY}")
|
||||
endforeach()
|
||||
elseif(CMAKE_SYSTEM_NAME STREQUAL "Linux")
|
||||
find_package(PkgConfig REQUIRED)
|
||||
pkg_check_modules(MPV REQUIRED IMPORTED_TARGET mpv>=2.0)
|
||||
set(MPV_TARGET PkgConfig::MPV)
|
||||
pkg_check_modules(FFMPEG REQUIRED IMPORTED_TARGET libavcodec>=60 libavformat>=60 libavutil>=58 libswscale>=7)
|
||||
set(FFMPEG_LIBRARIES PkgConfig::FFMPEG)
|
||||
else()
|
||||
message(FATAL_ERROR "The PC video plugin supports Windows and Linux.")
|
||||
endif()
|
||||
find_package(Threads REQUIRED)
|
||||
|
||||
add_library(jellyframe_mpv SHARED mpv_video.cpp render_worker.cpp render_gl.cpp render_gl_worker.cpp gl_functions.cpp register_types.cpp)
|
||||
target_link_libraries(jellyframe_mpv PRIVATE godot-cpp ${MPV_TARGET} Threads::Threads)
|
||||
add_library(jellyxr_mpv SHARED
|
||||
mpv_video.cpp render_worker.cpp render_gl.cpp render_gl_worker.cpp
|
||||
gl_functions.cpp register_types.cpp render_vulkan.cpp
|
||||
video_decoder.cpp video_decoder_frames.cpp video_decoder_packets.cpp video_decoder_planes.cpp video_plane.cpp video_sync.cpp)
|
||||
target_link_libraries(jellyxr_mpv PRIVATE godot-cpp ${MPV_TARGET} ${FFMPEG_LIBRARIES} Threads::Threads)
|
||||
if(WIN32)
|
||||
target_sources(jellyframe_mpv PRIVATE shared_gl_context_windows.cpp)
|
||||
target_link_libraries(jellyframe_mpv PRIVATE opengl32)
|
||||
target_include_directories(jellyxr_mpv PRIVATE "${FFMPEG_INCLUDE_DIR}")
|
||||
endif()
|
||||
if(WIN32)
|
||||
target_sources(jellyxr_mpv PRIVATE shared_gl_context_windows.cpp)
|
||||
target_link_libraries(jellyxr_mpv PRIVATE opengl32)
|
||||
else()
|
||||
target_sources(jellyframe_mpv PRIVATE shared_gl_context_linux.cpp)
|
||||
target_link_libraries(jellyframe_mpv PRIVATE ${CMAKE_DL_LIBS})
|
||||
target_sources(jellyxr_mpv PRIVATE shared_gl_context_linux.cpp)
|
||||
target_link_libraries(jellyxr_mpv PRIVATE ${CMAKE_DL_LIBS})
|
||||
endif()
|
||||
if(MINGW)
|
||||
target_link_options(jellyframe_mpv PRIVATE -static -static-libgcc -static-libstdc++)
|
||||
target_link_options(jellyxr_mpv PRIVATE -static -static-libgcc -static-libstdc++)
|
||||
endif()
|
||||
set_target_properties(jellyframe_mpv PROPERTIES PREFIX "" OUTPUT_NAME "jellyframe_mpv")
|
||||
set_target_properties(jellyxr_mpv PROPERTIES PREFIX "" OUTPUT_NAME "jellyxr_mpv")
|
||||
if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
|
||||
get_target_property(JELLYFRAME_ARCH godot-cpp GODOTCPP_ARCH)
|
||||
if(NOT JELLYFRAME_ARCH MATCHES "^(x86_64|arm64)$")
|
||||
message(FATAL_ERROR "Unsupported Linux architecture: ${JELLYFRAME_ARCH}")
|
||||
get_target_property(JELLYXR_ARCH godot-cpp GODOTCPP_ARCH)
|
||||
if(NOT JELLYXR_ARCH MATCHES "^(x86_64|arm64)$")
|
||||
message(FATAL_ERROR "Unsupported Linux architecture: ${JELLYXR_ARCH}")
|
||||
endif()
|
||||
set_target_properties(jellyframe_mpv PROPERTIES OUTPUT_NAME "jellyframe_mpv.linux.${JELLYFRAME_ARCH}")
|
||||
set_target_properties(jellyxr_mpv PROPERTIES OUTPUT_NAME "jellyxr_mpv.linux.${JELLYXR_ARCH}")
|
||||
endif()
|
||||
@@ -1,14 +0,0 @@
|
||||
# PC playback dependencies
|
||||
|
||||
The Windows build bundles libmpv from shinchiro's `20261010` build, mpv revision `b2c255c13e`, and links godot-cpp `godot-4.5-stable`.
|
||||
|
||||
- [libmpv binary release](https://github.com/shinchiro/mpv-winbuild-cmake/releases/tag/20261010)
|
||||
- [mpv source and copyright information](https://github.com/mpv-player/mpv/tree/b2c255c13e)
|
||||
- [Windows decoder build scripts and dependency sources](https://github.com/shinchiro/mpv-winbuild-cmake)
|
||||
- [godot-cpp source](https://github.com/godotengine/godot-cpp/tree/godot-4.5-stable)
|
||||
|
||||
godot-cpp uses the MIT licence, while libmpv and its bundled codec dependencies have their own licences, including GPL components. Redistributors must provide the required copyright notices and corresponding source for the exact decoder build they ship; the links above identify upstream projects and do not replace those obligations.
|
||||
|
||||
The Linux builds link against the distribution's libmpv, which you install with its codec dependencies through the distribution's package manager, and do not bundle the Windows decoder. Each Linux artifact records its compile-time libmpv version in `build-info.txt` and includes the godot-cpp licence.
|
||||
|
||||
The Windows build script pins download versions and SHA-256 checksums, downloads build tools into the ignored build directory, and does not install them system-wide. The Linux build script verifies the same godot-cpp archive checksum and uses the host's compiler, CMake, Python, and libmpv development package.
|
||||
@@ -1,3 +1,19 @@
|
||||
{
|
||||
"enabled_classes": ["Node", "Image", "ImageTexture", "OS", "DisplayServer", "RenderingServer"]
|
||||
"enabled_classes": [
|
||||
"Node",
|
||||
"Image",
|
||||
"ImageTexture",
|
||||
"OS",
|
||||
"DisplayServer",
|
||||
"RenderingServer",
|
||||
"RenderingDevice",
|
||||
"Texture2DRD",
|
||||
"RDShaderSource",
|
||||
"RDShaderSPIRV",
|
||||
"RDTextureFormat",
|
||||
"RDTextureView",
|
||||
"RDSamplerState",
|
||||
"RDUniform",
|
||||
"RDPipelineSpecializationConstant"
|
||||
]
|
||||
}
|
||||
@@ -12,25 +12,25 @@ constexpr unsigned sync_complete = 0x9117, already_signaled = 0x911A, condition_
|
||||
}
|
||||
|
||||
struct GlFunctions {
|
||||
void (JELLYFRAME_GL_CALL *gen_textures)(int, unsigned *) = nullptr;
|
||||
void (JELLYFRAME_GL_CALL *delete_textures)(int, const unsigned *) = nullptr;
|
||||
void (JELLYFRAME_GL_CALL *bind_texture)(unsigned, unsigned) = nullptr;
|
||||
void (JELLYFRAME_GL_CALL *texture_parameter)(unsigned, unsigned, int) = nullptr;
|
||||
void (JELLYFRAME_GL_CALL *texture_image)(unsigned, int, int, int, int, int, unsigned, unsigned, const void *) = nullptr;
|
||||
void (JELLYFRAME_GL_CALL *gen_framebuffers)(int, unsigned *) = nullptr;
|
||||
void (JELLYFRAME_GL_CALL *delete_framebuffers)(int, const unsigned *) = nullptr;
|
||||
void (JELLYFRAME_GL_CALL *bind_framebuffer)(unsigned, unsigned) = nullptr;
|
||||
void (JELLYFRAME_GL_CALL *attach_texture)(unsigned, unsigned, unsigned, unsigned, int) = nullptr;
|
||||
unsigned (JELLYFRAME_GL_CALL *check_framebuffer)(unsigned) = nullptr;
|
||||
void (JELLYFRAME_GL_CALL *blit)(int, int, int, int, int, int, int, int, unsigned, unsigned) = nullptr;
|
||||
void *(JELLYFRAME_GL_CALL *fence)(unsigned, unsigned) = nullptr;
|
||||
unsigned (JELLYFRAME_GL_CALL *poll_fence)(void *, unsigned, unsigned long long) = nullptr;
|
||||
void (JELLYFRAME_GL_CALL *delete_fence)(void *) = nullptr;
|
||||
void (JELLYFRAME_GL_CALL *flush)() = nullptr;
|
||||
void (JELLYFRAME_GL_CALL *get_integer)(unsigned, int *) = nullptr;
|
||||
unsigned char (JELLYFRAME_GL_CALL *is_enabled)(unsigned) = nullptr;
|
||||
void (JELLYFRAME_GL_CALL *enable)(unsigned) = nullptr;
|
||||
void (JELLYFRAME_GL_CALL *disable)(unsigned) = nullptr;
|
||||
void (JELLYXR_GL_CALL *gen_textures)(int, unsigned *) = nullptr;
|
||||
void (JELLYXR_GL_CALL *delete_textures)(int, const unsigned *) = nullptr;
|
||||
void (JELLYXR_GL_CALL *bind_texture)(unsigned, unsigned) = nullptr;
|
||||
void (JELLYXR_GL_CALL *texture_parameter)(unsigned, unsigned, int) = nullptr;
|
||||
void (JELLYXR_GL_CALL *texture_image)(unsigned, int, int, int, int, int, unsigned, unsigned, const void *) = nullptr;
|
||||
void (JELLYXR_GL_CALL *gen_framebuffers)(int, unsigned *) = nullptr;
|
||||
void (JELLYXR_GL_CALL *delete_framebuffers)(int, const unsigned *) = nullptr;
|
||||
void (JELLYXR_GL_CALL *bind_framebuffer)(unsigned, unsigned) = nullptr;
|
||||
void (JELLYXR_GL_CALL *attach_texture)(unsigned, unsigned, unsigned, unsigned, int) = nullptr;
|
||||
unsigned (JELLYXR_GL_CALL *check_framebuffer)(unsigned) = nullptr;
|
||||
void (JELLYXR_GL_CALL *blit)(int, int, int, int, int, int, int, int, unsigned, unsigned) = nullptr;
|
||||
void *(JELLYXR_GL_CALL *fence)(unsigned, unsigned) = nullptr;
|
||||
unsigned (JELLYXR_GL_CALL *poll_fence)(void *, unsigned, unsigned long long) = nullptr;
|
||||
void (JELLYXR_GL_CALL *delete_fence)(void *) = nullptr;
|
||||
void (JELLYXR_GL_CALL *flush)() = nullptr;
|
||||
void (JELLYXR_GL_CALL *get_integer)(unsigned, int *) = nullptr;
|
||||
unsigned char (JELLYXR_GL_CALL *is_enabled)(unsigned) = nullptr;
|
||||
void (JELLYXR_GL_CALL *enable)(unsigned) = nullptr;
|
||||
void (JELLYXR_GL_CALL *disable)(unsigned) = nullptr;
|
||||
|
||||
bool load(const SharedGlContext &context);
|
||||
};
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
[configuration]
|
||||
entry_symbol = "jellyframe_mpv_init"
|
||||
compatibility_minimum = "4.5"
|
||||
reloadable = false
|
||||
|
||||
[libraries]
|
||||
windows.x86_64 = "res://addons/jellyframe_mpv/bin/jellyframe_mpv.dll"
|
||||
linux.x86_64 = "res://addons/jellyframe_mpv/bin/jellyframe_mpv.linux.x86_64.so"
|
||||
linux.arm64 = "res://addons/jellyframe_mpv/bin/jellyframe_mpv.linux.arm64.so"
|
||||
|
||||
[dependencies]
|
||||
windows.x86_64 = { "res://addons/jellyframe_mpv/bin/libmpv-2.dll": "" }
|
||||
@@ -0,0 +1,12 @@
|
||||
[configuration]
|
||||
entry_symbol = "jellyxr_mpv_init"
|
||||
compatibility_minimum = "4.5"
|
||||
reloadable = false
|
||||
|
||||
[libraries]
|
||||
windows.x86_64 = "res://addons/jellyxr_mpv/bin/jellyxr_mpv.dll"
|
||||
linux.x86_64 = "res://addons/jellyxr_mpv/bin/jellyxr_mpv.linux.x86_64.so"
|
||||
linux.arm64 = "res://addons/jellyxr_mpv/bin/jellyxr_mpv.linux.arm64.so"
|
||||
|
||||
[dependencies]
|
||||
windows.x86_64 = { "res://addons/jellyxr_mpv/bin/libmpv-2.dll": "", "res://addons/jellyxr_mpv/bin/avcodec-62.dll": "", "res://addons/jellyxr_mpv/bin/avformat-62.dll": "", "res://addons/jellyxr_mpv/bin/avutil-60.dll": "", "res://addons/jellyxr_mpv/bin/swscale-9.dll": "", "res://addons/jellyxr_mpv/bin/swresample-6.dll": "" }
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "mpv_video.h"
|
||||
#include "stream_identity.h"
|
||||
#include <godot_cpp/classes/image.hpp>
|
||||
#include <godot_cpp/classes/os.hpp>
|
||||
#include <godot_cpp/classes/display_server.hpp>
|
||||
@@ -24,16 +25,17 @@ static bool hardware_opengl_available() {
|
||||
!adapter.contains("software rasterizer") && !adapter.contains("swiftshader");
|
||||
}
|
||||
|
||||
void JellyframeMpv::_bind_methods() {
|
||||
ClassDB::bind_method(D_METHOD("open_video", "url", "authorization", "start_ms"), &JellyframeMpv::open_video);
|
||||
ClassDB::bind_method(D_METHOD("set_paused", "paused"), &JellyframeMpv::set_paused);
|
||||
ClassDB::bind_method(D_METHOD("seek_to", "milliseconds"), &JellyframeMpv::seek_to);
|
||||
ClassDB::bind_method(D_METHOD("position_ms"), &JellyframeMpv::position_ms);
|
||||
ClassDB::bind_method(D_METHOD("duration_ms"), &JellyframeMpv::duration_ms);
|
||||
ClassDB::bind_method(D_METHOD("is_buffering"), &JellyframeMpv::is_buffering);
|
||||
ClassDB::bind_method(D_METHOD("playback_diagnostics"), &JellyframeMpv::playback_diagnostics);
|
||||
ClassDB::bind_method(D_METHOD("rendering_backend"), &JellyframeMpv::rendering_backend);
|
||||
ClassDB::bind_method(D_METHOD("stop_video"), &JellyframeMpv::stop_video);
|
||||
void JellyXRMpv::_bind_methods() {
|
||||
ClassDB::bind_method(D_METHOD("open_video", "url", "authorization", "start_ms"), &JellyXRMpv::open_video);
|
||||
ClassDB::bind_method(D_METHOD("set_paused", "paused"), &JellyXRMpv::set_paused);
|
||||
ClassDB::bind_method(D_METHOD("seek_to", "milliseconds"), &JellyXRMpv::seek_to);
|
||||
ClassDB::bind_method(D_METHOD("position_ms"), &JellyXRMpv::position_ms);
|
||||
ClassDB::bind_method(D_METHOD("duration_ms"), &JellyXRMpv::duration_ms);
|
||||
ClassDB::bind_method(D_METHOD("is_buffering"), &JellyXRMpv::is_buffering);
|
||||
ClassDB::bind_method(D_METHOD("rendering_backend"), &JellyXRMpv::rendering_backend);
|
||||
ClassDB::bind_method(D_METHOD("video_shader_parameters"), &JellyXRMpv::video_shader_parameters);
|
||||
ClassDB::bind_method(D_METHOD("playback_diagnostics"), &JellyXRMpv::playback_diagnostics);
|
||||
ClassDB::bind_method(D_METHOD("stop_video"), &JellyXRMpv::stop_video);
|
||||
ADD_SIGNAL(MethodInfo("prepared"));
|
||||
ADD_SIGNAL(MethodInfo("ended"));
|
||||
ADD_SIGNAL(MethodInfo("failed", PropertyInfo(Variant::STRING, "message")));
|
||||
@@ -41,26 +43,44 @@ void JellyframeMpv::_bind_methods() {
|
||||
ADD_SIGNAL(MethodInfo("texture_changed", PropertyInfo(Variant::OBJECT, "texture", PROPERTY_HINT_RESOURCE_TYPE, "Texture2D")));
|
||||
}
|
||||
|
||||
JellyframeMpv::~JellyframeMpv() {
|
||||
JellyXRMpv::~JellyXRMpv() {
|
||||
shutdown();
|
||||
}
|
||||
|
||||
String JellyframeMpv::initialize() {
|
||||
String JellyXRMpv::initialize(int64_t start_ms) {
|
||||
log_video_stats = OS::get_singleton()->get_environment("JELLYXR_VIDEO_STATS") == "1";
|
||||
last_diagnostics_time = playback_time_ms();
|
||||
player = mpv_create();
|
||||
if (!player) return "Could not create the PC video decoder.";
|
||||
const bool use_opengl = !software_only &&
|
||||
OS::get_singleton()->get_environment("JELLYFRAME_PC_RENDERER") != "software" && hardware_opengl_available();
|
||||
OS::get_singleton()->get_environment("JELLYXR_PC_RENDERER") != "software" && hardware_opengl_available();
|
||||
if (!software_only && OS::get_singleton()->get_environment("JELLYXR_PC_RENDERER") != "software" &&
|
||||
DisplayServer::get_singleton()->get_name() != "headless" &&
|
||||
RenderingServer::get_singleton()->get_current_rendering_driver_name() == "vulkan") {
|
||||
vulkan_renderer.instantiate();
|
||||
if (vulkan_renderer->start(current_url, current_authorization, start_ms) < 0) {
|
||||
vulkan_renderer->stop();
|
||||
vulkan_renderer.unref();
|
||||
}
|
||||
}
|
||||
const char *options[][2] = {
|
||||
{"config", "no"}, {"terminal", "no"}, {"load-scripts", "no"},
|
||||
{"ytdl", "no"}, {"input-default-bindings", "no"}, {"input-vo-keyboard", "no"},
|
||||
{"vo", "libmpv"}, {"hwdec", use_opengl ? "auto" : "auto-copy"}, {"osd-level", "0"},
|
||||
{"sub-auto", "no"}, {"audio-file-auto", "no"}, {"keep-open", "no"},
|
||||
{"idle", "yes"}, {"tls-verify", "yes"}
|
||||
{"vo", vulkan_renderer.is_valid() ? "null" : "libmpv"},
|
||||
{"hwdec", use_opengl ? "auto" : "auto-copy"}, {"osd-level", "0"},
|
||||
{"sid", "no"}, {"sub-auto", "no"}, {"audio-file-auto", "no"}, {"keep-open", "no"},
|
||||
{"idle", "yes"}, {"tls-verify", "yes"},
|
||||
{"cache", "yes"}, {"cache-secs", "30"}, {"demuxer-max-bytes", "256MiB"},
|
||||
{"user-agent", JELLYXR_STREAM_USER_AGENT}
|
||||
};
|
||||
for (const auto &option : options) {
|
||||
int error = mpv_set_option_string(player, option[0], option[1]);
|
||||
if (error < 0) return String("PC decoder option failed: ") + option[0] + ".";
|
||||
}
|
||||
if (vulkan_renderer.is_valid() && vulkan_renderer->has_audio()) {
|
||||
int error = mpv_set_option_string(player, "vid", "no");
|
||||
if (error < 0) return "Could not configure the Vulkan playback clock.";
|
||||
}
|
||||
int error = mpv_initialize(player);
|
||||
if (error >= 0 && use_opengl) {
|
||||
gl_renderer.instantiate();
|
||||
@@ -69,7 +89,7 @@ String JellyframeMpv::initialize() {
|
||||
gl_renderer.unref();
|
||||
}
|
||||
}
|
||||
if (error >= 0 && gl_renderer.is_null()) {
|
||||
if (error >= 0 && gl_renderer.is_null() && vulkan_renderer.is_null()) {
|
||||
error = mpv_set_property_string(player, "hwdec", "auto-copy");
|
||||
if (error >= 0) error = renderer.start(player);
|
||||
}
|
||||
@@ -82,15 +102,23 @@ String JellyframeMpv::initialize() {
|
||||
return "";
|
||||
}
|
||||
|
||||
String JellyframeMpv::open_video(String url, String authorization, int64_t start_ms) {
|
||||
String JellyXRMpv::open_video(String url, String authorization, int64_t start_ms) {
|
||||
shutdown();
|
||||
current_url = url;
|
||||
current_authorization = authorization;
|
||||
String error = initialize();
|
||||
String error = initialize(start_ms);
|
||||
if (!error.is_empty()) {
|
||||
shutdown();
|
||||
return error;
|
||||
}
|
||||
if (uses_video_clock()) {
|
||||
position = std::max<int64_t>(0, start_ms);
|
||||
duration = vulkan_renderer->duration_ms();
|
||||
video_clock_origin = playback_time_ms() - position;
|
||||
active = true;
|
||||
set_process(true);
|
||||
return "";
|
||||
}
|
||||
auto header = (String("Authorization: ") + authorization).utf8();
|
||||
mpv_node entry{};
|
||||
entry.format = MPV_FORMAT_STRING;
|
||||
@@ -112,70 +140,76 @@ String JellyframeMpv::open_video(String url, String authorization, int64_t start
|
||||
return String("Could not open the video: ") + mpv_error_string(result);
|
||||
}
|
||||
position = std::max<int64_t>(0, start_ms);
|
||||
audio_clock.reset(position, playback_time_ms());
|
||||
duration = 0;
|
||||
active = true;
|
||||
diagnostics_enabled = OS::get_singleton()->get_environment("JELLYFRAME_PC_DIAGNOSTICS") == "1";
|
||||
diagnostic_time_ms = playback_time_ms();
|
||||
set_process(true);
|
||||
return "";
|
||||
}
|
||||
|
||||
void JellyframeMpv::set_paused(bool value) {
|
||||
void JellyXRMpv::set_paused(bool value) {
|
||||
if (!player || !active) return;
|
||||
if (uses_video_clock()) {
|
||||
if (!paused && video_clock_started && !seeking) position = int64_t(playback_time_ms() - video_clock_origin);
|
||||
video_clock_origin = playback_time_ms() - position;
|
||||
paused = value;
|
||||
watchdog.reset(playback_time_ms());
|
||||
return;
|
||||
}
|
||||
paused = value;
|
||||
audio_clock.reset(position, playback_time_ms());
|
||||
watchdog.reset(playback_time_ms());
|
||||
int flag = value;
|
||||
mpv_set_property_async(player, 0, "pause", MPV_FORMAT_FLAG, &flag);
|
||||
apply_audio_pause();
|
||||
}
|
||||
|
||||
void JellyframeMpv::seek_to(int64_t milliseconds) {
|
||||
void JellyXRMpv::seek_to(int64_t milliseconds) {
|
||||
if (!player || !active) return;
|
||||
seeking = true;
|
||||
audio_clock.reset(std::max<int64_t>(0, milliseconds), playback_time_ms());
|
||||
if (vulkan_renderer.is_valid()) vulkan_renderer->seek(milliseconds);
|
||||
watchdog.reset(playback_time_ms());
|
||||
if (uses_video_clock()) {
|
||||
position = std::max<int64_t>(0, milliseconds);
|
||||
video_clock_origin = playback_time_ms() - position;
|
||||
return;
|
||||
}
|
||||
auto target = String::num(double(std::max<int64_t>(0, milliseconds)) / 1000.0, 3).utf8();
|
||||
const char *command[] = {"seek", target.get_data(), "absolute+exact", nullptr};
|
||||
mpv_command_async(player, 0, command);
|
||||
}
|
||||
|
||||
int64_t JellyframeMpv::position_ms() const { return position; }
|
||||
int64_t JellyframeMpv::duration_ms() const { return duration; }
|
||||
int64_t JellyXRMpv::position_ms() const { return position; }
|
||||
int64_t JellyXRMpv::duration_ms() const { return duration; }
|
||||
|
||||
bool JellyframeMpv::is_buffering() const {
|
||||
return active && (buffering || seeking);
|
||||
bool JellyXRMpv::is_buffering() const {
|
||||
return active && (buffering || video_buffering || seeking);
|
||||
}
|
||||
|
||||
Dictionary JellyframeMpv::playback_diagnostics() const {
|
||||
Dictionary result;
|
||||
if (!player) return result;
|
||||
for (const char *name : {"video-codec", "hwdec-current"}) {
|
||||
char *value = mpv_get_property_string(player, name);
|
||||
result[name] = value ? String::utf8(value) : String("unavailable");
|
||||
mpv_free(value);
|
||||
}
|
||||
for (const char *name : {"container-fps", "decoder-frame-drop-count", "frame-drop-count"}) {
|
||||
double value = 0;
|
||||
result[name] = mpv_get_property(player, name, MPV_FORMAT_DOUBLE, &value) >= 0 ? Variant(value) : Variant();
|
||||
}
|
||||
result["render_backend"] = rendering_backend();
|
||||
result["width"] = texture.is_valid() ? texture->get_width() : frame.width;
|
||||
result["height"] = texture.is_valid() ? texture->get_height() : frame.height;
|
||||
result["cpu_rgba_bytes"] = frame.pixels.size();
|
||||
result["render_call_ms"] = gl_renderer.is_valid() ? gl_renderer->render_call_ms() : double(frame.render_time_us) / 1000.0;
|
||||
result["present_cpu_ms"] = gl_renderer.is_valid() ? gl_renderer->present_cpu_ms() : 0.0;
|
||||
result["upload_cpu_ms"] = upload_cpu_ms;
|
||||
result["uploaded_frames"] = int64_t(uploaded_frames);
|
||||
result["paused"] = paused;
|
||||
result["buffering"] = buffering;
|
||||
result["seeking"] = seeking;
|
||||
return result;
|
||||
bool JellyXRMpv::uses_video_clock() const {
|
||||
return vulkan_renderer.is_valid() && !vulkan_renderer->has_audio();
|
||||
}
|
||||
|
||||
String JellyframeMpv::rendering_backend() const {
|
||||
String JellyXRMpv::rendering_backend() const {
|
||||
if (!player) return "none";
|
||||
if (vulkan_renderer.is_valid()) return "vulkan";
|
||||
return gl_renderer.is_valid() ? "opengl" : "software";
|
||||
}
|
||||
|
||||
void JellyframeMpv::fallback_to_software() {
|
||||
Dictionary JellyXRMpv::playback_diagnostics() const {
|
||||
Dictionary values = vulkan_renderer.is_valid() ? vulkan_renderer->diagnostics() : Dictionary();
|
||||
values["renderer"] = rendering_backend();
|
||||
values["version"] = "vulkan-paced-yuv-2";
|
||||
values["audio_position_ms"] = position;
|
||||
if (vulkan_renderer.is_valid()) {
|
||||
values["video_position_ms"] = vulkan_renderer->timestamp_ms();
|
||||
values["video_buffering"] = video_buffering;
|
||||
values["video_buffer_count"] = int64_t(video_buffer_count);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
void JellyXRMpv::fallback_to_software() {
|
||||
UtilityFunctions::print("PC video: falling back to software. ", playback_diagnostics());
|
||||
String url = current_url;
|
||||
String authorization = current_authorization;
|
||||
int64_t resume_at = position;
|
||||
@@ -186,29 +220,24 @@ void JellyframeMpv::fallback_to_software() {
|
||||
if (was_paused) set_paused(true);
|
||||
}
|
||||
|
||||
void JellyframeMpv::report_diagnostics() {
|
||||
if (!diagnostics_enabled || !prepared) return;
|
||||
uint64_t now = playback_time_ms();
|
||||
uint64_t elapsed = now - diagnostic_time_ms;
|
||||
if (elapsed < 5000) return;
|
||||
Dictionary result = playback_diagnostics();
|
||||
result["uploaded_fps"] = double(uploaded_frames - diagnostic_frames) * 1000.0 / double(elapsed);
|
||||
UtilityFunctions::print("Jellyframe PC playback: ", result);
|
||||
diagnostic_time_ms = now;
|
||||
diagnostic_frames = uploaded_frames;
|
||||
}
|
||||
|
||||
void JellyframeMpv::stop_video() {
|
||||
void JellyXRMpv::stop_video() {
|
||||
shutdown();
|
||||
}
|
||||
|
||||
void JellyframeMpv::shutdown() {
|
||||
void JellyXRMpv::shutdown() {
|
||||
active = false;
|
||||
prepared = false;
|
||||
paused = false;
|
||||
buffering = false;
|
||||
video_buffering = false;
|
||||
video_buffer_count = 0;
|
||||
seeking = false;
|
||||
video_clock_started = false;
|
||||
set_process(false);
|
||||
if (vulkan_renderer.is_valid()) {
|
||||
vulkan_renderer->stop();
|
||||
vulkan_renderer.unref();
|
||||
}
|
||||
if (gl_renderer.is_valid()) {
|
||||
gl_renderer->stop();
|
||||
gl_renderer.unref();
|
||||
@@ -218,15 +247,15 @@ void JellyframeMpv::shutdown() {
|
||||
player = nullptr;
|
||||
texture.unref();
|
||||
frame = VideoFrame();
|
||||
uploaded_frames = 0;
|
||||
diagnostic_frames = 0;
|
||||
diagnostic_time_ms = 0;
|
||||
upload_cpu_ms = 0;
|
||||
current_url = "";
|
||||
current_authorization = "";
|
||||
}
|
||||
|
||||
bool JellyframeMpv::update_size() {
|
||||
bool JellyXRMpv::update_size() {
|
||||
if (vulkan_renderer.is_valid()) {
|
||||
emit_signal("size_changed", vulkan_renderer->width(), vulkan_renderer->height());
|
||||
return vulkan_renderer->width() > 0 && vulkan_renderer->height() > 0;
|
||||
}
|
||||
int64_t width = 0;
|
||||
int64_t height = 0;
|
||||
mpv_get_property(player, "dwidth", MPV_FORMAT_INT64, &width);
|
||||
@@ -243,7 +272,29 @@ bool JellyframeMpv::update_size() {
|
||||
return true;
|
||||
}
|
||||
|
||||
bool JellyframeMpv::upload_frame() {
|
||||
bool JellyXRMpv::upload_frame() {
|
||||
if (vulkan_renderer.is_valid()) {
|
||||
uint64_t now = playback_time_ms();
|
||||
if (paused || buffering || video_buffering || seeking || !prepared) audio_clock.reset(position, now);
|
||||
vulkan_renderer->set_clock(uses_video_clock() ? position : audio_clock.position(now));
|
||||
Dictionary previous_parameters = vulkan_renderer->shader_parameters();
|
||||
if (!vulkan_renderer->take_frame()) return false;
|
||||
if (uses_video_clock()) {
|
||||
if (!video_clock_started || seeking) video_clock_origin = playback_time_ms() - position;
|
||||
video_clock_started = true;
|
||||
seeking = false;
|
||||
}
|
||||
Ref<Texture2D> next = vulkan_renderer->shader_parameters()["y_plane"];
|
||||
if (texture != next) {
|
||||
texture = next;
|
||||
emit_signal("size_changed", vulkan_renderer->width(), vulkan_renderer->height());
|
||||
UtilityFunctions::print("PC video: ", playback_diagnostics());
|
||||
}
|
||||
if (!vulkan_renderer->shader_parameters().recursive_equal(previous_parameters, 0)) {
|
||||
emit_signal("texture_changed", texture);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (gl_renderer.is_valid()) {
|
||||
bool received = gl_renderer->take_frame();
|
||||
if (received) {
|
||||
@@ -251,31 +302,32 @@ bool JellyframeMpv::upload_frame() {
|
||||
texture = gl_renderer->get_texture();
|
||||
emit_signal("texture_changed", texture);
|
||||
}
|
||||
++uploaded_frames;
|
||||
}
|
||||
return received;
|
||||
}
|
||||
if (!renderer.take_frame(frame)) return false;
|
||||
auto upload_started = std::chrono::steady_clock::now();
|
||||
auto image = Image::create_from_data(frame.width, frame.height, false, Image::FORMAT_RGBA8, frame.pixels);
|
||||
if (texture.is_null() || texture->get_width() != frame.width || texture->get_height() != frame.height) {
|
||||
texture = ImageTexture::create_from_image(image);
|
||||
emit_signal("texture_changed", texture);
|
||||
} else {
|
||||
texture->update(image);
|
||||
Ref<ImageTexture> image_texture = texture;
|
||||
image_texture->update(image);
|
||||
}
|
||||
upload_cpu_ms = double(std::chrono::duration_cast<std::chrono::microseconds>(
|
||||
std::chrono::steady_clock::now() - upload_started).count()) / 1000.0;
|
||||
++uploaded_frames;
|
||||
return true;
|
||||
}
|
||||
|
||||
void JellyframeMpv::update_property(const mpv_event &event) {
|
||||
void JellyXRMpv::update_property(const mpv_event &event) {
|
||||
auto *property = static_cast<mpv_event_property *>(event.data);
|
||||
if (!property->data) return;
|
||||
if (property->format == MPV_FORMAT_DOUBLE) {
|
||||
int64_t milliseconds = int64_t(*static_cast<double *>(property->data) * 1000.0);
|
||||
if (event.reply_userdata == 1) position = std::max<int64_t>(0, milliseconds);
|
||||
if (event.reply_userdata == 1) {
|
||||
position = std::max<int64_t>(0, milliseconds);
|
||||
uint64_t now = playback_time_ms();
|
||||
if (paused || buffering || video_buffering || seeking || !prepared) audio_clock.reset(position, now);
|
||||
else audio_clock.synchronize(position, now);
|
||||
}
|
||||
if (event.reply_userdata == 2) duration = std::max<int64_t>(0, milliseconds);
|
||||
} else if (property->format == MPV_FORMAT_FLAG) {
|
||||
bool value = *static_cast<int *>(property->data) != 0;
|
||||
@@ -287,22 +339,39 @@ void JellyframeMpv::update_property(const mpv_event &event) {
|
||||
if (seeking && !value) watchdog.reset(playback_time_ms());
|
||||
seeking = value;
|
||||
}
|
||||
if (event.reply_userdata == 5) {
|
||||
if (event.reply_userdata == 5 && vulkan_renderer.is_null()) {
|
||||
paused = value;
|
||||
watchdog.reset(playback_time_ms());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void JellyframeMpv::_process(double) {
|
||||
void JellyXRMpv::_process(double) {
|
||||
if (!player || !active) return;
|
||||
if (vulkan_renderer.is_valid() && vulkan_renderer->error() < 0) { fallback_to_software(); return; }
|
||||
if (gl_renderer.is_valid() && gl_renderer->error() < 0) { fallback_to_software(); return; }
|
||||
if (gl_renderer.is_null() && renderer.error() < 0) {
|
||||
if (gl_renderer.is_null() && vulkan_renderer.is_null() && renderer.error() < 0) {
|
||||
active = false;
|
||||
emit_signal("failed", String("PC video rendering failed: ") + mpv_error_string(renderer.error()));
|
||||
return;
|
||||
}
|
||||
for (int count = 0; count < 64; ++count) {
|
||||
if (uses_video_clock()) {
|
||||
if (!prepared) {
|
||||
if (!update_size()) { fallback_to_software(); return; }
|
||||
prepared = true;
|
||||
video_clock_origin = playback_time_ms() - position;
|
||||
watchdog.reset(playback_time_ms());
|
||||
emit_signal("prepared");
|
||||
return;
|
||||
}
|
||||
if (!paused && video_clock_started && !seeking) position = int64_t(playback_time_ms() - video_clock_origin);
|
||||
if (!paused && video_clock_started && ((duration > 0 && position >= duration) || (duration == 0 && vulkan_renderer->ended()))) {
|
||||
active = false;
|
||||
emit_signal("ended");
|
||||
return;
|
||||
}
|
||||
}
|
||||
for (int count = 0; !uses_video_clock() && count < 64; ++count) {
|
||||
auto *event = mpv_wait_event(player, 0);
|
||||
if (event->event_id == MPV_EVENT_NONE) break;
|
||||
if (event->event_id == MPV_EVENT_PROPERTY_CHANGE) {
|
||||
@@ -339,10 +408,18 @@ void JellyframeMpv::_process(double) {
|
||||
}
|
||||
}
|
||||
bool received_frame = upload_frame();
|
||||
report_diagnostics();
|
||||
if (prepared && watchdog.stalled(playback_time_ms(), received_frame, paused, buffering || seeking)) {
|
||||
if (gl_renderer.is_valid()) { fallback_to_software(); return; }
|
||||
update_video_buffering();
|
||||
if (log_video_stats && playback_time_ms() - last_diagnostics_time >= 5000) {
|
||||
last_diagnostics_time = playback_time_ms();
|
||||
UtilityFunctions::print("PC video statistics: ", playback_diagnostics());
|
||||
}
|
||||
if (prepared && watchdog.stalled(playback_time_ms(), received_frame, paused, buffering || video_buffering || seeking)) {
|
||||
if (gl_renderer.is_valid() || vulkan_renderer.is_valid()) { fallback_to_software(); return; }
|
||||
active = false;
|
||||
emit_signal("failed", "Video playback stalled.");
|
||||
}
|
||||
}
|
||||
|
||||
Dictionary JellyXRMpv::video_shader_parameters() const {
|
||||
return vulkan_renderer.is_valid() ? vulkan_renderer->shader_parameters() : Dictionary();
|
||||
}
|
||||
@@ -2,23 +2,27 @@
|
||||
|
||||
#include "render_worker.h"
|
||||
#include "render_gl.h"
|
||||
#include "render_vulkan.h"
|
||||
#include "playback_watchdog.h"
|
||||
#include "playback_clock.h"
|
||||
#include <godot_cpp/classes/image_texture.hpp>
|
||||
#include <godot_cpp/classes/node.hpp>
|
||||
|
||||
class JellyframeMpv : public godot::Node {
|
||||
GDCLASS(JellyframeMpv, godot::Node)
|
||||
class JellyXRMpv : public godot::Node {
|
||||
GDCLASS(JellyXRMpv, godot::Node)
|
||||
|
||||
public:
|
||||
~JellyframeMpv();
|
||||
~JellyXRMpv();
|
||||
godot::String open_video(godot::String url, godot::String authorization, int64_t start_ms);
|
||||
void set_paused(bool paused);
|
||||
void seek_to(int64_t milliseconds);
|
||||
int64_t position_ms() const;
|
||||
int64_t duration_ms() const;
|
||||
bool is_buffering() const;
|
||||
godot::Dictionary playback_diagnostics() const;
|
||||
godot::String rendering_backend() const;
|
||||
godot::Dictionary playback_diagnostics() const;
|
||||
// Vulkan exposes YUV planes; texture_changed carries the luma texture.
|
||||
godot::Dictionary video_shader_parameters() const;
|
||||
void stop_video();
|
||||
void _process(double delta) override;
|
||||
|
||||
@@ -26,18 +30,27 @@ protected:
|
||||
static void _bind_methods();
|
||||
|
||||
private:
|
||||
godot::String initialize();
|
||||
godot::String initialize(int64_t start_ms);
|
||||
bool update_size();
|
||||
bool upload_frame();
|
||||
void report_diagnostics();
|
||||
void fallback_to_software();
|
||||
void update_property(const mpv_event &event);
|
||||
void shutdown();
|
||||
bool uses_video_clock() const;
|
||||
void update_video_buffering();
|
||||
void apply_audio_pause();
|
||||
bool video_buffering = false;
|
||||
uint64_t video_buffer_count = 0;
|
||||
uint64_t video_clock_origin = 0;
|
||||
bool video_clock_started = false;
|
||||
bool log_video_stats = false;
|
||||
uint64_t last_diagnostics_time = 0;
|
||||
mpv_handle *player = nullptr;
|
||||
RenderWorker renderer;
|
||||
godot::Ref<RenderGl> gl_renderer;
|
||||
godot::Ref<RenderVulkan> vulkan_renderer;
|
||||
VideoFrame frame;
|
||||
godot::Ref<godot::ImageTexture> texture;
|
||||
godot::Ref<godot::Texture2D> texture;
|
||||
int64_t position = 0;
|
||||
int64_t duration = 0;
|
||||
bool active = false;
|
||||
@@ -45,13 +58,9 @@ private:
|
||||
bool paused = false;
|
||||
bool buffering = false;
|
||||
bool seeking = false;
|
||||
bool diagnostics_enabled = false;
|
||||
uint64_t uploaded_frames = 0;
|
||||
uint64_t diagnostic_frames = 0;
|
||||
uint64_t diagnostic_time_ms = 0;
|
||||
double upload_cpu_ms = 0;
|
||||
bool software_only = false;
|
||||
godot::String current_url;
|
||||
godot::String current_authorization;
|
||||
PlaybackWatchdog watchdog;
|
||||
PlaybackClock audio_clock;
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
|
||||
class PlaybackClock {
|
||||
public:
|
||||
void reset(int64_t position, uint64_t now) {
|
||||
anchor = position;
|
||||
sampled_at = now;
|
||||
}
|
||||
|
||||
int64_t position(uint64_t now) const {
|
||||
return anchor + int64_t(std::min<uint64_t>(now - sampled_at, 250));
|
||||
}
|
||||
|
||||
void synchronize(int64_t audio_position, uint64_t now) {
|
||||
int64_t predicted = position(now);
|
||||
int64_t error = audio_position - predicted;
|
||||
anchor = std::abs(error) > 250 ? audio_position : predicted + std::clamp<int64_t>(error, -2, 2);
|
||||
sampled_at = now;
|
||||
}
|
||||
|
||||
private:
|
||||
int64_t anchor = 0;
|
||||
uint64_t sampled_at = 0;
|
||||
};
|
||||
@@ -6,13 +6,14 @@ using namespace godot;
|
||||
static void initialize(ModuleInitializationLevel level) {
|
||||
if (level == MODULE_INITIALIZATION_LEVEL_SCENE) {
|
||||
ClassDB::register_internal_class<RenderGl>();
|
||||
ClassDB::register_class<JellyframeMpv>();
|
||||
ClassDB::register_internal_class<RenderVulkan>();
|
||||
ClassDB::register_class<JellyXRMpv>();
|
||||
}
|
||||
}
|
||||
|
||||
static void terminate(ModuleInitializationLevel) {}
|
||||
|
||||
extern "C" GDExtensionBool GDE_EXPORT jellyframe_mpv_init(
|
||||
extern "C" GDExtensionBool GDE_EXPORT jellyxr_mpv_init(
|
||||
GDExtensionInterfaceGetProcAddress get_proc_address,
|
||||
GDExtensionClassLibraryPtr library,
|
||||
GDExtensionInitialization *initialization) {
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
#include <godot_cpp/classes/image.hpp>
|
||||
#include <godot_cpp/classes/rendering_server.hpp>
|
||||
#include <godot_cpp/variant/callable_method_pointer.hpp>
|
||||
#include <chrono>
|
||||
#include <future>
|
||||
|
||||
using namespace godot;
|
||||
@@ -121,7 +120,6 @@ int RenderGl::select_ready_slot() {
|
||||
}
|
||||
|
||||
void RenderGl::present_on_render_thread() {
|
||||
auto started = std::chrono::steady_clock::now();
|
||||
std::unique_lock<std::mutex> lock(slots_mutex, std::try_to_lock);
|
||||
if (lock.owns_lock() && display_texture) {
|
||||
int selected = select_ready_slot();
|
||||
@@ -149,7 +147,6 @@ void RenderGl::present_on_render_thread() {
|
||||
wake.notify_one();
|
||||
}
|
||||
}
|
||||
present_time_us = std::chrono::duration_cast<std::chrono::microseconds>(std::chrono::steady_clock::now() - started).count();
|
||||
queued = false;
|
||||
}
|
||||
|
||||
@@ -183,6 +180,4 @@ void RenderGl::stop() {
|
||||
}
|
||||
|
||||
int RenderGl::error() const { return render_error; }
|
||||
double RenderGl::render_call_ms() const { return double(render_time_us.load()) / 1000.0; }
|
||||
double RenderGl::present_cpu_ms() const { return double(present_time_us.load()) / 1000.0; }
|
||||
Ref<ImageTexture> RenderGl::get_texture() const { return texture; }
|
||||
@@ -19,8 +19,6 @@ public:
|
||||
bool take_frame();
|
||||
void stop();
|
||||
int error() const;
|
||||
double render_call_ms() const;
|
||||
double present_cpu_ms() const;
|
||||
godot::Ref<godot::ImageTexture> get_texture() const;
|
||||
|
||||
protected:
|
||||
@@ -65,8 +63,6 @@ private:
|
||||
std::atomic<bool> dirty{false};
|
||||
std::atomic<int> render_error{0};
|
||||
std::atomic<uint64_t> dimensions{(uint64_t(1920) << 32) | 1080};
|
||||
std::atomic<int64_t> render_time_us{0};
|
||||
std::atomic<int64_t> present_time_us{0};
|
||||
std::array<Slot, 2> slots;
|
||||
std::mutex slots_mutex;
|
||||
uint64_t next_sequence = 0;
|
||||
|
||||
@@ -53,9 +53,7 @@ bool RenderGl::render_slot(mpv_render_context *renderer, Slot &slot) {
|
||||
{MPV_RENDER_PARAM_BLOCK_FOR_TARGET_TIME, &block},
|
||||
{MPV_RENDER_PARAM_INVALID, nullptr}
|
||||
};
|
||||
auto started = std::chrono::steady_clock::now();
|
||||
int error = mpv_render_context_render(renderer, params);
|
||||
render_time_us = std::chrono::duration_cast<std::chrono::microseconds>(std::chrono::steady_clock::now() - started).count();
|
||||
if (error < 0) { render_error = error; return false; }
|
||||
void *ready = api.fence(gl::sync_complete, 0);
|
||||
api.flush();
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
#include "render_vulkan.h"
|
||||
#include <godot_cpp/classes/rd_texture_format.hpp>
|
||||
#include <godot_cpp/classes/rd_texture_view.hpp>
|
||||
#include <godot_cpp/classes/rendering_server.hpp>
|
||||
#include <godot_cpp/variant/callable_method_pointer.hpp>
|
||||
#include <mpv/client.h>
|
||||
#include <chrono>
|
||||
|
||||
using namespace godot;
|
||||
|
||||
void RenderVulkan::run_sync(void (RenderVulkan::*operation)()) {
|
||||
auto *server = RenderingServer::get_singleton();
|
||||
if (server->is_on_render_thread()) { (this->*operation)(); return; }
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(sync_mutex);
|
||||
sync_operation = operation;
|
||||
sync_complete = false;
|
||||
}
|
||||
server->call_on_render_thread(callable_mp(this, &RenderVulkan::dispatch_sync));
|
||||
std::unique_lock<std::mutex> lock(sync_mutex);
|
||||
sync_condition.wait(lock, [this] { return sync_complete; });
|
||||
}
|
||||
|
||||
void RenderVulkan::dispatch_sync() {
|
||||
(this->*sync_operation)();
|
||||
{ std::lock_guard<std::mutex> lock(sync_mutex); sync_complete = true; }
|
||||
sync_condition.notify_one();
|
||||
}
|
||||
|
||||
void RenderVulkan::initialize_on_render_thread() {
|
||||
device = RenderingServer::get_singleton()->get_rendering_device();
|
||||
if (!device) { render_error = MPV_ERROR_UNSUPPORTED; return; }
|
||||
}
|
||||
|
||||
int RenderVulkan::start(String url, String authorization, int64_t start_ms) {
|
||||
run_sync(&RenderVulkan::initialize_on_render_thread);
|
||||
if (render_error < 0) return render_error;
|
||||
decoder.set_frame_callback(wakeup, this);
|
||||
int result = decoder.start(url.utf8().get_data(), authorization.utf8().get_data(), start_ms);
|
||||
return result < 0 ? MPV_ERROR_LOADING_FAILED : 0;
|
||||
}
|
||||
|
||||
void RenderVulkan::destroy_textures() {
|
||||
for (auto &texture : textures) {
|
||||
if (texture.is_valid()) texture->set_texture_rd_rid(RID());
|
||||
texture.unref();
|
||||
}
|
||||
for (auto &plane : planes) {
|
||||
if (plane.is_valid()) device->free_rid(plane);
|
||||
plane = RID();
|
||||
}
|
||||
target_width = target_height = 0;
|
||||
}
|
||||
|
||||
bool RenderVulkan::create_textures() {
|
||||
destroy_textures();
|
||||
Ref<RDTextureFormat> format;
|
||||
format.instantiate();
|
||||
format->set_texture_type(RenderingDevice::TEXTURE_TYPE_2D);
|
||||
format->set_usage_bits(RenderingDevice::TEXTURE_USAGE_SAMPLING_BIT | RenderingDevice::TEXTURE_USAGE_CAN_UPDATE_BIT);
|
||||
Ref<RDTextureView> view;
|
||||
view.instantiate();
|
||||
for (int index = 0; index < frame.plane_count; ++index) {
|
||||
format->set_format(frame.sample_bytes == 1 ?
|
||||
(index && frame.interleaved ? RenderingDevice::DATA_FORMAT_R8G8_UNORM : RenderingDevice::DATA_FORMAT_R8_UNORM) :
|
||||
(index && frame.interleaved ? RenderingDevice::DATA_FORMAT_R16G16_UNORM : RenderingDevice::DATA_FORMAT_R16_UNORM));
|
||||
format->set_width(index ? (frame.storage_width + 1) / 2 : frame.storage_width);
|
||||
format->set_height(index ? (frame.storage_height + 1) / 2 : frame.storage_height);
|
||||
planes[index] = device->texture_create(format, view);
|
||||
if (!planes[index].is_valid()) return false;
|
||||
textures[index].instantiate();
|
||||
textures[index]->set_texture_rd_rid(planes[index]);
|
||||
}
|
||||
source_width = frame.width;
|
||||
source_height = frame.height;
|
||||
storage_width = frame.storage_width;
|
||||
storage_height = frame.storage_height;
|
||||
target_width = frame.display_width;
|
||||
target_height = frame.display_height;
|
||||
sample_bytes = frame.sample_bytes;
|
||||
interleaved = frame.interleaved;
|
||||
return true;
|
||||
}
|
||||
|
||||
void RenderVulkan::present_on_render_thread() {
|
||||
if (!stopping && decoder.take_frame(frame) && frame.generation == decoder.current_generation()) {
|
||||
present_frame();
|
||||
presentation_us += std::chrono::duration_cast<std::chrono::microseconds>(std::chrono::steady_clock::now() - presentation_started).count();
|
||||
if (render_error >= 0 && frame.generation == decoder.current_generation()) {
|
||||
const Dictionary parameters = frame_parameters();
|
||||
std::lock_guard<std::mutex> lock(texture_mutex);
|
||||
ready_parameters = parameters;
|
||||
ready_size = Vector2i(frame.display_width, frame.display_height);
|
||||
ready_generation = frame.generation;
|
||||
ready_timestamp = frame.timestamp;
|
||||
++presented_frames;
|
||||
frame_ready = true;
|
||||
}
|
||||
}
|
||||
queued = false;
|
||||
}
|
||||
|
||||
void RenderVulkan::wakeup(void *owner) { static_cast<RenderVulkan *>(owner)->queue_frame(); }
|
||||
|
||||
void RenderVulkan::queue_frame() {
|
||||
if (stopping || queued.exchange(true)) return;
|
||||
presentation_started = std::chrono::steady_clock::now();
|
||||
RenderingServer::get_singleton()->call_on_render_thread(callable_mp(this, &RenderVulkan::present_on_render_thread));
|
||||
}
|
||||
|
||||
void RenderVulkan::present_frame() {
|
||||
if ((frame.display_width != target_width || frame.display_height != target_height ||
|
||||
frame.width != source_width || frame.height != source_height || frame.storage_width != storage_width || frame.storage_height != storage_height ||
|
||||
frame.sample_bytes != sample_bytes || frame.interleaved != interleaved) && !create_textures()) {
|
||||
render_error = MPV_ERROR_NOMEM;
|
||||
return;
|
||||
}
|
||||
for (int index = 0; index < frame.plane_count; ++index) {
|
||||
if (device->texture_update(planes[index], 0, frame.planes[index].data()) != OK) {
|
||||
render_error = MPV_ERROR_GENERIC;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool RenderVulkan::take_frame() {
|
||||
if (!stopping && render_error >= 0 && !queued && decoder.has_frame()) queue_frame();
|
||||
std::lock_guard<std::mutex> lock(texture_mutex);
|
||||
if (!frame_ready.exchange(false) || ready_generation != decoder.current_generation()) return false;
|
||||
presented_parameters = ready_parameters;
|
||||
presented_size = ready_size;
|
||||
presented_timestamp = ready_timestamp;
|
||||
return true;
|
||||
}
|
||||
|
||||
Dictionary RenderVulkan::diagnostics() const {
|
||||
Dictionary values = decoder.diagnostics();
|
||||
uint64_t count = presented_frames.load();
|
||||
values["direct_yuv"] = true;
|
||||
values["presented_frames"] = int64_t(count);
|
||||
values["presentation_ms"] = count ? double(presentation_us.load()) / count / 1000.0 : 0;
|
||||
return values;
|
||||
}
|
||||
|
||||
int RenderVulkan::error() const {
|
||||
return render_error < 0 ? render_error.load() : decoder.error() < 0 ? MPV_ERROR_GENERIC : 0;
|
||||
}
|
||||
|
||||
void RenderVulkan::destroy_on_render_thread() {
|
||||
if (!device) return;
|
||||
destroy_textures();
|
||||
device = nullptr;
|
||||
}
|
||||
|
||||
void RenderVulkan::stop() {
|
||||
stopping = true;
|
||||
decoder.stop();
|
||||
run_sync(&RenderVulkan::destroy_on_render_thread);
|
||||
presented_parameters.clear();
|
||||
ready_parameters.clear();
|
||||
frame = VideoPlanes();
|
||||
}
|
||||
|
||||
Dictionary RenderVulkan::frame_parameters() const {
|
||||
Dictionary values;
|
||||
values["direct_yuv"] = true;
|
||||
values["y_plane"] = textures[0];
|
||||
values["u_plane"] = textures[1];
|
||||
values["v_plane"] = frame.interleaved ? textures[1] : textures[2];
|
||||
values["source_size"] = Vector2(frame.width, frame.height);
|
||||
values["video_rotation"] = frame.rotation;
|
||||
values["interleaved_chroma"] = frame.interleaved;
|
||||
values["chroma_in_alpha"] = frame.interleaved && frame.sample_bytes == 1;
|
||||
values["color_coefficients"] = Vector2(frame.kr, frame.kb);
|
||||
values["full_range"] = frame.full_range;
|
||||
values["transfer"] = frame.transfer;
|
||||
values["sample_scale"] = frame.sample_scale;
|
||||
values["code_max"] = frame.code_max;
|
||||
values["source_peak"] = frame.source_peak;
|
||||
return values;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
#pragma once
|
||||
|
||||
#include "video_decoder.h"
|
||||
#include <godot_cpp/classes/ref_counted.hpp>
|
||||
#include <godot_cpp/classes/rendering_device.hpp>
|
||||
#include <godot_cpp/classes/texture2drd.hpp>
|
||||
#include <chrono>
|
||||
|
||||
class RenderVulkan : public godot::RefCounted {
|
||||
GDCLASS(RenderVulkan, godot::RefCounted)
|
||||
public:
|
||||
int start(godot::String url, godot::String authorization, int64_t start_ms);
|
||||
void stop();
|
||||
void set_clock(int64_t milliseconds) { decoder.set_clock(milliseconds); }
|
||||
void seek(int64_t milliseconds) { decoder.seek(milliseconds); frame_ready = false; presented_timestamp = -1; }
|
||||
int64_t timestamp_ms() const { return presented_timestamp; }
|
||||
bool take_frame();
|
||||
int error() const;
|
||||
bool has_audio() const { return decoder.has_audio(); }
|
||||
int width() const { return presented_size.x > 0 ? presented_size.x : decoder.width(); }
|
||||
int height() const { return presented_size.y > 0 ? presented_size.y : decoder.height(); }
|
||||
int64_t duration_ms() const { return decoder.duration_ms(); }
|
||||
bool ended() const { return decoder.ended(); }
|
||||
bool read_ahead_ready() const { return decoder.read_ahead_ready(); }
|
||||
godot::Dictionary diagnostics() const;
|
||||
godot::Dictionary shader_parameters() const { return presented_parameters; }
|
||||
protected:
|
||||
static void _bind_methods() {}
|
||||
private:
|
||||
void run_sync(void (RenderVulkan::*operation)());
|
||||
void dispatch_sync();
|
||||
void initialize_on_render_thread();
|
||||
void present_on_render_thread();
|
||||
void present_frame();
|
||||
void queue_frame();
|
||||
static void wakeup(void *owner);
|
||||
void destroy_on_render_thread();
|
||||
void destroy_textures();
|
||||
bool create_textures();
|
||||
VideoDecoder decoder;
|
||||
VideoPlanes frame;
|
||||
godot::RenderingDevice *device = nullptr;
|
||||
std::array<godot::Ref<godot::Texture2DRD>, 3> textures;
|
||||
godot::Dictionary ready_parameters;
|
||||
godot::Dictionary presented_parameters;
|
||||
godot::Dictionary frame_parameters() const;
|
||||
godot::Vector2i ready_size;
|
||||
godot::Vector2i presented_size;
|
||||
std::mutex texture_mutex;
|
||||
uint64_t ready_generation = 0;
|
||||
int64_t ready_timestamp = -1;
|
||||
int64_t presented_timestamp = -1;
|
||||
std::array<godot::RID, 3> planes;
|
||||
int target_width = 0;
|
||||
int target_height = 0;
|
||||
int source_width = 0;
|
||||
int source_height = 0;
|
||||
int storage_width = 0;
|
||||
int storage_height = 0;
|
||||
int sample_bytes = 0;
|
||||
bool interleaved = false;
|
||||
std::atomic<int> render_error{0};
|
||||
std::atomic<uint64_t> presented_frames{0}, presentation_us{0};
|
||||
std::atomic<bool> queued{false}, frame_ready{false};
|
||||
std::atomic<bool> stopping{false};
|
||||
std::chrono::steady_clock::time_point presentation_started;
|
||||
std::mutex sync_mutex;
|
||||
std::condition_variable sync_condition;
|
||||
bool sync_complete = false;
|
||||
void (RenderVulkan::*sync_operation)() = nullptr;
|
||||
};
|
||||
@@ -67,10 +67,7 @@ void RenderWorker::render_loop(mpv_render_context *renderer) {
|
||||
{MPV_RENDER_PARAM_SW_POINTER, frame.pixels.ptrw()},
|
||||
{MPV_RENDER_PARAM_INVALID, nullptr}
|
||||
};
|
||||
auto render_started = std::chrono::steady_clock::now();
|
||||
int result = mpv_render_context_render(renderer, params);
|
||||
frame.render_time_us = std::chrono::duration_cast<std::chrono::microseconds>(
|
||||
std::chrono::steady_clock::now() - render_started).count();
|
||||
if (result < 0) {
|
||||
render_error = result;
|
||||
break;
|
||||
|
||||
@@ -13,7 +13,6 @@ struct VideoFrame {
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
godot::PackedByteArray pixels;
|
||||
int64_t render_time_us = 0;
|
||||
};
|
||||
|
||||
class RenderWorker {
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
#include <memory>
|
||||
|
||||
#ifdef _WIN32
|
||||
#define JELLYFRAME_GL_CALL __stdcall
|
||||
#define JELLYXR_GL_CALL __stdcall
|
||||
#else
|
||||
#define JELLYFRAME_GL_CALL
|
||||
#define JELLYXR_GL_CALL
|
||||
#endif
|
||||
|
||||
class SharedGlContext {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
#pragma once
|
||||
|
||||
inline constexpr const char *JELLYXR_STREAM_USER_AGENT = "jellyXR";
|
||||
@@ -0,0 +1,274 @@
|
||||
#include "video_decoder.h"
|
||||
#include "stream_identity.h"
|
||||
extern "C" {
|
||||
#include <libavcodec/avcodec.h>
|
||||
#include <libavformat/avformat.h>
|
||||
#include <libavutil/avutil.h>
|
||||
#include <libavutil/hwcontext.h>
|
||||
#include <libavutil/display.h>
|
||||
#include <libavutil/pixdesc.h>
|
||||
#include <libavutil/time.h>
|
||||
#include <libswscale/swscale.h>
|
||||
}
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cstring>
|
||||
#include <cmath>
|
||||
#include <future>
|
||||
|
||||
VideoDecoder::~VideoDecoder() { stop(); }
|
||||
|
||||
void VideoDecoder::configure_hardware(const AVCodec *decoder) {
|
||||
#ifdef _WIN32
|
||||
const AVHWDeviceType devices[] = {AV_HWDEVICE_TYPE_CUDA, AV_HWDEVICE_TYPE_D3D11VA};
|
||||
#else
|
||||
const AVHWDeviceType devices[] = {AV_HWDEVICE_TYPE_VAAPI, AV_HWDEVICE_TYPE_CUDA};
|
||||
#endif
|
||||
for (auto device_type : devices) {
|
||||
for (int index = 0; ; ++index) {
|
||||
const AVCodecHWConfig *config = avcodec_get_hw_config(decoder, index);
|
||||
if (!config) break;
|
||||
if (config->device_type != device_type || !(config->methods & AV_CODEC_HW_CONFIG_METHOD_HW_DEVICE_CTX)) continue;
|
||||
if (av_hwdevice_ctx_create(&codec->hw_device_ctx, device_type, nullptr, nullptr, 0) < 0) continue;
|
||||
hardware_device = av_hwdevice_get_type_name(device_type);
|
||||
hardware_format = config->pix_fmt;
|
||||
hardware_attempted = false;
|
||||
codec->opaque = this;
|
||||
codec->get_format = [](AVCodecContext *context, const AVPixelFormat *formats) {
|
||||
auto *self = static_cast<VideoDecoder *>(context->opaque);
|
||||
if (!self->hardware_attempted) {
|
||||
self->hardware_attempted = true;
|
||||
for (auto *format = formats; *format != AV_PIX_FMT_NONE; ++format) {
|
||||
if (*format == self->hardware_format) return *format;
|
||||
}
|
||||
}
|
||||
for (auto *format = formats; *format != AV_PIX_FMT_NONE; ++format) {
|
||||
const AVPixFmtDescriptor *description = av_pix_fmt_desc_get(*format);
|
||||
if (description && !(description->flags & AV_PIX_FMT_FLAG_HWACCEL)) return *format;
|
||||
}
|
||||
return AV_PIX_FMT_NONE;
|
||||
};
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int VideoDecoder::interrupt(void *owner) {
|
||||
auto *self = static_cast<VideoDecoder *>(owner);
|
||||
int64_t deadline = self->io_deadline.load();
|
||||
return self->stopping || self->seek_interrupt || (deadline && av_gettime_relative() > deadline);
|
||||
}
|
||||
|
||||
int VideoDecoder::open(const std::string &url, const std::string &authorization) {
|
||||
input = avformat_alloc_context();
|
||||
if (!input) return AVERROR(ENOMEM);
|
||||
input->interrupt_callback = {interrupt, this};
|
||||
AVDictionary *options = nullptr;
|
||||
if (!authorization.empty()) {
|
||||
if (authorization.find_first_of("\r\n") != std::string::npos) return AVERROR(EINVAL);
|
||||
std::string header = "Authorization: " + authorization + "\r\n";
|
||||
av_dict_set(&options, "headers", header.c_str(), 0);
|
||||
}
|
||||
av_dict_set(&options, "tls_verify", "1", 0);
|
||||
av_dict_set(&options, "user_agent", JELLYXR_STREAM_USER_AGENT, 0);
|
||||
av_dict_set(&options, "rw_timeout", "15000000", 0);
|
||||
io_deadline = av_gettime_relative() + 15000000;
|
||||
int result = avformat_open_input(&input, url.c_str(), nullptr, &options);
|
||||
av_dict_free(&options);
|
||||
if (result < 0) return result;
|
||||
result = avformat_find_stream_info(input, nullptr);
|
||||
if (result < 0) return result;
|
||||
const AVCodec *decoder = nullptr;
|
||||
stream_index = av_find_best_stream(input, AVMEDIA_TYPE_VIDEO, -1, -1, &decoder, 0);
|
||||
if (stream_index < 0) return stream_index;
|
||||
audio = av_find_best_stream(input, AVMEDIA_TYPE_AUDIO, -1, -1, nullptr, 0) >= 0;
|
||||
codec = avcodec_alloc_context3(nullptr);
|
||||
if (!codec) return AVERROR(ENOMEM);
|
||||
result = avcodec_parameters_to_context(codec, input->streams[stream_index]->codecpar);
|
||||
if (result < 0) return result;
|
||||
codec->thread_count = std::min(8u, std::max(1u, std::thread::hardware_concurrency()));
|
||||
const AVCodec *software_decoder = decoder;
|
||||
if (codec->codec_id == AV_CODEC_ID_AV1) {
|
||||
const AVCodec *native_decoder = avcodec_find_decoder_by_name("av1");
|
||||
if (native_decoder) decoder = native_decoder;
|
||||
}
|
||||
configure_hardware(decoder);
|
||||
if (!codec->hw_device_ctx) decoder = software_decoder;
|
||||
else codec->thread_count = 1;
|
||||
result = avcodec_open2(codec, decoder, nullptr);
|
||||
if (result < 0) return result;
|
||||
codec_name = decoder->name;
|
||||
return read_geometry();
|
||||
}
|
||||
|
||||
int VideoDecoder::read_geometry() {
|
||||
source_width = codec->width;
|
||||
source_height = codec->height;
|
||||
auto *stream = input->streams[stream_index];
|
||||
#if LIBAVFORMAT_VERSION_MAJOR >= 61
|
||||
const AVPacketSideData *display = av_packet_side_data_get(stream->codecpar->coded_side_data,
|
||||
stream->codecpar->nb_coded_side_data, AV_PKT_DATA_DISPLAYMATRIX);
|
||||
const uint8_t *matrix = display ? display->data : nullptr;
|
||||
size_t matrix_size = display ? display->size : 0;
|
||||
#else
|
||||
size_t matrix_size = 0;
|
||||
const uint8_t *matrix = av_stream_get_side_data(stream, AV_PKT_DATA_DISPLAYMATRIX, &matrix_size);
|
||||
#endif
|
||||
if (matrix && matrix_size >= 9 * sizeof(int32_t)) {
|
||||
double angle = -av_display_rotation_get(reinterpret_cast<const int32_t *>(matrix));
|
||||
if (!std::isfinite(angle) || std::abs(angle / 90.0 - std::round(angle / 90.0)) > 0.001) return AVERROR(ENOSYS);
|
||||
rotation = (int(std::round(angle / 90.0)) % 4 + 4) % 4;
|
||||
}
|
||||
AVRational aspect = av_guess_sample_aspect_ratio(input, stream, nullptr);
|
||||
if (aspect.num > 0 && aspect.den > 0) source_width = int(av_rescale(source_width, aspect.num, aspect.den));
|
||||
if (rotation % 2) std::swap(source_width, source_height);
|
||||
if (source_width <= 0 || source_height <= 0 || source_width > 8192 || source_height > 8192 ||
|
||||
int64_t(source_width) * source_height > 33554432) return AVERROR(ENOSYS);
|
||||
origin_us = input->start_time == AV_NOPTS_VALUE ? 0 : input->start_time;
|
||||
duration = input->duration == AV_NOPTS_VALUE ? 0 : input->duration / 1000;
|
||||
io_deadline = 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int VideoDecoder::start(const std::string &url, const std::string &authorization, int64_t start_ms) {
|
||||
stop();
|
||||
stopping = false;
|
||||
decode_error = 0;
|
||||
hardware_active = false;
|
||||
pinned_download = false;
|
||||
hardware_device.clear();
|
||||
corrupt_packets = 0;
|
||||
decoded_frames = dropped_frames = converted_frames = 0;
|
||||
download_us = conversion_us = 0;
|
||||
read_us = decode_us = read_bytes = 0;
|
||||
buffered_ms = buffered_bytes = 0;
|
||||
reader_ended = seek_interrupt = false;
|
||||
end_of_stream = false;
|
||||
clock = std::max<int64_t>(0, start_ms);
|
||||
seek_target = clock > 0 ? clock : -1;
|
||||
discard_before = clock - 100;
|
||||
last_publish_us = 0;
|
||||
decoded_timestamp = 0;
|
||||
generation = 0;
|
||||
awaiting_first_frame = true;
|
||||
rotation = 0;
|
||||
std::promise<int> initialized;
|
||||
auto result = initialized.get_future();
|
||||
worker = std::thread([this, url, authorization, ready = std::move(initialized)]() mutable {
|
||||
int error = open(url, authorization);
|
||||
ready.set_value(error);
|
||||
if (error >= 0) {
|
||||
reader = std::thread(&VideoDecoder::read_loop, this);
|
||||
decode_loop();
|
||||
stopping = true;
|
||||
wake.notify_all();
|
||||
reader.join();
|
||||
}
|
||||
close();
|
||||
});
|
||||
return result.get();
|
||||
}
|
||||
|
||||
void VideoDecoder::close() {
|
||||
sws_freeContext(scaler);
|
||||
scaler = nullptr;
|
||||
avcodec_free_context(&codec);
|
||||
avformat_close_input(&input);
|
||||
}
|
||||
|
||||
void VideoDecoder::stop() {
|
||||
stopping = true;
|
||||
wake.notify_all();
|
||||
if (worker.joinable()) worker.join();
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
clear_packets();
|
||||
pending = VideoPlanes();
|
||||
staging = VideoPlanes();
|
||||
frame_ready = false;
|
||||
}
|
||||
|
||||
void VideoDecoder::set_clock(int64_t milliseconds) {
|
||||
{ std::lock_guard<std::mutex> lock(mutex); clock = milliseconds; }
|
||||
wake.notify_all();
|
||||
}
|
||||
|
||||
void VideoDecoder::seek(int64_t milliseconds) {
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
seek_target = std::max<int64_t>(0, milliseconds);
|
||||
clock = seek_target;
|
||||
discard_before = seek_target - 100;
|
||||
last_publish_us = 0;
|
||||
++generation;
|
||||
seek_interrupt = true;
|
||||
clear_packets();
|
||||
reader_ended = false;
|
||||
end_of_stream = false;
|
||||
frame_ready = false;
|
||||
awaiting_first_frame = true;
|
||||
}
|
||||
wake.notify_all();
|
||||
}
|
||||
|
||||
bool VideoDecoder::take_frame(VideoPlanes &frame) {
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
if (!frame_ready || (!awaiting_first_frame && pending.timestamp > clock)) return false;
|
||||
awaiting_first_frame = false;
|
||||
std::swap(frame, pending);
|
||||
frame_ready = false;
|
||||
wake.notify_all();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool VideoDecoder::has_frame() {
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
return frame_ready && (awaiting_first_frame || pending.timestamp <= clock);
|
||||
}
|
||||
|
||||
void VideoDecoder::decode_loop() {
|
||||
AVPacket *packet = av_packet_alloc();
|
||||
AVFrame *frame = av_frame_alloc();
|
||||
if (!packet || !frame) decode_error = AVERROR(ENOMEM);
|
||||
uint64_t codec_generation = 0;
|
||||
int consecutive_errors = 0;
|
||||
while (!stopping && decode_error >= 0) {
|
||||
int64_t started = av_gettime_relative();
|
||||
int result = avcodec_receive_frame(codec, frame);
|
||||
decode_us += av_gettime_relative() - started;
|
||||
if (result >= 0) {
|
||||
consecutive_errors = 0;
|
||||
++decoded_frames;
|
||||
bool keep_going = publish(frame, codec_generation);
|
||||
av_frame_unref(frame);
|
||||
if (!keep_going) break;
|
||||
continue;
|
||||
}
|
||||
if (result == AVERROR_EOF) {
|
||||
end_of_stream = true;
|
||||
} else if (result == AVERROR_INVALIDDATA) {
|
||||
++corrupt_packets;
|
||||
if (++consecutive_errors > 8) { decode_error = result; break; }
|
||||
} else if (result != AVERROR(EAGAIN)) { decode_error = result; break; }
|
||||
bool flush = false;
|
||||
if (!read_packet(packet, codec_generation, flush, result)) break;
|
||||
if (result < 0 && (flush || result != AVERROR_EOF)) { decode_error = result; break; }
|
||||
if (flush) {
|
||||
avcodec_flush_buffers(codec);
|
||||
end_of_stream = false;
|
||||
consecutive_errors = 0;
|
||||
continue;
|
||||
}
|
||||
started = av_gettime_relative();
|
||||
bool draining = result == AVERROR_EOF;
|
||||
result = avcodec_send_packet(codec, draining ? nullptr : packet);
|
||||
decode_us += av_gettime_relative() - started;
|
||||
av_packet_unref(packet);
|
||||
if (result == AVERROR_INVALIDDATA && ++consecutive_errors <= 8) {
|
||||
++corrupt_packets;
|
||||
continue;
|
||||
}
|
||||
if (result < 0) { decode_error = result; break; }
|
||||
}
|
||||
av_frame_free(&frame);
|
||||
av_packet_free(&packet);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
#pragma once
|
||||
|
||||
#include "video_plane.h"
|
||||
|
||||
#include <godot_cpp/variant/packed_byte_array.hpp>
|
||||
#include <godot_cpp/variant/dictionary.hpp>
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <condition_variable>
|
||||
#include <deque>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
struct AVFormatContext;
|
||||
struct AVCodecContext;
|
||||
struct AVFrame;
|
||||
struct AVPacket;
|
||||
struct SwsContext;
|
||||
|
||||
struct VideoPlanes {
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
int storage_width = 0;
|
||||
int storage_height = 0;
|
||||
int display_width = 0;
|
||||
int display_height = 0;
|
||||
int rotation = 0;
|
||||
int plane_count = 3;
|
||||
int sample_bytes = 2;
|
||||
bool interleaved = false;
|
||||
float sample_scale = 65535;
|
||||
float code_max = 1023;
|
||||
int64_t timestamp = 0;
|
||||
uint64_t generation = 0;
|
||||
float kr = 0.2126f;
|
||||
float kb = 0.0722f;
|
||||
float full_range = 0;
|
||||
float transfer = 0;
|
||||
float source_peak = 1000.f / 203.f;
|
||||
std::array<VideoPlane, 3> planes;
|
||||
};
|
||||
|
||||
class VideoDecoder {
|
||||
public:
|
||||
~VideoDecoder();
|
||||
int start(const std::string &url, const std::string &authorization, int64_t start_ms);
|
||||
void stop();
|
||||
void set_clock(int64_t milliseconds);
|
||||
void seek(int64_t milliseconds);
|
||||
bool take_frame(VideoPlanes &frame);
|
||||
int error() const { return decode_error; }
|
||||
bool has_audio() const { return audio; }
|
||||
int width() const { return source_width; }
|
||||
int height() const { return source_height; }
|
||||
int64_t duration_ms() const { return duration; }
|
||||
bool ended() const { return end_of_stream; }
|
||||
bool read_ahead_ready() const {
|
||||
return reader_ended || buffered_ms >= 1000 || buffered_bytes >= max_buffer_bytes || buffered_packets >= max_buffer_packets;
|
||||
}
|
||||
godot::Dictionary diagnostics() const;
|
||||
void set_frame_callback(void (*callback)(void *), void *owner) { frame_callback = callback; frame_owner = owner; }
|
||||
bool has_frame();
|
||||
uint64_t current_generation() const { return generation; }
|
||||
|
||||
private:
|
||||
static constexpr int64_t max_buffer_ms = 30000;
|
||||
static constexpr uint64_t max_buffer_bytes = 256 * 1048576;
|
||||
static constexpr size_t max_buffer_packets = 4096;
|
||||
static int interrupt(void *owner);
|
||||
void configure_hardware(const struct AVCodec *decoder);
|
||||
int open(const std::string &url, const std::string &authorization);
|
||||
int read_geometry();
|
||||
void decode_loop();
|
||||
void read_loop();
|
||||
bool read_packet(AVPacket *packet, uint64_t &packet_generation, bool &flush, int &error);
|
||||
void clear_packets();
|
||||
void update_buffer_size();
|
||||
bool publish(AVFrame *frame, uint64_t frame_generation);
|
||||
bool convert(AVFrame *frame, VideoPlanes &output);
|
||||
bool prepare_planes(AVFrame *frame, VideoPlanes &output, int storage_width, int storage_height);
|
||||
bool download_planes(AVFrame *frame, VideoPlanes &output);
|
||||
void close();
|
||||
AVFormatContext *input = nullptr;
|
||||
AVCodecContext *codec = nullptr;
|
||||
SwsContext *scaler = nullptr;
|
||||
int stream_index = -1;
|
||||
int hardware_format = -1;
|
||||
bool hardware_attempted = false;
|
||||
int source_width = 0;
|
||||
int source_height = 0;
|
||||
int rotation = 0;
|
||||
bool audio = false;
|
||||
std::string codec_name;
|
||||
std::string hardware_device;
|
||||
std::atomic<uint64_t> corrupt_packets{0};
|
||||
std::atomic<bool> hardware_active{false};
|
||||
std::atomic<bool> pinned_download{false};
|
||||
std::atomic<int> active_transfer{0};
|
||||
std::atomic<uint64_t> decoded_frames{0}, dropped_frames{0}, converted_frames{0};
|
||||
std::atomic<uint64_t> download_us{0}, conversion_us{0};
|
||||
std::atomic<uint64_t> read_us{0}, decode_us{0}, read_bytes{0};
|
||||
std::atomic<int64_t> buffered_ms{0};
|
||||
std::atomic<uint64_t> buffered_bytes{0};
|
||||
std::atomic<size_t> buffered_packets{0};
|
||||
std::atomic<bool> reader_ended{false}, seek_interrupt{false};
|
||||
int64_t origin_us = 0;
|
||||
int64_t duration = 0;
|
||||
std::atomic<bool> end_of_stream{false};
|
||||
std::thread worker;
|
||||
std::thread reader;
|
||||
std::atomic<bool> stopping{false};
|
||||
std::atomic<int> decode_error{0};
|
||||
std::atomic<int64_t> io_deadline{0};
|
||||
std::mutex mutex;
|
||||
std::condition_variable wake;
|
||||
struct QueuedPacket {
|
||||
AVPacket *packet = nullptr;
|
||||
uint64_t generation = 0;
|
||||
bool flush = false;
|
||||
int error = 0;
|
||||
};
|
||||
std::deque<QueuedPacket> packets;
|
||||
VideoPlanes pending;
|
||||
VideoPlanes staging;
|
||||
bool frame_ready = false;
|
||||
bool awaiting_first_frame = true;
|
||||
int64_t clock = 0;
|
||||
int64_t seek_target = -1;
|
||||
int64_t discard_before = -1;
|
||||
int64_t last_publish_us = 0;
|
||||
std::atomic<int64_t> decoded_timestamp{0};
|
||||
std::atomic<uint64_t> generation{0};
|
||||
void (*frame_callback)(void *) = nullptr;
|
||||
void *frame_owner = nullptr;
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
#include "video_decoder.h"
|
||||
#include <godot_cpp/variant/variant.hpp>
|
||||
extern "C" {
|
||||
#include <libavcodec/avcodec.h>
|
||||
#include <libavformat/avformat.h>
|
||||
#include <libavutil/hwcontext.h>
|
||||
#include <libavutil/time.h>
|
||||
#include <libavutil/pixfmt.h>
|
||||
#include <libswscale/swscale.h>
|
||||
}
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
|
||||
bool VideoDecoder::publish(AVFrame *frame, uint64_t frame_generation) {
|
||||
VideoPlanes &output = staging;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
if (generation != frame_generation) { ++dropped_frames; return true; }
|
||||
}
|
||||
int64_t timestamp = frame->best_effort_timestamp;
|
||||
output.timestamp = timestamp == AV_NOPTS_VALUE ? 0 :
|
||||
(av_rescale_q(timestamp, input->streams[stream_index]->time_base, AV_TIME_BASE_Q) - origin_us) / 1000;
|
||||
decoded_timestamp = output.timestamp;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
if (output.timestamp < discard_before ||
|
||||
(output.timestamp < clock - 100 && av_gettime_relative() - last_publish_us < 100000)) {
|
||||
++dropped_frames;
|
||||
return true;
|
||||
}
|
||||
discard_before = -1;
|
||||
}
|
||||
int64_t started = av_gettime_relative();
|
||||
hardware_active = frame->hw_frames_ctx != nullptr;
|
||||
bool converted;
|
||||
if (frame->hw_frames_ctx) {
|
||||
converted = download_planes(frame, output);
|
||||
download_us += av_gettime_relative() - started;
|
||||
} else {
|
||||
pinned_download = false;
|
||||
converted = convert(frame, output);
|
||||
conversion_us += av_gettime_relative() - started;
|
||||
}
|
||||
++converted_frames;
|
||||
if (!converted) { decode_error = AVERROR_INVALIDDATA; return false; }
|
||||
output.generation = frame_generation;
|
||||
std::unique_lock<std::mutex> lock(mutex);
|
||||
wake.wait(lock, [this, &output, frame_generation] {
|
||||
return stopping || generation != frame_generation || (!frame_ready && output.timestamp <= clock + 30);
|
||||
});
|
||||
if (stopping) return false;
|
||||
if (generation != frame_generation) { ++dropped_frames; return true; }
|
||||
std::swap(pending, output);
|
||||
frame_ready = true;
|
||||
last_publish_us = av_gettime_relative();
|
||||
lock.unlock();
|
||||
if (frame_callback) frame_callback(frame_owner);
|
||||
return true;
|
||||
}
|
||||
|
||||
godot::Dictionary VideoDecoder::diagnostics() const {
|
||||
godot::Dictionary values;
|
||||
values["codec"] = godot::String(codec_name.c_str());
|
||||
values["hardware_device"] = hardware_active ? godot::String(hardware_device.c_str()) : godot::String("none");
|
||||
values["hardware_decoding"] = hardware_active.load();
|
||||
values["pinned_download"] = pinned_download.load();
|
||||
values["transfer"] = active_transfer.load() == 1 ? "pq" : active_transfer.load() == 2 ? "hlg" : "sdr";
|
||||
values["decoded_frames"] = int64_t(decoded_frames.load());
|
||||
values["dropped_frames"] = int64_t(dropped_frames.load());
|
||||
values["corrupt_packets"] = int64_t(corrupt_packets.load());
|
||||
values["decode_error"] = decode_error.load();
|
||||
values["decoded_timestamp_ms"] = decoded_timestamp.load();
|
||||
values["read_ahead_ms"] = buffered_ms.load();
|
||||
values["read_ahead_mb"] = double(buffered_bytes.load()) / 1048576.0;
|
||||
values["read_mb"] = double(read_bytes.load()) / 1048576.0;
|
||||
values["read_seconds"] = double(read_us.load()) / 1000000.0;
|
||||
values["decode_ms"] = decoded_frames ? double(decode_us.load()) / decoded_frames.load() / 1000.0 : 0;
|
||||
uint64_t count = converted_frames.load();
|
||||
values["download_ms"] = count ? double(download_us.load()) / count / 1000.0 : 0;
|
||||
values["conversion_ms"] = count ? double(conversion_us.load()) / count / 1000.0 : 0;
|
||||
return values;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
#include "video_decoder.h"
|
||||
extern "C" {
|
||||
#include <libavcodec/avcodec.h>
|
||||
#include <libavformat/avformat.h>
|
||||
#include <libavutil/time.h>
|
||||
}
|
||||
#include <algorithm>
|
||||
|
||||
void VideoDecoder::clear_packets() {
|
||||
for (auto &entry : packets) av_packet_free(&entry.packet);
|
||||
packets.clear();
|
||||
buffered_bytes = buffered_ms = 0;
|
||||
buffered_packets = 0;
|
||||
}
|
||||
|
||||
void VideoDecoder::update_buffer_size() {
|
||||
buffered_packets = packets.size();
|
||||
int64_t first = AV_NOPTS_VALUE;
|
||||
int64_t last = AV_NOPTS_VALUE;
|
||||
for (const auto &entry : packets) {
|
||||
if (!entry.packet) continue;
|
||||
int64_t timestamp = entry.packet->dts == AV_NOPTS_VALUE ? entry.packet->pts : entry.packet->dts;
|
||||
if (timestamp == AV_NOPTS_VALUE) continue;
|
||||
if (first == AV_NOPTS_VALUE) first = timestamp;
|
||||
last = timestamp;
|
||||
}
|
||||
buffered_ms = first == AV_NOPTS_VALUE ? 0 : std::max<int64_t>(0,
|
||||
av_rescale_q(last - first, input->streams[stream_index]->time_base, AVRational{1, 1000}));
|
||||
}
|
||||
|
||||
bool VideoDecoder::read_packet(AVPacket *packet, uint64_t &packet_generation, bool &flush, int &error) {
|
||||
std::unique_lock<std::mutex> lock(mutex);
|
||||
wake.wait(lock, [this] { return stopping || !packets.empty(); });
|
||||
if (stopping) return false;
|
||||
auto entry = packets.front();
|
||||
packets.pop_front();
|
||||
packet_generation = entry.generation;
|
||||
flush = entry.flush;
|
||||
error = entry.error;
|
||||
if (entry.packet) {
|
||||
buffered_bytes -= entry.packet->size;
|
||||
av_packet_move_ref(packet, entry.packet);
|
||||
av_packet_free(&entry.packet);
|
||||
}
|
||||
update_buffer_size();
|
||||
wake.notify_all();
|
||||
return true;
|
||||
}
|
||||
|
||||
void VideoDecoder::read_loop() {
|
||||
int consecutive_errors = 0;
|
||||
while (!stopping) {
|
||||
int64_t target;
|
||||
uint64_t read_generation;
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(mutex);
|
||||
wake.wait(lock, [this] {
|
||||
return stopping || seek_target >= 0 || (!reader_ended &&
|
||||
packets.size() < max_buffer_packets && buffered_bytes < max_buffer_bytes && buffered_ms < max_buffer_ms);
|
||||
});
|
||||
if (stopping) break;
|
||||
target = seek_target;
|
||||
seek_target = -1;
|
||||
seek_interrupt = false;
|
||||
read_generation = generation;
|
||||
}
|
||||
if (target >= 0) {
|
||||
io_deadline = av_gettime_relative() + 15000000;
|
||||
int error = avformat_seek_file(input, -1, INT64_MIN, origin_us + target * 1000, INT64_MAX, 0);
|
||||
io_deadline = 0;
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
if (generation != read_generation) continue;
|
||||
consecutive_errors = 0;
|
||||
packets.push_back({nullptr, read_generation, true, error});
|
||||
if (error < 0) reader_ended = true;
|
||||
wake.notify_all();
|
||||
if (error < 0) continue;
|
||||
}
|
||||
AVPacket *packet = av_packet_alloc();
|
||||
int64_t started = av_gettime_relative();
|
||||
io_deadline = started + 15000000;
|
||||
int result = packet ? av_read_frame(input, packet) : AVERROR(ENOMEM);
|
||||
io_deadline = 0;
|
||||
read_us += av_gettime_relative() - started;
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
if (generation != read_generation || stopping) { av_packet_free(&packet); continue; }
|
||||
if (result == AVERROR_INVALIDDATA && ++consecutive_errors <= 8) {
|
||||
++corrupt_packets;
|
||||
av_packet_free(&packet);
|
||||
continue;
|
||||
}
|
||||
if (result < 0) {
|
||||
av_packet_free(&packet);
|
||||
packets.push_back({nullptr, read_generation, false, result});
|
||||
reader_ended = true;
|
||||
} else {
|
||||
consecutive_errors = 0;
|
||||
read_bytes += packet->size;
|
||||
if (packet->stream_index != stream_index) { av_packet_free(&packet); continue; }
|
||||
buffered_bytes += packet->size;
|
||||
packets.push_back({packet, read_generation, false, 0});
|
||||
update_buffer_size();
|
||||
}
|
||||
wake.notify_all();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
#include "video_decoder.h"
|
||||
extern "C" {
|
||||
#include <libavcodec/avcodec.h>
|
||||
#include <libavformat/avformat.h>
|
||||
#include <libavutil/hwcontext.h>
|
||||
#include <libavutil/mastering_display_metadata.h>
|
||||
#include <libswscale/swscale.h>
|
||||
}
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
|
||||
static bool native_format(int format) {
|
||||
return format == AV_PIX_FMT_NV12 || format == AV_PIX_FMT_P010LE || format == AV_PIX_FMT_YUV420P ||
|
||||
format == AV_PIX_FMT_YUVJ420P || format == AV_PIX_FMT_YUV420P10LE;
|
||||
}
|
||||
|
||||
static bool supported_size(int width, int height) {
|
||||
return width > 0 && height > 0 && width <= 8192 && height <= 8192 && int64_t(width) * height <= 33554432;
|
||||
}
|
||||
|
||||
bool VideoDecoder::prepare_planes(AVFrame *frame, VideoPlanes &output, int storage_width, int storage_height) {
|
||||
output.width = frame->width;
|
||||
output.height = frame->height;
|
||||
output.storage_width = storage_width;
|
||||
output.storage_height = storage_height;
|
||||
output.rotation = rotation;
|
||||
AVRational aspect = av_guess_sample_aspect_ratio(input, input->streams[stream_index], frame);
|
||||
output.display_width = aspect.num > 0 && aspect.den > 0 ? int(av_rescale(frame->width, aspect.num, aspect.den)) : frame->width;
|
||||
output.display_height = frame->height;
|
||||
if (rotation % 2) std::swap(output.display_width, output.display_height);
|
||||
if (!supported_size(output.width, output.height) || !supported_size(storage_width, storage_height) ||
|
||||
!supported_size(output.display_width, output.display_height)) return false;
|
||||
output.interleaved = frame->format == AV_PIX_FMT_NV12 || frame->format == AV_PIX_FMT_P010LE;
|
||||
output.sample_bytes = frame->format == AV_PIX_FMT_NV12 || frame->format == AV_PIX_FMT_YUV420P || frame->format == AV_PIX_FMT_YUVJ420P ? 1 : 2;
|
||||
output.plane_count = output.interleaved ? 2 : 3;
|
||||
output.sample_scale = output.sample_bytes == 1 ? 1020.f : frame->format == AV_PIX_FMT_P010LE ? 65535.f / 64.f : 65535.f;
|
||||
output.code_max = output.sample_bytes == 1 ? 1020.f : 1023.f;
|
||||
for (int index = 0; index < 3; ++index) {
|
||||
int width = index ? (storage_width + 1) / 2 : storage_width;
|
||||
int height = index ? (storage_height + 1) / 2 : storage_height;
|
||||
int64_t bytes = int64_t(width) * height * output.sample_bytes * (index && output.interleaved ? 2 : 1);
|
||||
if (output.planes[index].resize(index < output.plane_count ? bytes : 0) != 0) return false;
|
||||
}
|
||||
auto range = frame->color_range != AVCOL_RANGE_UNSPECIFIED ? frame->color_range : codec->color_range;
|
||||
auto colorspace = frame->colorspace != AVCOL_SPC_UNSPECIFIED ? frame->colorspace : codec->colorspace;
|
||||
auto transfer = frame->color_trc != AVCOL_TRC_UNSPECIFIED ? frame->color_trc : codec->color_trc;
|
||||
output.full_range = range == AVCOL_RANGE_JPEG ? 1.f : 0.f;
|
||||
output.kr = .2126f;
|
||||
output.kb = .0722f;
|
||||
if (colorspace == AVCOL_SPC_BT2020_NCL) { output.kr = .2627f; output.kb = .0593f; }
|
||||
else if (colorspace == AVCOL_SPC_BT470BG || colorspace == AVCOL_SPC_SMPTE170M ||
|
||||
(colorspace == AVCOL_SPC_UNSPECIFIED && frame->height < 720)) { output.kr = .299f; output.kb = .114f; }
|
||||
output.transfer = transfer == AVCOL_TRC_SMPTE2084 ? 1.f : transfer == AVCOL_TRC_ARIB_STD_B67 ? 2.f : 0.f;
|
||||
active_transfer = int(output.transfer);
|
||||
float peak_nits = 1000;
|
||||
auto *mastering = av_frame_get_side_data(frame, AV_FRAME_DATA_MASTERING_DISPLAY_METADATA);
|
||||
if (mastering && mastering->size >= sizeof(AVMasteringDisplayMetadata)) {
|
||||
auto *metadata = reinterpret_cast<AVMasteringDisplayMetadata *>(mastering->data);
|
||||
if (metadata->has_luminance) peak_nits = float(av_q2d(metadata->max_luminance));
|
||||
}
|
||||
auto *light = av_frame_get_side_data(frame, AV_FRAME_DATA_CONTENT_LIGHT_LEVEL);
|
||||
if (light && light->size >= sizeof(AVContentLightMetadata)) {
|
||||
auto *metadata = reinterpret_cast<AVContentLightMetadata *>(light->data);
|
||||
if (metadata->MaxCLL) peak_nits = float(metadata->MaxCLL);
|
||||
}
|
||||
output.source_peak = std::clamp(peak_nits, 203.f, 10000.f) / 203.f;
|
||||
return true;
|
||||
}
|
||||
|
||||
// VideoPlane owns the allocation until the transfer completes.
|
||||
static void retain_plane_storage(void *, uint8_t *) {}
|
||||
|
||||
bool VideoDecoder::download_planes(AVFrame *frame, VideoPlanes &output) {
|
||||
auto *context = reinterpret_cast<AVHWFramesContext *>(frame->hw_frames_ctx->data);
|
||||
if (!native_format(context->sw_format)) return false;
|
||||
AVFrame description = *frame;
|
||||
description.format = context->sw_format;
|
||||
if (!prepare_planes(&description, output, context->width, context->height)) return false;
|
||||
AVFrame *target = av_frame_alloc();
|
||||
if (!target) return false;
|
||||
target->format = context->sw_format;
|
||||
target->width = context->width;
|
||||
target->height = context->height;
|
||||
bool ready = true;
|
||||
bool pinned = context->format == AV_PIX_FMT_CUDA;
|
||||
for (int index = 0; index < output.plane_count; ++index) {
|
||||
if (context->format == AV_PIX_FMT_CUDA) pinned = output.planes[index].pin(context->device_ref) && pinned;
|
||||
int width = index ? (context->width + 1) / 2 : context->width;
|
||||
target->data[index] = output.planes[index].ptrw();
|
||||
target->linesize[index] = width * output.sample_bytes * (index && output.interleaved ? 2 : 1);
|
||||
target->buf[index] = av_buffer_create(target->data[index], output.planes[index].size(), retain_plane_storage, nullptr, 0);
|
||||
ready = ready && target->buf[index];
|
||||
}
|
||||
pinned_download = pinned;
|
||||
int result = ready ? av_hwframe_transfer_data(target, frame, 0) : AVERROR(ENOMEM);
|
||||
av_frame_free(&target);
|
||||
return result >= 0;
|
||||
}
|
||||
|
||||
bool VideoDecoder::convert(AVFrame *frame, VideoPlanes &output) {
|
||||
bool native = native_format(frame->format);
|
||||
AVFrame description = *frame;
|
||||
if (!native) description.format = AV_PIX_FMT_YUV420P10LE;
|
||||
if (!prepare_planes(&description, output, frame->width, frame->height)) return false;
|
||||
uint8_t *data[4] = {};
|
||||
int strides[4] = {};
|
||||
for (int index = 0; index < output.plane_count; ++index) {
|
||||
int width = index ? (frame->width + 1) / 2 : frame->width;
|
||||
int height = index ? (frame->height + 1) / 2 : frame->height;
|
||||
strides[index] = width * output.sample_bytes * (index && output.interleaved ? 2 : 1);
|
||||
data[index] = output.planes[index].ptrw();
|
||||
if (native) {
|
||||
for (int row = 0; row < height; ++row) {
|
||||
std::memcpy(data[index] + row * strides[index], frame->data[index] + row * frame->linesize[index], strides[index]);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (native) return true;
|
||||
scaler = sws_getCachedContext(scaler, frame->width, frame->height, AVPixelFormat(frame->format),
|
||||
frame->width, frame->height, AV_PIX_FMT_YUV420P10LE, SWS_POINT, nullptr, nullptr, nullptr);
|
||||
if (!scaler) return false;
|
||||
int range = int(output.full_range);
|
||||
const int *coefficients = sws_getCoefficients(SWS_CS_DEFAULT);
|
||||
if (sws_setColorspaceDetails(scaler, coefficients, range, coefficients, range, 0, 1 << 16, 1 << 16) < 0) return false;
|
||||
return sws_scale(scaler, frame->data, frame->linesize, 0, frame->height, data, strides) == frame->height;
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
#include "video_plane.h"
|
||||
extern "C" {
|
||||
#include <libavutil/buffer.h>
|
||||
#include <libavutil/hwcontext.h>
|
||||
}
|
||||
#include <cstring>
|
||||
#ifdef _WIN32
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#include <windows.h>
|
||||
#define CUDA_CALL __stdcall
|
||||
#else
|
||||
#include <dlfcn.h>
|
||||
#define CUDA_CALL
|
||||
#endif
|
||||
|
||||
struct CudaApi {
|
||||
int (CUDA_CALL *push)(void *) = nullptr;
|
||||
int (CUDA_CALL *pop)(void **) = nullptr;
|
||||
int (CUDA_CALL *host_register)(void *, size_t, unsigned) = nullptr;
|
||||
int (CUDA_CALL *host_unregister)(void *) = nullptr;
|
||||
|
||||
CudaApi() {
|
||||
#ifdef _WIN32
|
||||
HMODULE library = LoadLibraryExW(L"nvcuda.dll", nullptr, LOAD_LIBRARY_SEARCH_SYSTEM32);
|
||||
auto symbol = [library](const char *name) -> void * { return library ? reinterpret_cast<void *>(GetProcAddress(library, name)) : nullptr; };
|
||||
#else
|
||||
void *library = dlopen("libcuda.so.1", RTLD_NOW | RTLD_LOCAL);
|
||||
auto symbol = [library](const char *name) -> void * { return library ? dlsym(library, name) : nullptr; };
|
||||
#endif
|
||||
push = reinterpret_cast<decltype(push)>(symbol("cuCtxPushCurrent_v2"));
|
||||
pop = reinterpret_cast<decltype(pop)>(symbol("cuCtxPopCurrent_v2"));
|
||||
host_register = reinterpret_cast<decltype(host_register)>(symbol("cuMemHostRegister_v2"));
|
||||
host_unregister = reinterpret_cast<decltype(host_unregister)>(symbol("cuMemHostUnregister"));
|
||||
}
|
||||
};
|
||||
|
||||
static const CudaApi &cuda_api() {
|
||||
static const CudaApi api;
|
||||
return api;
|
||||
}
|
||||
|
||||
|
||||
class CudaHostPin {
|
||||
public:
|
||||
CudaHostPin(AVBufferRef *owner, void *address, size_t size) : address(address) {
|
||||
auto *device = reinterpret_cast<AVHWDeviceContext *>(owner->data);
|
||||
if (device->type != AV_HWDEVICE_TYPE_CUDA) return;
|
||||
const auto &api = cuda_api();
|
||||
if (!api.push || !api.pop || !api.host_register || !api.host_unregister) return;
|
||||
// CUcontext is the first member of FFmpeg's public AVCUDADeviceContext.
|
||||
std::memcpy(&context, device->hwctx, sizeof(context));
|
||||
reference = av_buffer_ref(owner);
|
||||
if (!reference || api.push(context) != 0) return;
|
||||
registered = api.host_register(address, size, 1) == 0;
|
||||
void *previous = nullptr;
|
||||
api.pop(&previous);
|
||||
}
|
||||
|
||||
~CudaHostPin() {
|
||||
const auto &api = cuda_api();
|
||||
if (registered && api.push(context) == 0) {
|
||||
api.host_unregister(address);
|
||||
void *previous = nullptr;
|
||||
api.pop(&previous);
|
||||
}
|
||||
av_buffer_unref(&reference);
|
||||
}
|
||||
|
||||
bool valid() const { return registered; }
|
||||
void *get_address() const { return address; }
|
||||
private:
|
||||
AVBufferRef *reference = nullptr;
|
||||
void *context = nullptr;
|
||||
void *address = nullptr;
|
||||
bool registered = false;
|
||||
};
|
||||
|
||||
VideoPlane::VideoPlane() = default;
|
||||
VideoPlane::~VideoPlane() = default;
|
||||
VideoPlane::VideoPlane(VideoPlane &&other) noexcept = default;
|
||||
|
||||
VideoPlane &VideoPlane::operator=(VideoPlane &&other) noexcept {
|
||||
if (this == &other) return *this;
|
||||
registration.reset();
|
||||
bytes = std::move(other.bytes);
|
||||
registration = std::move(other.registration);
|
||||
return *this;
|
||||
}
|
||||
|
||||
godot::Error VideoPlane::resize(int64_t size) {
|
||||
if (bytes.size() == size) return godot::OK;
|
||||
registration.reset();
|
||||
return godot::Error(bytes.resize(size));
|
||||
}
|
||||
|
||||
bool VideoPlane::pin(AVBufferRef *device) {
|
||||
uint8_t *address = ptrw();
|
||||
if (registration) return registration->valid();
|
||||
registration = std::make_unique<CudaHostPin>(device, address, bytes.size());
|
||||
return registration->valid();
|
||||
}
|
||||
|
||||
uint8_t *VideoPlane::ptrw() {
|
||||
uint8_t *address = bytes.ptrw();
|
||||
if (registration && registration->get_address() != address) registration.reset();
|
||||
return address;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
#pragma once
|
||||
|
||||
#include <godot_cpp/variant/packed_byte_array.hpp>
|
||||
#include <memory>
|
||||
|
||||
struct AVBufferRef;
|
||||
class CudaHostPin;
|
||||
|
||||
class VideoPlane {
|
||||
public:
|
||||
VideoPlane();
|
||||
~VideoPlane();
|
||||
VideoPlane(VideoPlane &&other) noexcept;
|
||||
VideoPlane &operator=(VideoPlane &&other) noexcept;
|
||||
godot::Error resize(int64_t size);
|
||||
int64_t size() const { return bytes.size(); }
|
||||
uint8_t *ptrw();
|
||||
const godot::PackedByteArray &data() const { return bytes; }
|
||||
bool pin(AVBufferRef *device);
|
||||
private:
|
||||
godot::PackedByteArray bytes;
|
||||
std::unique_ptr<CudaHostPin> registration;
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
#include "mpv_video.h"
|
||||
|
||||
void JellyXRMpv::apply_audio_pause() {
|
||||
int flag = paused || video_buffering;
|
||||
mpv_set_property_async(player, 0, "pause", MPV_FORMAT_FLAG, &flag);
|
||||
}
|
||||
|
||||
void JellyXRMpv::update_video_buffering() {
|
||||
if (vulkan_renderer.is_null() || uses_video_clock() || !prepared || seeking) return;
|
||||
int64_t timestamp = vulkan_renderer->timestamp_ms();
|
||||
bool waiting = video_buffering;
|
||||
if (timestamp < 0 || position - timestamp > 250) waiting = true;
|
||||
else if (position - timestamp <= 80 && vulkan_renderer->read_ahead_ready()) waiting = false;
|
||||
if (waiting == video_buffering) return;
|
||||
video_buffering = waiting;
|
||||
if (waiting) ++video_buffer_count;
|
||||
apply_audio_pause();
|
||||
}
|
||||
@@ -5,6 +5,6 @@ dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories { google(); mavenCentral() }
|
||||
}
|
||||
rootProject.name = 'JellyframeVideo'
|
||||
rootProject.name = 'JellyXRVideo'
|
||||
include ':android'
|
||||
|
||||
@@ -14,12 +14,13 @@ compatibility/default_parent_skeleton_in_mesh_instance_3d=true
|
||||
|
||||
[application]
|
||||
|
||||
config/name="Jellyframe"
|
||||
config/name="jellyXR"
|
||||
config/description="A Jellyfin cinema for standalone VR."
|
||||
config/version="0.1.3"
|
||||
run/main_scene="res://scenes/main.tscn"
|
||||
config/use_custom_user_dir=true
|
||||
config/features=PackedStringArray("4.7", "GL Compatibility")
|
||||
config/icon="res://assets/logo/jellyframe-logo.png"
|
||||
config/icon="res://assets/logo/jellyxr-logo.png"
|
||||
|
||||
[display]
|
||||
|
||||
@@ -28,20 +29,29 @@ window/size/viewport_height=900
|
||||
|
||||
[editor_plugins]
|
||||
|
||||
enabled=PackedStringArray("res://addons/jellyframe_video/plugin.cfg")
|
||||
enabled=PackedStringArray("res://addons/jellyxr_video/plugin.cfg")
|
||||
|
||||
[rendering]
|
||||
|
||||
renderer/rendering_method="gl_compatibility"
|
||||
renderer/rendering_method.mobile="gl_compatibility"
|
||||
textures/vram_compression/import_etc2_astc=true
|
||||
environment/defaults/default_clear_color=Color(0.018, 0.029, 0.046, 1)
|
||||
renderer/rendering_method.linux_opengl="gl_compatibility"
|
||||
renderer/rendering_method.linux_vulkan="mobile"
|
||||
rendering_device/driver.linux_vulkan="vulkan"
|
||||
environment/defaults/default_clear_color=Color(0, 0, 0, 1)
|
||||
renderer/rendering_method.windows="mobile"
|
||||
renderer/rendering_method.linux="mobile"
|
||||
rendering_device/fallback_to_d3d12.windows=false
|
||||
rendering_device/fallback_to_opengl3.windows=true
|
||||
rendering_device/fallback_to_opengl3.linux=true
|
||||
rendering_device/staging_buffer/block_size_kb.windows=4096
|
||||
rendering_device/staging_buffer/block_size_kb.linux=4096
|
||||
rendering_device/staging_buffer/max_size_mb.windows=512
|
||||
rendering_device/staging_buffer/max_size_mb.linux=512
|
||||
rendering_device/staging_buffer/texture_upload_region_size_px.windows=512
|
||||
rendering_device/staging_buffer/texture_upload_region_size_px.linux=512
|
||||
|
||||
[xr]
|
||||
|
||||
openxr/enabled=true
|
||||
openxr/extensions/render_model=true
|
||||
shaders/enabled=true
|
||||
openxr/extensions/meta/passthrough=true
|
||||
@@ -2,6 +2,6 @@
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/main.gd" id="1"]
|
||||
|
||||
[node name="Jellyframe" type="Node3D"]
|
||||
[node name="jellyXR" type="Node3D"]
|
||||
script = ExtResource("1")
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
class_name PhotoSequence
|
||||
extends RefCounted
|
||||
|
||||
var items: Array = []
|
||||
var index := -1
|
||||
var offset := 0
|
||||
var has_more := false
|
||||
var search := ""
|
||||
var library_id := ""
|
||||
var busy := false
|
||||
|
||||
func capture(library: LibraryView, selected: Dictionary) -> void:
|
||||
items.clear()
|
||||
if library.grid:
|
||||
for card in library.grid.get_children():
|
||||
if card.item.get("Type", "") == "Photo": items.append(card.item)
|
||||
index = items.find(selected)
|
||||
if index < 0:
|
||||
items = [selected]
|
||||
index = 0
|
||||
offset = library.offset
|
||||
has_more = library.has_more
|
||||
search = library.search
|
||||
library_id = library.library_id
|
||||
|
||||
func can_next() -> bool:
|
||||
return index + 1 < items.size() or has_more
|
||||
|
||||
func step(direction: int, api: JellyfinClient) -> Dictionary:
|
||||
if busy or index + direction < 0: return {}
|
||||
busy = true
|
||||
while direction > 0 and index + 1 >= items.size() and has_more:
|
||||
var result := await api.library(search, offset, library_id)
|
||||
if not result.ok:
|
||||
busy = false
|
||||
return {"error": result.error}
|
||||
var page: Array = result.data.get("Items", [])
|
||||
offset += page.size()
|
||||
has_more = not page.is_empty() and offset < int(result.data.get("TotalRecordCount", offset))
|
||||
for item in page:
|
||||
if item.get("Type", "") == "Photo": items.append(item)
|
||||
busy = false
|
||||
if index + direction >= items.size(): return {}
|
||||
index += direction
|
||||
return {"item": items[index]}
|
||||
@@ -0,0 +1 @@
|
||||
uid://gscxynvjkrml
|
||||
@@ -1,9 +1,14 @@
|
||||
class_name JellyframeSettings
|
||||
class_name JellyXRSettings
|
||||
extends RefCounted
|
||||
|
||||
const PATH := "user://settings.cfg"
|
||||
const SCREEN_KEYS := ["width", "distance", "height", "curvature"]
|
||||
const PLAYBACK_MODES := ["auto", "original", "transcoded"]
|
||||
const TRANSCODE_PRESETS := {
|
||||
720: {"label": "720p", "width": 1280, "height": 720, "bitrate": 6000000},
|
||||
1080: {"label": "1080p", "width": 1920, "height": 1080, "bitrate": 12000000},
|
||||
2160: {"label": "4K", "width": 3840, "height": 2160, "bitrate": 40000000}
|
||||
}
|
||||
const LIBRARY_SORT_OPTIONS := [
|
||||
["Name", "SortName"], ["Random", "Random"],
|
||||
["Community rating", "CommunityRating"], ["Critics rating", "CriticRating"],
|
||||
@@ -16,11 +21,16 @@ var distance := 3.0
|
||||
var height := 0.0
|
||||
var curvature := 0.0
|
||||
var environment := "Void"
|
||||
var follow_screen := false
|
||||
var server := ""
|
||||
var device_id := ""
|
||||
var library_sort_by := "SortName"
|
||||
var library_sort_order := "Ascending"
|
||||
var playback_mode := "auto"
|
||||
var transcode_resolution := 1080
|
||||
var subtitle_size := 48
|
||||
var subtitle_color := "White"
|
||||
var subtitle_height := 0.18
|
||||
|
||||
func load_settings(path: String = PATH) -> void:
|
||||
var config := ConfigFile.new()
|
||||
@@ -29,6 +39,7 @@ func load_settings(path: String = PATH) -> void:
|
||||
distance = clampf(config.get_value("screen", "distance", distance), 1.5, 8.0)
|
||||
height = clampf(config.get_value("screen", "height", height), -2.0, 2.0)
|
||||
curvature = clampf(config.get_value("screen", "curvature", curvature), 0.0, 1.2)
|
||||
follow_screen = config.get_value("screen", "follow_screen", false) == true
|
||||
environment = config.get_value("screen", "environment", "Void")
|
||||
if int(config.get_value("screen", "environment_version", 1)) < 2:
|
||||
if environment == "Theatre": environment = "Cinema"
|
||||
@@ -44,6 +55,12 @@ func load_settings(path: String = PATH) -> void:
|
||||
library_sort_order = "Ascending"
|
||||
playback_mode = str(config.get_value("playback", "mode", "auto"))
|
||||
if playback_mode not in PLAYBACK_MODES: playback_mode = "auto"
|
||||
transcode_resolution = int(config.get_value("playback", "transcode_resolution", 1080))
|
||||
if not TRANSCODE_PRESETS.has(transcode_resolution): transcode_resolution = 1080
|
||||
subtitle_size = clampi(int(config.get_value("subtitles", "size", 48)), 28, 80)
|
||||
subtitle_color = str(config.get_value("subtitles", "color", "White"))
|
||||
if subtitle_color not in ["White", "Yellow", "Green"]: subtitle_color = "White"
|
||||
subtitle_height = clampf(float(config.get_value("subtitles", "height", 0.18)), 0.1, 0.8)
|
||||
if device_id.is_empty():
|
||||
device_id = Crypto.new().generate_random_bytes(16).hex_encode()
|
||||
save(path)
|
||||
@@ -52,7 +69,7 @@ func is_theatre() -> bool:
|
||||
return environment in ["Cinema", "Theatre"]
|
||||
|
||||
func reset_screen() -> void:
|
||||
var defaults := JellyframeSettings.new()
|
||||
var defaults := JellyXRSettings.new()
|
||||
for key in SCREEN_KEYS:
|
||||
set(key, defaults.get(key))
|
||||
|
||||
@@ -61,11 +78,16 @@ func save(path: String = PATH) -> Error:
|
||||
for key in SCREEN_KEYS:
|
||||
config.set_value("screen", key, get(key))
|
||||
config.set_value("screen", "environment", environment)
|
||||
config.set_value("screen", "follow_screen", follow_screen)
|
||||
config.set_value("screen", "environment_version", 2)
|
||||
config.set_value("connection", "server", server)
|
||||
config.set_value("connection", "device_id", device_id)
|
||||
config.set_value("library", "sort_by", library_sort_by)
|
||||
config.set_value("library", "sort_order", library_sort_order)
|
||||
config.set_value("playback", "mode", playback_mode)
|
||||
config.set_value("playback", "transcode_resolution", transcode_resolution)
|
||||
config.set_value("subtitles", "size", subtitle_size)
|
||||
config.set_value("subtitles", "color", subtitle_color)
|
||||
config.set_value("subtitles", "height", subtitle_height)
|
||||
return config.save(path)
|
||||
|
||||
@@ -12,7 +12,7 @@ var random_library_generation: Dictionary = {}
|
||||
|
||||
func authorization() -> String:
|
||||
var name := headset_name().replace("\\", "\\\\").replace('"', '\\"').replace("\r", " ").replace("\n", " ")
|
||||
var value := 'MediaBrowser Client="Jellyframe", Device="%s", DeviceId="%s", Version="0.1.0"' % [name, device_id]
|
||||
var value := 'MediaBrowser Client="jellyXR", Device="%s", DeviceId="%s", Version="0.1.3"' % [name, device_id]
|
||||
if not token.is_empty():
|
||||
value += ', Token="%s"' % token
|
||||
return value
|
||||
@@ -84,7 +84,7 @@ func query(values: Dictionary) -> String:
|
||||
return "?" + "&".join(pairs)
|
||||
|
||||
func library(search: String = "", offset: int = 0, parent_id: String = "") -> Dictionary:
|
||||
var values := {"UserId": user_id, "Recursive": "true", "IncludeItemTypes": "Movie,Series,Video", "Fields": "Overview,UserData", "Limit": 25, "StartIndex": offset, "SortBy": library_sort_by, "SortOrder": library_sort_order}
|
||||
var values := {"UserId": user_id, "Recursive": "true", "IncludeItemTypes": "Movie,Series,Video,Photo", "Fields": "Overview,UserData", "Limit": 25, "StartIndex": offset, "SortBy": library_sort_by, "SortOrder": library_sort_order}
|
||||
if search.is_empty() and parent_id.is_empty():
|
||||
values["SortBy"] = "DateCreated"
|
||||
values["SortOrder"] = "Descending"
|
||||
@@ -133,11 +133,33 @@ func resume_items() -> Dictionary:
|
||||
func details(id: String) -> Dictionary:
|
||||
return await request("/Users/%s/Items/%s" % [user_id, id.uri_encode()])
|
||||
|
||||
func set_watched(id: String, watched: bool) -> Dictionary:
|
||||
return await request("/Users/%s/PlayedItems/%s" % [user_id.uri_encode(), id.uri_encode()], HTTPClient.METHOD_POST if watched else HTTPClient.METHOD_DELETE)
|
||||
|
||||
func seasons(series_id: String) -> Dictionary:
|
||||
return await request("/Shows/%s/Seasons" % series_id.uri_encode() + query({"UserId": user_id, "Fields": "Overview,UserData", "EnableUserData": "true"}))
|
||||
|
||||
func series_playback(series_id: String) -> Dictionary:
|
||||
var result := await request("/Shows/NextUp" + query(_next_up_parameters(series_id)))
|
||||
if not result.ok or not result.data.get("Items", []).is_empty(): return result
|
||||
return await request("/Shows/%s/Episodes" % series_id.uri_encode() + query({"UserId": user_id, "StartIndex": 0, "Limit": 1, "SortBy": "ParentIndexNumber,IndexNumber", "SortOrder": "Ascending", "Fields": "Overview,UserData,MediaSources", "EnableUserData": "true", "IsMissing": "false"}))
|
||||
|
||||
func _next_up_parameters(series_id: String) -> Dictionary:
|
||||
return {"UserId": user_id, "SeriesId": series_id, "Limit": 1, "Fields": "Overview,UserData,MediaSources", "EnableUserData": "true", "EnableResumable": "true", "EnableRewatching": "false"}
|
||||
|
||||
func next_up(series_id: String) -> Dictionary:
|
||||
var parameters := _next_up_parameters(series_id)
|
||||
var result := await request("/Shows/NextUp" + query(parameters))
|
||||
if not result.ok: return result
|
||||
var items: Array = result.data.get("Items", [])
|
||||
if items.is_empty(): return result
|
||||
var user: Dictionary = items[0].get("UserData", {}) if items[0].get("UserData") else {}
|
||||
if int(user.get("PlaybackPositionTicks", 0)) > 0: return result
|
||||
parameters["DisableFirstEpisode"] = "true"
|
||||
return await request("/Shows/NextUp" + query(parameters))
|
||||
|
||||
func episodes(series_id: String, season_id: String, offset: int = 0) -> Dictionary:
|
||||
return await request("/Shows/%s/Episodes" % series_id.uri_encode() + query({"UserId": user_id, "SeasonId": season_id, "StartIndex": offset, "Limit": 100, "Fields": "Overview,UserData", "EnableUserData": "true", "IsMissing": "false"}))
|
||||
return await request("/Shows/%s/Episodes" % series_id.uri_encode() + query({"UserId": user_id, "SeasonId": season_id, "StartIndex": offset, "Limit": 100, "Fields": "Overview,UserData,MediaSources", "EnableUserData": "true", "IsMissing": "false"}))
|
||||
|
||||
func next_episode(item: Dictionary) -> Dictionary:
|
||||
var series_id := str(item.get("SeriesId", ""))
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
class_name MediaSegments
|
||||
extends RefCounted
|
||||
|
||||
const LABELS := {"Intro": "Skip intro", "Outro": "Skip credits", "Recap": "Skip recap", "Preview": "Skip preview"}
|
||||
|
||||
var segments: Array[Dictionary] = []
|
||||
var generation := 0
|
||||
|
||||
func clear() -> void:
|
||||
generation += 1
|
||||
segments.clear()
|
||||
|
||||
func load_item(api: JellyfinClient, item_id: String) -> void:
|
||||
clear()
|
||||
var current := generation
|
||||
var result := await api.request("/MediaSegments/" + item_id.uri_encode())
|
||||
if current != generation or not result.get("ok", false): return
|
||||
segments = parse(result.get("data"))
|
||||
|
||||
static func parse(data: Variant) -> Array[Dictionary]:
|
||||
var parsed: Array[Dictionary] = []
|
||||
if not data is Dictionary or not data.get("Items") is Array: return parsed
|
||||
for item in data.Items:
|
||||
if not item is Dictionary or not LABELS.has(item.get("Type")): continue
|
||||
var start: Variant = item.get("StartTicks")
|
||||
var end: Variant = item.get("EndTicks")
|
||||
if not (start is int or start is float) or not (end is int or end is float): continue
|
||||
if not is_finite(float(start)) or not is_finite(float(end)): continue
|
||||
if start < 0 or end <= start or end >= 9223372036854775807: continue
|
||||
var start_ms := int(start / 10000)
|
||||
var end_ms := int(end / 10000)
|
||||
if end_ms <= start_ms: continue
|
||||
parsed.append({"start_ms": start_ms, "end_ms": end_ms, "label": LABELS[item.Type]})
|
||||
parsed.sort_custom(func(a: Dictionary, b: Dictionary): return a.start_ms < b.start_ms)
|
||||
return parsed
|
||||
|
||||
func active(position_ms: int, duration_ms: int) -> Dictionary:
|
||||
if duration_ms <= 0: return {}
|
||||
for segment in segments:
|
||||
var target := mini(int(segment.end_ms), maxi(0, duration_ms - 1000))
|
||||
if position_ms >= segment.start_ms and position_ms < target:
|
||||
return {"label": segment.label, "target_ms": target}
|
||||
return {}
|
||||
@@ -0,0 +1 @@
|
||||
uid://cwoeoeho3i2tr
|
||||
@@ -3,10 +3,12 @@ extends Node
|
||||
|
||||
signal failed(message: String)
|
||||
signal stream_ready(url: String, source: Dictionary, start_ms: int)
|
||||
signal reporting_failed(message: String)
|
||||
|
||||
var api: JellyfinClient
|
||||
var settings: JellyframeSettings
|
||||
var settings: JellyXRSettings
|
||||
var active_playback_mode := "auto"
|
||||
var active_transcode_resolution := 1080
|
||||
var item: Dictionary = {}
|
||||
var source: Dictionary = {}
|
||||
var session_id := ""
|
||||
@@ -14,40 +16,69 @@ var method := "DirectPlay"
|
||||
var position_ticks := 0
|
||||
var started := false
|
||||
var fallback_attempted := false
|
||||
var diagnostics_pending := false
|
||||
var media_source_id := ""
|
||||
var audio_index := -1
|
||||
var subtitle_index := -1
|
||||
var burn_subtitles := false
|
||||
var generation := 0
|
||||
|
||||
func open(selected: Dictionary, transcode: bool = false) -> void:
|
||||
func open(selected: Dictionary, transcode: bool = false, continuing: bool = false) -> void:
|
||||
generation += 1
|
||||
var current := generation
|
||||
item = selected
|
||||
if not transcode:
|
||||
if not transcode and not continuing:
|
||||
audio_index = -1
|
||||
subtitle_index = -1
|
||||
burn_subtitles = false
|
||||
media_source_id = str(item.get("SelectedMediaSourceId", ""))
|
||||
if media_source_id.is_empty() and item.get("MediaSources") is Array and not item.MediaSources.is_empty():
|
||||
media_source_id = str(item.MediaSources[0].get("Id", ""))
|
||||
position_ticks = int(item.get("UserData", {}).get("PlaybackPositionTicks", 0))
|
||||
fallback_attempted = false
|
||||
active_playback_mode = settings.playback_mode if settings != null else "auto"
|
||||
active_transcode_resolution = settings.transcode_resolution if settings != null and active_playback_mode == "transcoded" else 1080
|
||||
started = false
|
||||
var use_transcode := transcode or active_playback_mode == "transcoded"
|
||||
var use_transcode := transcode or burn_subtitles or active_playback_mode == "transcoded"
|
||||
var original := active_playback_mode == "original"
|
||||
var profile := _device_profile(use_transcode, original)
|
||||
if not use_transcode:
|
||||
print("Jellyframe playback: direct codecs=", profile.DirectPlayProfiles[0].get("VideoCodec", "original formats"))
|
||||
var result := await api.request("/Items/%s/PlaybackInfo" % str(item.Id).uri_encode(), HTTPClient.METHOD_POST, {
|
||||
var body := {
|
||||
"UserId": api.user_id, "StartTimeTicks": position_ticks, "IsPlayback": true,
|
||||
"AutoOpenLiveStream": true, "EnableDirectPlay": not use_transcode, "EnableDirectStream": false,
|
||||
"AutoOpenLiveStream": true, "EnableDirectPlay": not use_transcode and audio_index < 0, "EnableDirectStream": not use_transcode and not original,
|
||||
"EnableTranscoding": not original, "AllowVideoStreamCopy": not use_transcode,
|
||||
"DeviceProfile": profile, "MaxStreamingBitrate": profile.MaxStreamingBitrate
|
||||
})
|
||||
}
|
||||
body["SubtitleStreamIndex"] = subtitle_index if burn_subtitles else -1
|
||||
if burn_subtitles: body["AlwaysBurnInSubtitleWhenTranscoding"] = true
|
||||
if audio_index >= 0: body["AudioStreamIndex"] = audio_index
|
||||
if not media_source_id.is_empty(): body["MediaSourceId"] = media_source_id
|
||||
var needs_source_selection := media_source_id.is_empty()
|
||||
var result := await api.request("/Items/%s/PlaybackInfo" % str(item.Id).uri_encode(), HTTPClient.METHOD_POST, body)
|
||||
if current != generation: return
|
||||
if not result.ok:
|
||||
failed.emit(result.error)
|
||||
return
|
||||
if result.data.get("MediaSources", []).is_empty():
|
||||
failed.emit("Jellyfin could not provide a video stream. Check your account's playback permissions.")
|
||||
return
|
||||
source = result.data.MediaSources[0]
|
||||
source = {}
|
||||
for candidate in result.data.MediaSources:
|
||||
if media_source_id.is_empty() or str(candidate.get("Id", "")) == media_source_id:
|
||||
source = candidate
|
||||
break
|
||||
if source.is_empty():
|
||||
failed.emit("The selected version is no longer available. Reopen the item and choose another version.")
|
||||
return
|
||||
media_source_id = str(source.Id)
|
||||
if needs_source_selection and source.get("DefaultSubtitleStreamIndex") != null and int(source.DefaultSubtitleStreamIndex) >= 0:
|
||||
await open(item, transcode, true)
|
||||
return
|
||||
session_id = result.data.get("PlaySessionId", "")
|
||||
var url: String
|
||||
var start_ms := int(position_ticks / 10000)
|
||||
if original and not source.get("SupportsDirectPlay", false):
|
||||
failed.emit("Jellyfin cannot provide the original video. Choose Transcoded in Settings > Playback.")
|
||||
return
|
||||
if source.get("SupportsDirectPlay", false) and not use_transcode:
|
||||
if source.get("SupportsDirectPlay", false) and not use_transcode and audio_index < 0:
|
||||
method = "DirectPlay"
|
||||
url = api.server + "/Videos/%s/stream" % str(item.Id).uri_encode() + api.query({"Static": "true", "MediaSourceId": source.Id})
|
||||
else:
|
||||
@@ -56,20 +87,17 @@ func open(selected: Dictionary, transcode: bool = false) -> void:
|
||||
if path.is_empty():
|
||||
failed.emit("Jellyfin needs to convert this video but did not provide a stream.")
|
||||
return
|
||||
if not use_transcode and (source.get("SupportsDirectStream", false) or _copies_video(path)):
|
||||
method = "DirectStream"
|
||||
url = api.stream_url(path)
|
||||
if url.is_empty():
|
||||
failed.emit("The server returned an unsupported stream address.")
|
||||
return
|
||||
if not is_hls(): start_ms = 0
|
||||
print("Jellyframe playback: method=", method, ", bitrate=", source.get("Bitrate", 0), ", transcode reasons=", source.get("TranscodeReasons", []))
|
||||
for track in source.get("MediaStreams", []):
|
||||
if track.get("Type", "") == "Video":
|
||||
print("Jellyframe playback: codec=", track.get("Codec", "unknown"), ", size=", track.get("Width", 0), "x", track.get("Height", 0), ", range=", track.get("VideoRangeType", "unknown"), ", fps=", track.get("AverageFrameRate", 0))
|
||||
break
|
||||
stream_ready.emit(url, source, start_ms)
|
||||
|
||||
func is_hls() -> bool:
|
||||
if method != "Transcode": return false
|
||||
if method == "DirectPlay": return false
|
||||
var protocol := str(source.get("TranscodingSubProtocol", "")).to_lower()
|
||||
var path := str(source.get("TranscodingUrl", "")).get_slice("?", 0).to_lower()
|
||||
return protocol == "hls" or path.ends_with(".m3u8")
|
||||
@@ -81,17 +109,18 @@ func _device_profile(transcode: bool, original: bool = false) -> Dictionary:
|
||||
direct_profile["AudioCodec"] = "aac,mp3,opus,vorbis"
|
||||
var transcoding_profile := {"Container": "ts", "Type": "Video", "VideoCodec": "h264", "AudioCodec": "aac", "Protocol": "hls", "Context": "Streaming", "MaxAudioChannels": "2", "MinSegments": 2, "BreakOnNonKeyFrames": true}
|
||||
var codec_conditions := [{"Condition": "Equals", "Property": "VideoRangeType", "Value": "SDR", "IsRequired": true}]
|
||||
var preset: Dictionary = JellyXRSettings.TRANSCODE_PRESETS.get(active_transcode_resolution, JellyXRSettings.TRANSCODE_PRESETS[1080])
|
||||
transcoding_profile["Conditions"] = []
|
||||
for limit in [["Width", "1920"], ["Height", "1080"], ["VideoFramerate", "30"], ["VideoBitrate", "12000000"]]:
|
||||
for limit in [["Width", str(preset.width)], ["Height", str(preset.height)], ["VideoFramerate", "30"], ["VideoBitrate", str(preset.bitrate)]]:
|
||||
transcoding_profile.Conditions.append({"Condition": "LessThanEqual", "Property": limit[0], "Value": limit[1], "IsRequired": true})
|
||||
if transcode: codec_conditions.append_array(transcoding_profile.Conditions)
|
||||
return {
|
||||
"Name": ("Jellyframe original " if original else "Jellyframe SDR ") + OS.get_name(),
|
||||
"Name": ("jellyXR original " if original else "jellyXR SDR ") + OS.get_name(),
|
||||
"MaxStreamingBitrate": 2147483647 if original else 80000000,
|
||||
"DirectPlayProfiles": [] if transcode else [direct_profile],
|
||||
"TranscodingProfiles": [] if original else [transcoding_profile],
|
||||
"CodecProfiles": [] if original else [{"Type": "Video", "Conditions": codec_conditions}],
|
||||
"SubtitleProfiles": []
|
||||
"SubtitleProfiles": [{"Format": "vtt", "Method": "External"}, {"Format": "pgssub", "Method": "Encode"}, {"Format": "dvdsub", "Method": "Encode"}, {"Format": "dvbsub", "Method": "Encode"}]
|
||||
}
|
||||
|
||||
func retry_transcode() -> void:
|
||||
@@ -110,25 +139,26 @@ func report(event: String, paused: bool = false) -> void:
|
||||
if event == "Playing": started = true
|
||||
if not started: return
|
||||
var path := "/Sessions/Playing" + ("/" + event if event != "Playing" else "")
|
||||
await api.request(path, HTTPClient.METHOD_POST, {"ItemId": item.Id, "MediaSourceId": source.Id, "PlaySessionId": session_id, "PositionTicks": position_ticks, "IsPaused": paused, "CanSeek": true, "PlayMethod": method})
|
||||
if event == "Progress" and method == "Transcode" and OS.is_debug_build() and not diagnostics_pending:
|
||||
_log_transcoding()
|
||||
var body := {"ItemId": item.Id, "MediaSourceId": source.Id, "PlaySessionId": session_id, "PositionTicks": position_ticks, "IsPaused": paused, "CanSeek": true, "PlayMethod": method, "SubtitleStreamIndex": subtitle_index}
|
||||
if audio_index >= 0:
|
||||
body["AudioStreamIndex"] = audio_index
|
||||
elif source.get("DefaultAudioStreamIndex") != null:
|
||||
body["AudioStreamIndex"] = int(source.DefaultAudioStreamIndex)
|
||||
var result := await api.request(path, HTTPClient.METHOD_POST, body)
|
||||
if not result.ok:
|
||||
var message := "Jellyfin playback report (%s) failed: %s" % [event, result.get("error", "Unknown error.")]
|
||||
push_warning(message)
|
||||
reporting_failed.emit(message)
|
||||
if event == "Stopped":
|
||||
started = false
|
||||
if method == "Transcode":
|
||||
if method != "DirectPlay":
|
||||
await api.request("/Videos/ActiveEncodings" + api.query({"DeviceId": api.device_id, "PlaySessionId": session_id}), HTTPClient.METHOD_DELETE)
|
||||
|
||||
func _log_transcoding() -> void:
|
||||
diagnostics_pending = true
|
||||
var result := await api.request("/Sessions" + api.query({"DeviceId": api.device_id}))
|
||||
diagnostics_pending = false
|
||||
if not result.ok or not result.data is Array: return
|
||||
for active in result.data:
|
||||
if not active is Dictionary: continue
|
||||
if str(active.get("DeviceId", "")) != api.device_id: continue
|
||||
var playing_item: Variant = active.get("NowPlayingItem")
|
||||
if not playing_item is Dictionary: continue
|
||||
if str(playing_item.get("Id", "")) != str(item.get("Id", "")): continue
|
||||
var info: Variant = active.get("TranscodingInfo")
|
||||
if not info is Dictionary: continue
|
||||
print("Jellyframe transcoding: fps=", info.get("Framerate"), ", bitrate=", info.get("Bitrate"), ", size=", info.get("Width"), "x", info.get("Height"), ", hardware=", info.get("HardwareAccelerationType"), ", video copy=", info.get("IsVideoDirect"), ", reasons=", info.get("TranscodeReasons"))
|
||||
func cancel_pending() -> void:
|
||||
generation += 1
|
||||
|
||||
func _copies_video(path: String) -> bool:
|
||||
for pair in path.get_slice("?", 1).split("&"):
|
||||
if pair.get_slice("=", 0).to_lower() == "videocodec":
|
||||
return pair.get_slice("=", 1).uri_decode().to_lower() == "copy"
|
||||
return false
|
||||
@@ -1,6 +1,6 @@
|
||||
extends Node3D
|
||||
|
||||
var settings := JellyframeSettings.new()
|
||||
var settings := JellyXRSettings.new()
|
||||
var session_store := SessionStore.new()
|
||||
var api := JellyfinClient.new()
|
||||
var session := PlaybackSession.new()
|
||||
@@ -14,6 +14,9 @@ var libraries_loaded := false
|
||||
var library_generation := 0
|
||||
var page_generation := 0
|
||||
var page: VBoxContainer
|
||||
var browsing_return_page: Control
|
||||
var browsing_return_heading := ""
|
||||
var browsing_return_status := ""
|
||||
var status: Label
|
||||
var keyboard: VRKeyboard
|
||||
var material: ShaderMaterial
|
||||
@@ -25,17 +28,26 @@ var stereo_overridden := false
|
||||
var projection_mode := VideoProjection.Mode.SCREEN
|
||||
var projection_overridden := false
|
||||
var video_size := Vector2i(1920, 1080)
|
||||
var decoder_size := Vector2i.ZERO
|
||||
var playing := false
|
||||
var elapsed := 0.0
|
||||
var transcode_offset := 0
|
||||
var quick_generation := 0
|
||||
var countdown_generation := 0
|
||||
var autoplay_pending := false
|
||||
var calibration := false
|
||||
var seeking := false
|
||||
var seek_paused := false
|
||||
var timeline_elapsed := 0.0
|
||||
var decoder_ready := false
|
||||
var media_segments := MediaSegments.new()
|
||||
var photo_active := false
|
||||
var photo_loading := false
|
||||
var photo_item: Dictionary = {}
|
||||
var video_material: ShaderMaterial
|
||||
var photo_loader: PhotoView
|
||||
var photo_sequence := PhotoSequence.new()
|
||||
var subtitles := SubtitleTrack.new()
|
||||
var subtitle_overlay := SubtitleOverlay.new()
|
||||
|
||||
func _ready() -> void:
|
||||
settings.load_settings()
|
||||
@@ -45,20 +57,19 @@ func _ready() -> void:
|
||||
session.settings = settings
|
||||
add_child(session)
|
||||
add_child(video)
|
||||
add_child(subtitles)
|
||||
_setup_world()
|
||||
add_child(MenuFeedback.new())
|
||||
_setup_ui()
|
||||
session.stream_ready.connect(_play_stream)
|
||||
session.failed.connect(_error)
|
||||
session.reporting_failed.connect(func(message: String): status.text = message)
|
||||
video.failed.connect(_video_failed)
|
||||
video.prepared.connect(_video_prepared)
|
||||
video.ended.connect(_ended)
|
||||
video.size_changed.connect(func(w: int, h: int): video_size = Vector2i(w, h); _resize_screen())
|
||||
video.size_changed.connect(func(w: int, h: int): video_size = Vector2i(w, h); decoder_size = video_size; _resize_screen())
|
||||
_show_login()
|
||||
await _restore_session()
|
||||
if video.has_android_services() and OS.is_debug_build() and video.bridge.calibration_requested():
|
||||
await get_tree().create_timer(2).timeout
|
||||
_calibrate()
|
||||
|
||||
func _setup_world() -> void:
|
||||
world = ViewingWorld.new()
|
||||
@@ -69,14 +80,20 @@ func _setup_world() -> void:
|
||||
viewport = world.viewport
|
||||
panel = world.panel
|
||||
screen = world.screen
|
||||
screen.add_child(subtitle_overlay)
|
||||
player_overlay = world.player_overlay
|
||||
subtitle_overlay.controls = player_overlay
|
||||
player_overlay.action.connect(_player_action)
|
||||
player_overlay.statistics_provider = func(): return preload("res://scripts/ui/playback_statistics.gd").snapshot(session, video, decoder_size if decoder_ready else Vector2i.ZERO)
|
||||
player_overlay.seek_requested.connect(_seek_to)
|
||||
world.player_loading.cancelled.connect(_show_library)
|
||||
world.player_skip_popup.skip_requested.connect(_skip_segment)
|
||||
material = world.material
|
||||
theatre = world.theatre
|
||||
video.material = material
|
||||
world.menu_requested.connect(_toggle_menu)
|
||||
world.settings_requested.connect(_toggle_settings)
|
||||
world.library_requested.connect(_toggle_controller_library)
|
||||
var xr := XRServer.find_interface("OpenXR") as OpenXRInterface
|
||||
if xr:
|
||||
xr.pose_recentered.connect(_recenter, CONNECT_DEFERRED)
|
||||
@@ -93,12 +110,17 @@ func _setup_ui() -> void:
|
||||
status = shell.status
|
||||
shell.navigate.connect(_open_library)
|
||||
shell.screen_settings.connect(_screen_settings)
|
||||
shell.return_from_settings.connect(_restore_browsing_page)
|
||||
shell.sign_out.connect(_sign_out)
|
||||
keyboard = VRKeyboard.new()
|
||||
viewport.add_child(keyboard)
|
||||
keyboard.hide()
|
||||
|
||||
func _clear_page() -> void:
|
||||
func _clear_page(keep_browsing_return: bool = false) -> void:
|
||||
if not keep_browsing_return and is_instance_valid(browsing_return_page):
|
||||
browsing_return_page.queue_free()
|
||||
browsing_return_page = null
|
||||
shell.set_settings_mode(false)
|
||||
page_generation += 1
|
||||
countdown_generation += 1
|
||||
_cancel_countdown()
|
||||
@@ -141,8 +163,6 @@ func _show_login() -> void:
|
||||
else: status.text = result.error)
|
||||
Widgets.button("Quick Connect", func():
|
||||
if _configure(address.text): _quick_connect(), row)
|
||||
if video.has_android_services() and OS.is_debug_build():
|
||||
Widgets.button("Test 3D playback", _calibrate, row)
|
||||
Widgets.label("HTTP sends credentials without encryption. Use HTTPS outside your home network.", page, 18)
|
||||
status.text = "Sign in to start watching."
|
||||
|
||||
@@ -236,6 +256,22 @@ func _show_library() -> void:
|
||||
if shell.buttons.has(shell.selected_id): title = shell.buttons[shell.selected_id].text
|
||||
_open_library(shell.selected_id, title)
|
||||
|
||||
func _toggle_controller_library() -> void:
|
||||
if api.token.is_empty(): return
|
||||
if autoplay_pending:
|
||||
_show_library()
|
||||
return
|
||||
for child in page.get_children():
|
||||
if child is TracksView or child is SettingsView:
|
||||
_restore_browsing_page()
|
||||
return
|
||||
_toggle_panel()
|
||||
|
||||
func _toggle_panel() -> void:
|
||||
panel.visible = not panel.visible
|
||||
player_overlay.hide()
|
||||
keyboard.hide()
|
||||
|
||||
func _open_library(id: String, title: String) -> void:
|
||||
_clear_page()
|
||||
shell.set_signed_in(true)
|
||||
@@ -247,7 +283,11 @@ func _open_library(id: String, title: String) -> void:
|
||||
api.library_sort_by = settings.library_sort_by
|
||||
api.library_sort_order = settings.library_sort_order
|
||||
library.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
library.status.connect(func(message: String): status.text = message)
|
||||
library.status.connect(func(message: String):
|
||||
if library == browsing_return_page:
|
||||
browsing_return_status = message
|
||||
else:
|
||||
status.text = message)
|
||||
library.selected.connect(_details)
|
||||
page.add_child(library)
|
||||
library.field.focus_entered.connect(func(): keyboard.edit.call_deferred(library.field))
|
||||
@@ -269,6 +309,12 @@ func _load_libraries() -> void:
|
||||
Widgets.button("Retry libraries", _load_libraries, shell.libraries).add_theme_font_size_override("font_size", 17)
|
||||
|
||||
func _details(item: Dictionary, season: Dictionary = {}) -> void:
|
||||
if item.get("Type", "") == "Photo":
|
||||
photo_sequence = PhotoSequence.new()
|
||||
for child in page.get_children():
|
||||
if child is LibraryView: photo_sequence.capture(child, item)
|
||||
_show_photo(item)
|
||||
return
|
||||
page_generation += 1
|
||||
var current := page_generation
|
||||
status.text = "Loading details..."
|
||||
@@ -285,12 +331,96 @@ func _details(item: Dictionary, season: Dictionary = {}) -> void:
|
||||
view.initial_season = season
|
||||
view.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
view.play_requested.connect(_start)
|
||||
view.play_from_start_requested.connect(_start.bind(true))
|
||||
view.version_requested.connect(_choose_media_version)
|
||||
view.back_requested.connect(_show_library)
|
||||
page.add_child(view)
|
||||
status.text = "Ready."
|
||||
|
||||
func _start(item: Dictionary) -> void:
|
||||
if playing and str(session.item.get("Id", "")) == str(item.get("Id", "")):
|
||||
func _show_photo(item: Dictionary) -> void:
|
||||
page_generation += 1
|
||||
var current := page_generation
|
||||
await _stop(false)
|
||||
if current != page_generation: return
|
||||
photo_active = true
|
||||
photo_item = item
|
||||
video_material = material
|
||||
material = ShaderMaterial.new()
|
||||
material.shader = preload("res://shaders/pc_video.gdshader")
|
||||
screen.material_override = material
|
||||
world.panorama.material_override = material
|
||||
mode = PhotoView.stereo_mode(item)
|
||||
projection_mode = VideoProjection.detect({"Path": item.get("Path", item.get("Name", ""))})
|
||||
stereo_overridden = false
|
||||
projection_overridden = false
|
||||
photo_loader = PhotoView.new()
|
||||
photo_loader.api = api
|
||||
photo_loader.item = item
|
||||
photo_loader.photo_ready.connect(_photo_ready)
|
||||
photo_loader.failed.connect(_photo_failed)
|
||||
status.text = "Loading photo..."
|
||||
photo_loading = true
|
||||
keyboard.hide()
|
||||
panel.hide()
|
||||
screen.show()
|
||||
_resize_screen()
|
||||
_update_loading()
|
||||
add_child(photo_loader)
|
||||
photo_loader._load()
|
||||
|
||||
func _photo_ready(texture: Texture2D) -> void:
|
||||
if not photo_active: return
|
||||
photo_loading = false
|
||||
_update_loading()
|
||||
material.set_shader_parameter("video_texture", texture)
|
||||
video_size = Vector2i(texture.get_size())
|
||||
screen.show()
|
||||
world.set_panoramic(projection_mode != VideoProjection.Mode.SCREEN, VideoProjection.horizontal_angle(projection_mode))
|
||||
_resize_screen()
|
||||
status.text = ""
|
||||
_show_playback()
|
||||
|
||||
func _photo_failed(message: String) -> void:
|
||||
_close_photo()
|
||||
status.text = message + " Select the photo to try again."
|
||||
panel.show()
|
||||
|
||||
func _step_photo(direction: int) -> void:
|
||||
if not photo_active or photo_sequence.busy: return
|
||||
var current := page_generation
|
||||
player_overlay.set_photo_navigation(false, false)
|
||||
photo_loading = true
|
||||
_update_loading()
|
||||
var result := await photo_sequence.step(direction, api)
|
||||
if current != page_generation or not photo_active: return
|
||||
if result.has("item"):
|
||||
await _show_photo(result.item)
|
||||
else:
|
||||
photo_loading = false
|
||||
_update_loading()
|
||||
player_overlay.set_photo_navigation(photo_sequence.index > 0, photo_sequence.can_next())
|
||||
if result.has("error"): player_overlay.tooltip.text = result.error
|
||||
|
||||
func _close_photo() -> void:
|
||||
if not photo_active: return
|
||||
photo_active = false
|
||||
photo_loading = false
|
||||
world.player_loading.hide()
|
||||
photo_item = {}
|
||||
if is_instance_valid(photo_loader):
|
||||
remove_child(photo_loader)
|
||||
photo_loader.queue_free()
|
||||
photo_loader = null
|
||||
material = video_material
|
||||
screen.material_override = material
|
||||
world.panorama.material_override = material
|
||||
video_material = null
|
||||
world.set_panoramic(false)
|
||||
player_overlay.set_photo_mode(false)
|
||||
|
||||
func _start(item: Dictionary, from_start: bool = false) -> void:
|
||||
var requested_source := str(item.get("SelectedMediaSourceId", ""))
|
||||
if not from_start and playing and str(session.item.get("Id", "")) == str(item.get("Id", "")) and (requested_source.is_empty() or requested_source == session.media_source_id):
|
||||
if video.paused:
|
||||
video.toggle_pause()
|
||||
session.report("Progress", false)
|
||||
@@ -300,21 +430,60 @@ func _start(item: Dictionary) -> void:
|
||||
status.text = video.unavailable_message()
|
||||
return
|
||||
await _stop(false)
|
||||
calibration = false
|
||||
projection_overridden = false
|
||||
stereo_overridden = false
|
||||
projection_mode = VideoProjection.Mode.SCREEN
|
||||
world.set_panoramic(false)
|
||||
_recenter()
|
||||
_resize_screen()
|
||||
status.text = "Preparing playback..."
|
||||
playing = true
|
||||
screen.show()
|
||||
_show_playback()
|
||||
player_overlay.title.text = DetailsMetadata.title(item, "Now playing")
|
||||
await session.open(item)
|
||||
media_segments.load_item(api, str(item.Id))
|
||||
var selected := item
|
||||
if from_start:
|
||||
selected = item.duplicate(true)
|
||||
if not selected.get("UserData") is Dictionary: selected.UserData = {}
|
||||
selected.UserData.PlaybackPositionTicks = 0
|
||||
await session.open(selected)
|
||||
|
||||
func _choose_media_version(item: Dictionary) -> void:
|
||||
var sources: Array = item.get("MediaSources", []) if item.get("MediaSources") else []
|
||||
_clear_page()
|
||||
shell.heading.text = "Playback"
|
||||
panel.show()
|
||||
player_overlay.hide()
|
||||
Widgets.button("Back to details", _details.bind(item), page)
|
||||
var title := Widgets.label("Choose a version", page, 36)
|
||||
title.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
var name_label := Widgets.label(DetailsMetadata.title(item), page, 26)
|
||||
name_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
var scroll := ScrollContainer.new()
|
||||
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
||||
scroll.follow_focus = true
|
||||
page.add_child(scroll)
|
||||
var choices := VBoxContainer.new()
|
||||
choices.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
choices.add_theme_constant_override("separation", 12)
|
||||
scroll.add_child(choices)
|
||||
for index in range(sources.size()):
|
||||
var source: Dictionary = sources[index]
|
||||
var selected := item.duplicate(true)
|
||||
selected["SelectedMediaSourceId"] = str(source.get("Id", ""))
|
||||
var label := str(source.get("Name", "")) if source.get("Name") else "Version %d" % (index + 1)
|
||||
var button := Widgets.button(label, _start.bind(selected), choices)
|
||||
button.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
button.alignment = HORIZONTAL_ALIGNMENT_LEFT
|
||||
button.disabled = selected.SelectedMediaSourceId.is_empty()
|
||||
if index == 0: button.grab_focus.call_deferred()
|
||||
status.text = "Select the version to play."
|
||||
|
||||
func _play_stream(url: String, source: Dictionary, start_ms: int) -> void:
|
||||
var keep_menu_open := playing and panel.visible
|
||||
decoder_ready = false
|
||||
decoder_size = Vector2i.ZERO
|
||||
if not seeking and not stereo_overridden: mode = Stereo.detect(source)
|
||||
if not seeking and not projection_overridden:
|
||||
projection_mode = VideoProjection.detect(source)
|
||||
@@ -322,11 +491,11 @@ func _play_stream(url: String, source: Dictionary, start_ms: int) -> void:
|
||||
for stream in source.get("MediaStreams", []):
|
||||
if stream.get("Type", "") == "Video":
|
||||
video_size = Vector2i(int(stream.get("Width", 1920)), int(stream.get("Height", 1080)))
|
||||
transcode_offset = session.position_ticks if session.method == "Transcode" and not session.is_hls() else 0
|
||||
if not seeking: _recenter()
|
||||
transcode_offset = session.position_ticks if session.method != "DirectPlay" and not session.is_hls() else 0
|
||||
if not seeking: _resize_screen()
|
||||
screen.show()
|
||||
playing = true
|
||||
if not seeking: _show_playback()
|
||||
if not seeking and not keep_menu_open: _show_playback()
|
||||
_update_timeline()
|
||||
await video.open(url, api.authorization(), start_ms)
|
||||
|
||||
@@ -334,9 +503,8 @@ func _video_prepared() -> void:
|
||||
decoder_ready = true
|
||||
if seeking and seek_paused: video.toggle_pause()
|
||||
session.report("Playing", video.paused)
|
||||
status.text = "Playing. Press A or B to show the controls."
|
||||
if get_viewport().use_xr and not seeking:
|
||||
panel.hide()
|
||||
status.text = "Playing. Press A for controls or B for the library."
|
||||
if get_viewport().use_xr and not seeking and not panel.visible:
|
||||
player_overlay.hide()
|
||||
seeking = false
|
||||
_update_timeline()
|
||||
@@ -344,24 +512,35 @@ func _video_prepared() -> void:
|
||||
func _show_playback() -> void:
|
||||
keyboard.hide()
|
||||
panel.hide()
|
||||
player_overlay.title.text = DetailsMetadata.title(session.item, "Now playing")
|
||||
player_overlay.set_photo_mode(photo_active)
|
||||
if photo_active: player_overlay.set_photo_navigation(photo_sequence.index > 0, photo_sequence.can_next())
|
||||
player_overlay.title.text = DetailsMetadata.title(photo_item if photo_active else session.item, "Now playing")
|
||||
player_overlay.show()
|
||||
_update_timeline()
|
||||
|
||||
func _player_action(action: String) -> void:
|
||||
if photo_active and action in ["pause", "back", "forward", "skip_segment"]: return
|
||||
match action:
|
||||
"previous_photo": _step_photo(-1)
|
||||
"next_photo": _step_photo(1)
|
||||
"pause":
|
||||
video.toggle_pause()
|
||||
session.report("Progress", video.paused)
|
||||
_update_timeline()
|
||||
"back": _skip(-10000)
|
||||
"forward": _skip(30000)
|
||||
"skip_segment": _skip_segment()
|
||||
"stop": _stop()
|
||||
"tracks": _show_tracks()
|
||||
"settings": _screen_settings()
|
||||
"library": _return_to_title()
|
||||
"hide": player_overlay.hide()
|
||||
|
||||
func _return_to_title() -> void:
|
||||
if is_instance_valid(browsing_return_page): _restore_browsing_page()
|
||||
if photo_active:
|
||||
_show_library()
|
||||
return
|
||||
var item := session.item
|
||||
if item.is_empty():
|
||||
_show_library()
|
||||
@@ -382,11 +561,22 @@ func _return_to_title() -> void:
|
||||
return
|
||||
await _details({"Id": target_id}, season)
|
||||
|
||||
func _toggle_settings() -> void:
|
||||
if api.token.is_empty() or not shell.signed_in: return
|
||||
for child in page.get_children():
|
||||
if child is SettingsView:
|
||||
_toggle_panel()
|
||||
return
|
||||
_screen_settings()
|
||||
|
||||
func _screen_settings() -> void:
|
||||
_clear_page()
|
||||
var previous_heading := shell.heading.text if shell.heading.text != "Settings" else "Home"
|
||||
if api.token.is_empty() or not shell.signed_in: return
|
||||
_preserve_browsing_page()
|
||||
panel.show()
|
||||
shell.heading.text = "Settings"
|
||||
shell.set_settings_mode(true)
|
||||
var view := SettingsView.new()
|
||||
view.navigation_host = shell.settings_navigation
|
||||
view.settings = settings
|
||||
view.passthrough_supported = world.passthrough.is_supported()
|
||||
view.stereo_mode = mode
|
||||
@@ -394,19 +584,38 @@ func _screen_settings() -> void:
|
||||
view.swap_eyes = material.get_shader_parameter("swap_eyes") == true
|
||||
view.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
view.screen_changed.connect(func(): _resize_screen(); _position_screen())
|
||||
view.screen_follow_changed.connect(_update_screen_follow)
|
||||
view.environment_changed.connect(_change_environment)
|
||||
view.stereo_selected.connect(_select_stereo)
|
||||
view.projection_selected.connect(_select_projection)
|
||||
view.eyes_swapped.connect(func(): material.set_shader_parameter("swap_eyes", not material.get_shader_parameter("swap_eyes")))
|
||||
view.status.connect(func(message: String): status.text = message)
|
||||
view.back_requested.connect(func():
|
||||
shell.heading.text = previous_heading
|
||||
if playing: _show_playback()
|
||||
elif not api.token.is_empty(): _show_library()
|
||||
else: _show_login())
|
||||
page.add_child(view)
|
||||
status.text = "Changes take effect as you make them."
|
||||
|
||||
func _preserve_browsing_page() -> void:
|
||||
for child in page.get_children():
|
||||
if child is LibraryView or child is DetailsView:
|
||||
browsing_return_page = child
|
||||
browsing_return_heading = shell.heading.text
|
||||
browsing_return_status = status.text
|
||||
child.hide()
|
||||
child.reparent(viewport)
|
||||
_clear_page(true)
|
||||
|
||||
func _restore_browsing_page() -> void:
|
||||
if not is_instance_valid(browsing_return_page):
|
||||
_show_library()
|
||||
return
|
||||
var previous := browsing_return_page
|
||||
browsing_return_page = null
|
||||
_clear_page()
|
||||
previous.reparent(page)
|
||||
previous.show()
|
||||
shell.heading.text = browsing_return_heading
|
||||
status.text = browsing_return_status
|
||||
panel.show()
|
||||
|
||||
func _resize_screen() -> void:
|
||||
var panoramic := projection_mode != VideoProjection.Mode.SCREEN
|
||||
var aspect_ratio := 16.0 / 9.0 if panoramic else Stereo.aspect(video_size.x, video_size.y, mode)
|
||||
@@ -420,6 +629,8 @@ func _resize_screen() -> void:
|
||||
material.set_shader_parameter("top_bottom", mode == Stereo.Mode.TOP_BOTTOM)
|
||||
player_overlay.fit_screen(dimensions.x, aspect_ratio, curvature)
|
||||
world.player_loading.fit_screen(dimensions.x, curvature)
|
||||
world.skip_screen_size = Vector3(dimensions.x, aspect_ratio, curvature)
|
||||
subtitle_overlay.fit_screen(world.skip_screen_size, settings)
|
||||
|
||||
func _select_projection(value: int) -> void:
|
||||
projection_mode = value
|
||||
@@ -446,9 +657,8 @@ func _recenter() -> void:
|
||||
func _recenter_video() -> void:
|
||||
screen.global_basis = camera.global_basis
|
||||
theatre.recenter(camera.global_transform)
|
||||
if world.panoramic:
|
||||
world.panorama.global_position = camera.global_position
|
||||
world.panorama.global_rotation = Vector3(0.0, camera.global_rotation.y, 0.0)
|
||||
world.panorama.global_position = camera.global_position
|
||||
world.panorama.global_rotation = Vector3(0.0, camera.global_rotation.y, 0.0)
|
||||
_position_screen()
|
||||
_resize_screen()
|
||||
|
||||
@@ -460,12 +670,18 @@ func _position_screen() -> void:
|
||||
else:
|
||||
screen.global_position = camera.global_position - screen.global_basis.z * settings.distance + screen.global_basis.y * settings.height
|
||||
|
||||
func _update_screen_follow() -> void:
|
||||
if not settings.follow_screen or settings.environment not in ["Void", "Passthrough"]: return
|
||||
if projection_mode != VideoProjection.Mode.SCREEN: return
|
||||
screen.global_basis = camera.global_basis
|
||||
_position_screen()
|
||||
|
||||
func _toggle_menu() -> void:
|
||||
if not get_viewport().use_xr: return
|
||||
if autoplay_pending:
|
||||
_show_library()
|
||||
return
|
||||
if playing:
|
||||
if playing or photo_active:
|
||||
if player_overlay.visible: player_overlay.hide()
|
||||
else: _show_playback()
|
||||
else: panel.visible = not panel.visible
|
||||
@@ -474,7 +690,6 @@ func _skip(amount: int) -> void:
|
||||
_seek_to(int(transcode_offset / 10000) + video.position_ms() + amount)
|
||||
|
||||
func _duration_ms() -> int:
|
||||
if calibration: return video.duration_ms()
|
||||
var ticks := int(session.source.get("RunTimeTicks", 0))
|
||||
if ticks <= 0: ticks = int(session.item.get("RunTimeTicks", 0))
|
||||
if ticks > 0: return int(ticks / 10000)
|
||||
@@ -482,13 +697,26 @@ func _duration_ms() -> int:
|
||||
return duration + int(transcode_offset / 10000) if duration > 0 else 0
|
||||
|
||||
func _update_timeline() -> void:
|
||||
if autoplay_pending: return
|
||||
if autoplay_pending or photo_active: return
|
||||
player_overlay.update_time(int(transcode_offset / 10000) + video.position_ms(), _duration_ms(), decoder_ready and not seeking, video.paused)
|
||||
var buffered := video.buffered_position_ms() if decoder_ready and not seeking else -1
|
||||
player_overlay.timeline.update_buffer(buffered + int(transcode_offset / 10000) if buffered >= 0 else -1, _duration_ms(), decoder_ready and not seeking)
|
||||
world.player_skip_popup.update_segment(_active_segment())
|
||||
_update_loading()
|
||||
|
||||
func _active_segment() -> Dictionary:
|
||||
if not playing or not decoder_ready or seeking: return {}
|
||||
return media_segments.active(int(transcode_offset / 10000) + video.position_ms(), _duration_ms())
|
||||
|
||||
func _skip_segment() -> void:
|
||||
var segment := _active_segment()
|
||||
if not segment.is_empty(): _seek_to(int(segment.target_ms))
|
||||
|
||||
func _update_loading() -> void:
|
||||
if autoplay_pending: return
|
||||
if playing and (not decoder_ready or seeking):
|
||||
if photo_active and photo_loading:
|
||||
world.player_loading.show_photo_loading()
|
||||
elif playing and (not decoder_ready or seeking):
|
||||
world.player_loading.show_loading()
|
||||
elif playing and video.is_buffering():
|
||||
world.player_loading.show_buffering()
|
||||
@@ -498,7 +726,7 @@ func _update_loading() -> void:
|
||||
func _seek_to(milliseconds: int) -> void:
|
||||
if not playing or seeking or not decoder_ready: return
|
||||
var target := clampi(milliseconds, 0, maxi(0, _duration_ms() - 1000))
|
||||
if calibration or session.method != "Transcode" or session.is_hls():
|
||||
if session.method == "DirectPlay" or session.is_hls():
|
||||
video.seek(target)
|
||||
return
|
||||
seeking = true
|
||||
@@ -512,9 +740,16 @@ func _seek_to(milliseconds: int) -> void:
|
||||
await session.report("Stopped")
|
||||
if generation != countdown_generation: return
|
||||
session.position_ticks = target * 10000
|
||||
await session.open(session.item, true)
|
||||
await session.open(session.item, session.method == "Transcode", true)
|
||||
|
||||
func _stop(show_library: bool = true, completed: bool = false) -> void:
|
||||
session.cancel_pending()
|
||||
subtitles.clear()
|
||||
subtitles.delay_ms = 0
|
||||
subtitle_overlay.text = ""
|
||||
_close_photo()
|
||||
media_segments.clear()
|
||||
world.player_skip_popup.update_segment({})
|
||||
countdown_generation += 1
|
||||
_cancel_countdown()
|
||||
world.player_loading.hide()
|
||||
@@ -532,12 +767,6 @@ func _stop(show_library: bool = true, completed: bool = false) -> void:
|
||||
|
||||
func _ended() -> void:
|
||||
if not playing: return
|
||||
if calibration:
|
||||
await _stop(false)
|
||||
calibration = false
|
||||
_show_login()
|
||||
if get_viewport().use_xr: panel.show()
|
||||
return
|
||||
var previous := session.item.duplicate(true)
|
||||
var generation := countdown_generation + 1
|
||||
await _stop(false, true)
|
||||
@@ -580,9 +809,6 @@ func _cancel_countdown() -> void:
|
||||
|
||||
func _video_failed(message: String) -> void:
|
||||
status.text = message
|
||||
if calibration:
|
||||
_error(message)
|
||||
return
|
||||
if video.available() and playing:
|
||||
decoder_ready = false
|
||||
_update_timeline()
|
||||
@@ -591,6 +817,10 @@ func _video_failed(message: String) -> void:
|
||||
await session.retry_transcode()
|
||||
|
||||
func _error(message: String) -> void:
|
||||
subtitles.clear()
|
||||
subtitle_overlay.text = ""
|
||||
media_segments.clear()
|
||||
world.player_skip_popup.update_segment({})
|
||||
countdown_generation += 1
|
||||
_cancel_countdown()
|
||||
world.player_loading.hide()
|
||||
@@ -618,8 +848,10 @@ func _sign_out() -> void:
|
||||
_show_login()
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
_update_screen_follow()
|
||||
if not playing: return
|
||||
_update_loading()
|
||||
subtitle_overlay.text = subtitles.text_at(int(transcode_offset / 10000) + video.position_ms()) if decoder_ready and not seeking else ""
|
||||
timeline_elapsed += delta
|
||||
if timeline_elapsed >= 0.25:
|
||||
timeline_elapsed = 0.0
|
||||
@@ -634,20 +866,56 @@ func _process(delta: float) -> void:
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE: _toggle_menu()
|
||||
|
||||
func _show_tracks() -> void:
|
||||
if not playing or photo_active: return
|
||||
_preserve_browsing_page()
|
||||
panel.show()
|
||||
shell.heading.text = "Audio and subtitles"
|
||||
var view := TracksView.new()
|
||||
view.session = session
|
||||
view.subtitles = subtitles
|
||||
view.settings = settings
|
||||
view.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
view.closed.connect(_show_playback)
|
||||
view.appearance_changed.connect(_resize_screen)
|
||||
view.audio_selected.connect(_select_audio)
|
||||
view.subtitle_selected.connect(_select_subtitle)
|
||||
page.add_child(view)
|
||||
status.text = "Audio changes briefly restart playback at the current position."
|
||||
|
||||
func _calibrate() -> void:
|
||||
transcode_offset = 0
|
||||
func _select_subtitle(index: int) -> void:
|
||||
if not playing or seeking: return
|
||||
var burn := false
|
||||
for stream in session.source.get("MediaStreams", []):
|
||||
if stream.get("Type", "") == "Subtitle" and int(stream.Index) == index:
|
||||
burn = not SubtitleTrack.supported(stream)
|
||||
if burn and session.active_playback_mode == "original": return
|
||||
var restart := burn or session.burn_subtitles
|
||||
session.subtitle_index = index
|
||||
session.burn_subtitles = burn
|
||||
if burn:
|
||||
subtitles.clear()
|
||||
subtitles.index = index
|
||||
subtitles.message = "Picture subtitles are burned into the video. Text adjustments do not apply."
|
||||
subtitles.changed.emit()
|
||||
else:
|
||||
subtitles.select(api, str(session.item.Id), session.media_source_id, index)
|
||||
if restart: await _restart_tracks()
|
||||
else: session.report("Progress", video.paused)
|
||||
|
||||
func _select_audio(index: int) -> void:
|
||||
if not playing or seeking or session.active_playback_mode == "original": return
|
||||
session.audio_index = index
|
||||
await _restart_tracks()
|
||||
|
||||
func _restart_tracks() -> void:
|
||||
seeking = true
|
||||
seek_paused = video.paused
|
||||
decoder_ready = false
|
||||
print("Jellyframe: calibration requested")
|
||||
calibration = true
|
||||
mode = Stereo.Mode.HALF_SBS
|
||||
video_size = Vector2i(1920, 1080)
|
||||
_resize_screen()
|
||||
_recenter()
|
||||
print("Jellyframe: calibration screen positioned")
|
||||
screen.show()
|
||||
playing = true
|
||||
status.text = "Close one eye at a time. Check that LEFT appears in your left eye and RIGHT in your right eye."
|
||||
_update_timeline()
|
||||
await video.open(video.bridge.calibration_url(), "", 0)
|
||||
if get_viewport().use_xr: panel.hide()
|
||||
var current := countdown_generation
|
||||
session.position_ticks = transcode_offset + video.position_ms() * 10000
|
||||
video.stop()
|
||||
await session.report("Stopped")
|
||||
if current != countdown_generation: return
|
||||
await session.open(session.item, false, true)
|
||||
if current == countdown_generation and playing: _show_tracks()
|
||||
@@ -2,10 +2,13 @@ class_name AndroidVideo
|
||||
extends VideoBackend
|
||||
|
||||
var texture: ExternalTexture
|
||||
var playback_generation := 0
|
||||
var stream_open := false
|
||||
var frame_visible := false
|
||||
|
||||
func _ready() -> void:
|
||||
if not Engine.has_singleton("JellyframeVideo"): return
|
||||
bridge = Engine.get_singleton("JellyframeVideo")
|
||||
if not Engine.has_singleton("JellyXRVideo"): return
|
||||
bridge = Engine.get_singleton("JellyXRVideo")
|
||||
bridge.connect("prepared", func(): prepared.emit())
|
||||
bridge.connect("completed", func(): ended.emit())
|
||||
bridge.connect("failed", func(message: String): failed.emit(message))
|
||||
@@ -19,20 +22,27 @@ func has_android_services() -> bool:
|
||||
return available()
|
||||
|
||||
func unavailable_message() -> String:
|
||||
return "Video playback requires the Jellyframe Android plugin."
|
||||
return "Video playback requires the jellyXR Android plugin."
|
||||
|
||||
func open(url: String, authorization: String, start_ms: int) -> void:
|
||||
print("Jellyframe: opening video")
|
||||
playback_generation += 1
|
||||
var opening := playback_generation
|
||||
_clear_frame()
|
||||
if not available():
|
||||
failed.emit(unavailable_message())
|
||||
return
|
||||
material.set_shader_parameter("video_texture", texture)
|
||||
print("Jellyframe: external texture assigned")
|
||||
await RenderingServer.frame_post_draw
|
||||
print("Jellyframe: starting decoder, texture ID ", texture.get_external_texture_id())
|
||||
if opening != playback_generation: return
|
||||
bridge.open_video(texture.get_external_texture_id(), url, authorization, start_ms)
|
||||
stream_open = true
|
||||
paused = false
|
||||
|
||||
func _clear_frame() -> void:
|
||||
stream_open = false
|
||||
frame_visible = false
|
||||
if material != null: material.set_shader_parameter("video_ready", false)
|
||||
|
||||
func toggle_pause() -> void:
|
||||
if available():
|
||||
paused = not paused
|
||||
@@ -51,10 +61,16 @@ func is_buffering() -> bool:
|
||||
return bridge.is_buffering() if available() else false
|
||||
|
||||
func stop() -> void:
|
||||
playback_generation += 1
|
||||
_clear_frame()
|
||||
if available(): bridge.stop_video()
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if not available() or material == null: return
|
||||
var ready: bool = stream_open and bridge.has_video_frame()
|
||||
if ready != frame_visible:
|
||||
frame_visible = ready
|
||||
material.set_shader_parameter("video_ready", ready)
|
||||
var matrix: PackedFloat32Array = bridge.texture_transform()
|
||||
if matrix.size() == 16:
|
||||
material.set_shader_parameter("texture_transform", Projection(Vector4(matrix[0], matrix[1], matrix[2], matrix[3]), Vector4(matrix[4], matrix[5], matrix[6], matrix[7]), Vector4(matrix[8], matrix[9], matrix[10], matrix[11]), Vector4(matrix[12], matrix[13], matrix[14], matrix[15])))
|
||||
@@ -2,7 +2,7 @@ class_name PcVideo
|
||||
extends VideoBackend
|
||||
|
||||
func available() -> bool:
|
||||
return get_viewport().use_xr and ClassDB.class_exists("JellyframeMpv")
|
||||
return get_viewport().use_xr and ClassDB.class_exists("JellyXRMpv")
|
||||
|
||||
func unavailable_message() -> String:
|
||||
if not get_viewport().use_xr:
|
||||
@@ -12,21 +12,35 @@ func unavailable_message() -> String:
|
||||
return "The video plugin is missing. Extract all files from the Windows build, including the DLLs."
|
||||
|
||||
func open(url: String, authorization: String, start_ms: int) -> void:
|
||||
_set_texture(null)
|
||||
if not available():
|
||||
failed.emit(unavailable_message())
|
||||
return
|
||||
if bridge == null:
|
||||
bridge = ClassDB.instantiate("JellyframeMpv")
|
||||
bridge = ClassDB.instantiate("JellyXRMpv")
|
||||
add_child(bridge)
|
||||
bridge.prepared.connect(func(): prepared.emit())
|
||||
bridge.ended.connect(func(): ended.emit())
|
||||
bridge.failed.connect(func(message: String): failed.emit(message))
|
||||
bridge.size_changed.connect(func(w: int, h: int): size_changed.emit(w, h))
|
||||
bridge.texture_changed.connect(func(texture: Texture2D): material.set_shader_parameter("video_texture", texture))
|
||||
bridge.texture_changed.connect(_set_texture)
|
||||
paused = false
|
||||
var error: String = bridge.open_video(url, authorization, start_ms)
|
||||
if not error.is_empty(): failed.emit(error)
|
||||
|
||||
func _set_texture(texture: Texture2D) -> void:
|
||||
if material == null: return
|
||||
material.set_shader_parameter("video_texture", texture)
|
||||
material.set_shader_parameter("direct_yuv", false)
|
||||
var parameters: Dictionary = {}
|
||||
if texture != null and bridge != null:
|
||||
parameters = bridge.video_shader_parameters()
|
||||
for parameter in parameters:
|
||||
material.set_shader_parameter(parameter, parameters[parameter])
|
||||
if parameters.is_empty():
|
||||
for parameter in ["y_plane", "u_plane", "v_plane"]:
|
||||
material.set_shader_parameter(parameter, null)
|
||||
|
||||
func toggle_pause() -> void:
|
||||
if bridge != null:
|
||||
paused = not paused
|
||||
@@ -42,5 +56,6 @@ func duration_ms() -> int:
|
||||
return bridge.duration_ms() if bridge != null else 0
|
||||
|
||||
func stop() -> void:
|
||||
_set_texture(null)
|
||||
if bridge != null: bridge.stop_video()
|
||||
paused = false
|
||||
@@ -0,0 +1,102 @@
|
||||
class_name SubtitleTrack
|
||||
extends Node
|
||||
|
||||
signal changed
|
||||
var cues: Array[Dictionary] = []
|
||||
var index := -1
|
||||
var delay_ms := 0
|
||||
var message := "Subtitles off"
|
||||
var request: HTTPRequest
|
||||
var generation := 0
|
||||
|
||||
static func supported(stream: Dictionary) -> bool:
|
||||
return stream.get("IsTextSubtitleStream", false) or str(stream.get("Codec", "")).to_lower() in ["srt", "subrip", "vtt", "webvtt", "ass", "ssa", "mov_text", "ttml"]
|
||||
|
||||
func clear() -> void:
|
||||
generation += 1
|
||||
if is_instance_valid(request):
|
||||
request.cancel_request()
|
||||
request.queue_free()
|
||||
request = null
|
||||
cues.clear()
|
||||
index = -1
|
||||
message = "Subtitles off"
|
||||
changed.emit()
|
||||
|
||||
func select(api: JellyfinClient, item_id: String, source_id: String, selected: int) -> void:
|
||||
clear()
|
||||
index = selected
|
||||
if selected < 0: return
|
||||
var current := generation
|
||||
message = "Loading subtitles..."
|
||||
changed.emit()
|
||||
request = HTTPRequest.new()
|
||||
request.timeout = 120.0
|
||||
request.body_size_limit = 16 * 1024 * 1024
|
||||
add_child(request)
|
||||
var path := "/Videos/%s/%s/Subtitles/%d/Stream.vtt?CopyTimestamps=true&AddVttTimeMap=false" % [item_id.uri_encode(), source_id.uri_encode(), selected]
|
||||
var error := request.request(api.server + path, PackedStringArray(["Authorization: " + api.authorization()]))
|
||||
if error != OK:
|
||||
message = "Could not request subtitles. Select the track to retry."
|
||||
request.queue_free()
|
||||
request = null
|
||||
changed.emit()
|
||||
return
|
||||
var response: Array = await request.request_completed
|
||||
if current != generation: return
|
||||
request.queue_free()
|
||||
request = null
|
||||
if response[0] != HTTPRequest.RESULT_SUCCESS or int(response[1]) != 200:
|
||||
message = "Could not load subtitles. Select the track to retry."
|
||||
else:
|
||||
cues = parse(response[3].get_string_from_utf8())
|
||||
message = "Subtitles ready" if not cues.is_empty() else "No readable subtitle cues were returned."
|
||||
changed.emit()
|
||||
|
||||
static func timestamp(value: String) -> int:
|
||||
var parts := value.replace(",", ".").split(":")
|
||||
if parts.size() not in [2, 3]: return -1
|
||||
var seconds := 0.0
|
||||
for part in parts:
|
||||
if not part.is_valid_float(): return -1
|
||||
seconds = seconds * 60.0 + float(part)
|
||||
return roundi(seconds * 1000.0)
|
||||
|
||||
static func parse(document: String) -> Array[Dictionary]:
|
||||
var result: Array[Dictionary] = []
|
||||
var tags := RegEx.new()
|
||||
tags.compile("<[^>]*>")
|
||||
for block in document.replace("\r", "").split("\n\n", false):
|
||||
var lines := block.strip_edges().split("\n")
|
||||
if lines[0].begins_with("NOTE") or lines[0] in ["STYLE", "REGION"]: continue
|
||||
for line_index in range(lines.size()):
|
||||
if not " --> " in lines[line_index]: continue
|
||||
var times := lines[line_index].split(" --> ")
|
||||
var start := timestamp(times[0].strip_edges())
|
||||
var end := timestamp(times[1].strip_edges().get_slice(" ", 0))
|
||||
if start < 0 or end <= start: break
|
||||
var text := tags.sub("\n".join(lines.slice(line_index + 1)), "", true)
|
||||
text = text.replace("<", "<").replace(">", ">").replace(" ", " ").replace(""", '"').replace("&", "&")
|
||||
result.append({"start": start, "end": end, "text": text})
|
||||
break
|
||||
result.sort_custom(func(a: Dictionary, b: Dictionary): return a.start < b.start)
|
||||
var latest_end := 0
|
||||
for cue in result:
|
||||
latest_end = maxi(latest_end, cue.end)
|
||||
cue["latest_end"] = latest_end
|
||||
return result
|
||||
|
||||
func text_at(position_ms: int) -> String:
|
||||
var time := position_ms - delay_ms
|
||||
var low := 0
|
||||
var high := cues.size()
|
||||
while low < high:
|
||||
var middle := (low + high) / 2
|
||||
if int(cues[middle].start) <= time: low = middle + 1
|
||||
else: high = middle
|
||||
var active := PackedStringArray()
|
||||
var cursor := low - 1
|
||||
while cursor >= 0 and int(cues[cursor].latest_end) > time:
|
||||
if int(cues[cursor].end) > time: active.insert(0, cues[cursor].text)
|
||||
cursor -= 1
|
||||
return "\n".join(active)
|
||||
@@ -0,0 +1 @@
|
||||
uid://nv45euhbfw57
|
||||
@@ -34,6 +34,14 @@ func position_ms() -> int:
|
||||
func duration_ms() -> int:
|
||||
return 0
|
||||
|
||||
func buffered_position_ms() -> int:
|
||||
if bridge == null: return -1
|
||||
if bridge.has_method("buffered_position_ms"): return bridge.buffered_position_ms()
|
||||
if bridge.has_method("playback_diagnostics"):
|
||||
var values: Dictionary = bridge.playback_diagnostics()
|
||||
if values.has("read_ahead_ms"): return position_ms() + int(values.read_ahead_ms)
|
||||
return -1
|
||||
|
||||
func is_buffering() -> bool:
|
||||
return bridge != null and bridge.has_method("is_buffering") and bridge.is_buffering()
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ class_name DetailsView
|
||||
extends VBoxContainer
|
||||
|
||||
signal play_requested(item: Dictionary)
|
||||
signal play_from_start_requested(item: Dictionary)
|
||||
signal version_requested(item: Dictionary)
|
||||
signal back_requested
|
||||
|
||||
var item: Dictionary
|
||||
@@ -12,12 +14,18 @@ var scroll: ScrollContainer
|
||||
var body: VBoxContainer
|
||||
var series_browser: SeriesBrowser
|
||||
var play_button: Button
|
||||
var play_from_start_button: Button
|
||||
var version_button: Button
|
||||
var watched_control: VBoxContainer
|
||||
var resume_label: Label
|
||||
var back_button: Button
|
||||
var title_label: Label
|
||||
var overview_label: Label
|
||||
var poster: TextureRect
|
||||
var backdrop: TextureRect
|
||||
var cast_carousel: CastCarousel
|
||||
var series_playback_status: Label
|
||||
var series_playback_loading := false
|
||||
|
||||
func _ready() -> void:
|
||||
add_theme_constant_override("separation", 18)
|
||||
@@ -26,8 +34,10 @@ func _ready() -> void:
|
||||
artwork.api = api
|
||||
add_child(artwork)
|
||||
var navigation := HBoxContainer.new()
|
||||
navigation.add_theme_constant_override("separation", 12)
|
||||
add_child(navigation)
|
||||
back_button = Widgets.button("Back to library", func(): back_requested.emit(), navigation)
|
||||
back_button.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
|
||||
scroll = ScrollContainer.new()
|
||||
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
||||
@@ -44,8 +54,11 @@ func _ready() -> void:
|
||||
series_browser.api = api
|
||||
series_browser.artwork = artwork
|
||||
series_browser.series_id = str(item.Id)
|
||||
series_browser.series_item = item
|
||||
series_browser.navigation_host = navigation
|
||||
series_browser.selected_season = initial_season
|
||||
series_browser.play_requested.connect(func(episode: Dictionary): play_requested.emit(episode))
|
||||
series_browser.version_requested.connect(func(episode: Dictionary): version_requested.emit(episode))
|
||||
series_browser.section_changed.connect(_scroll_to_series)
|
||||
body.add_child(series_browser)
|
||||
_build_people()
|
||||
@@ -107,13 +120,14 @@ func _build_header() -> void:
|
||||
_build_playback(summary)
|
||||
|
||||
func _build_playback(parent: VBoxContainer) -> void:
|
||||
if item.get("Type", "") == "Series":
|
||||
return
|
||||
var is_series: bool = item.get("Type", "") == "Series"
|
||||
var user: Dictionary = item.get("UserData", {}) if item.get("UserData") else {}
|
||||
var position := int(user.get("PlaybackPositionTicks", 0))
|
||||
var actions := HBoxContainer.new()
|
||||
var actions := HFlowContainer.new()
|
||||
actions.add_theme_constant_override("h_separation", 12)
|
||||
actions.add_theme_constant_override("v_separation", 12)
|
||||
parent.add_child(actions)
|
||||
play_button = Widgets.button("Resume" if position > 0 else "Play movie", func(): play_requested.emit(item), actions)
|
||||
play_button = Widgets.button("Play series" if is_series else ("Resume" if position > 0 else "Play movie"), _play_item, actions)
|
||||
play_button.custom_minimum_size = Vector2(200, 64)
|
||||
play_button.icon = preload("res://assets/icons/material/play_arrow.svg")
|
||||
play_button.add_theme_constant_override("icon_max_width", 28)
|
||||
@@ -125,8 +139,67 @@ func _build_playback(parent: VBoxContainer) -> void:
|
||||
focus.set_border_width_all(3)
|
||||
focus.border_color = Color("071c20")
|
||||
play_button.add_theme_stylebox_override("focus", focus)
|
||||
if is_series:
|
||||
play_button.tooltip_text = "Start the first episode or continue where you left off."
|
||||
series_playback_status = _muted("", parent, 20)
|
||||
series_playback_status.hide()
|
||||
return
|
||||
if position > 0:
|
||||
_muted("Continue from " + DetailsMetadata.duration(position), parent, 20)
|
||||
play_from_start_button = Widgets.button("Play from start", func(): play_from_start_requested.emit(item), actions)
|
||||
play_from_start_button.custom_minimum_size = Vector2(200, 64)
|
||||
resume_label = _muted("Continue from " + DetailsMetadata.duration(position), parent, 20)
|
||||
var secondary_actions := HFlowContainer.new()
|
||||
secondary_actions.add_theme_constant_override("h_separation", 12)
|
||||
secondary_actions.add_theme_constant_override("v_separation", 12)
|
||||
parent.add_child(secondary_actions)
|
||||
if item.get("Type", "") in ["Movie", "Episode"]:
|
||||
watched_control = preload("res://scripts/ui/watched_control.gd").new()
|
||||
watched_control.api = api
|
||||
watched_control.item = item
|
||||
watched_control.changed.connect(_refresh_playback_state)
|
||||
secondary_actions.add_child(watched_control)
|
||||
watched_control.button.custom_minimum_size.y = 64
|
||||
var sources: Array = item.get("MediaSources", []) if item.get("MediaSources") else []
|
||||
if sources.size() > 1:
|
||||
version_button = Widgets.button("Choose version", func(): version_requested.emit(item), secondary_actions)
|
||||
version_button.custom_minimum_size = Vector2(200, 64)
|
||||
version_button.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
|
||||
|
||||
func _play_item() -> void:
|
||||
if item.get("Type", "") != "Series":
|
||||
play_requested.emit(item)
|
||||
return
|
||||
if series_playback_loading: return
|
||||
series_playback_loading = true
|
||||
play_button.disabled = true
|
||||
play_button.text = "Loading episode..."
|
||||
series_playback_status.hide()
|
||||
var result := await api.series_playback(str(item.Id))
|
||||
if not is_inside_tree(): return
|
||||
series_playback_loading = false
|
||||
play_button.disabled = false
|
||||
play_button.text = "Play series"
|
||||
if not result.ok:
|
||||
series_playback_status.text = "Could not load an episode. Try again."
|
||||
series_playback_status.show()
|
||||
return
|
||||
var episodes: Array = result.data.get("Items", [])
|
||||
if episodes.is_empty():
|
||||
series_playback_status.text = "No episodes available for this series."
|
||||
series_playback_status.show()
|
||||
return
|
||||
var episode: Dictionary = episodes[0].duplicate(true)
|
||||
episode["Type"] = "Episode"
|
||||
episode["SeriesId"] = str(item.Id)
|
||||
play_requested.emit(episode)
|
||||
|
||||
func _refresh_playback_state() -> void:
|
||||
var position := int(item.UserData.get("PlaybackPositionTicks", 0))
|
||||
play_button.text = "Resume" if position > 0 else ("Play episode" if item.get("Type") == "Episode" else "Play movie")
|
||||
if is_instance_valid(play_from_start_button): play_from_start_button.visible = position > 0
|
||||
if is_instance_valid(resume_label):
|
||||
resume_label.visible = position > 0
|
||||
resume_label.text = "Continue from " + DetailsMetadata.duration(position)
|
||||
|
||||
func _build_information() -> void:
|
||||
var section := VBoxContainer.new()
|
||||
|
||||
@@ -22,8 +22,6 @@ static func facts(item: Dictionary) -> Array[String]:
|
||||
if item.get("RunTimeTicks"): values.append(duration(int(item.RunTimeTicks)))
|
||||
if item.get("OfficialRating"): values.append(str(item.OfficialRating))
|
||||
if item.get("CommunityRating"): values.append("Rating %.1f / 10" % float(item.CommunityRating))
|
||||
var user: Dictionary = item.get("UserData", {}) if item.get("UserData") else {}
|
||||
if user.get("Played", false): values.append("Watched")
|
||||
return values
|
||||
|
||||
static func streams(item: Dictionary) -> Dictionary:
|
||||
|
||||
@@ -2,10 +2,16 @@ class_name EpisodeCard
|
||||
extends PanelContainer
|
||||
|
||||
signal chosen(item: Dictionary)
|
||||
signal version_requested(item: Dictionary)
|
||||
|
||||
var item: Dictionary
|
||||
var artwork: ArtworkLoader
|
||||
var api: JellyfinClient
|
||||
var watched_control: VBoxContainer
|
||||
var watched_badge: Control
|
||||
var facts_label: Label
|
||||
var play_button: Button
|
||||
var version_button: Button
|
||||
var thumbnail: TextureRect
|
||||
var title_label: Label
|
||||
|
||||
@@ -42,14 +48,37 @@ func _ready() -> void:
|
||||
title_label = _label(DetailsMetadata.title(item, "Episode"), text, 26)
|
||||
var facts := DetailsMetadata.facts(item)
|
||||
var user: Dictionary = item.get("UserData", {}) if item.get("UserData") else {}
|
||||
watched_badge = Widgets.watched_badge(frame)
|
||||
watched_badge.visible = user.get("Played", false)
|
||||
var position := int(user.get("PlaybackPositionTicks", 0))
|
||||
if position > 0: facts.append("Continue from " + DetailsMetadata.duration(position))
|
||||
if not facts.is_empty():
|
||||
_label(" / ".join(facts), text, 20).add_theme_color_override("font_color", Color("b0bbce"))
|
||||
facts_label = _label(" / ".join(facts), text, 20)
|
||||
facts_label.add_theme_color_override("font_color", Color("b0bbce"))
|
||||
_label(str(item.get("Overview") if item.get("Overview") else "No description available."), text, 22)
|
||||
play_button = Widgets.button("Resume episode" if position > 0 else "Play episode", func(): chosen.emit(item), image_column)
|
||||
play_button.custom_minimum_size = Vector2(190, 56)
|
||||
play_button.accessibility_name = play_button.text + " " + title_label.text
|
||||
if user.get("Played", false):
|
||||
play_button.accessibility_name += ", watched"
|
||||
var sources: Array = item.get("MediaSources", []) if item.get("MediaSources") else []
|
||||
if sources.size() > 1:
|
||||
version_button = Widgets.button("Choose version", func(): version_requested.emit(item), image_column)
|
||||
version_button.accessibility_name = "Choose version of " + title_label.text
|
||||
watched_control = preload("res://scripts/ui/watched_control.gd").new()
|
||||
watched_control.api = api
|
||||
watched_control.item = item
|
||||
watched_control.changed.connect(_refresh_user_data)
|
||||
text.add_child(watched_control)
|
||||
|
||||
func _refresh_user_data() -> void:
|
||||
var user: Dictionary = item.UserData
|
||||
var position := int(user.get("PlaybackPositionTicks", 0))
|
||||
var facts := DetailsMetadata.facts(item)
|
||||
if position > 0: facts.append("Continue from " + DetailsMetadata.duration(position))
|
||||
facts_label.text = " / ".join(facts)
|
||||
watched_badge.visible = user.get("Played", false)
|
||||
play_button.text = "Resume episode" if position > 0 else "Play episode"
|
||||
play_button.accessibility_name = play_button.text + " " + title_label.text + (", watched" if user.get("Played", false) else "")
|
||||
|
||||
func _label(value: String, parent: Node, font_size: int) -> Label:
|
||||
var label := Widgets.label(value, parent, font_size)
|
||||
|
||||
@@ -27,7 +27,7 @@ func _ready() -> void:
|
||||
add_child(artwork)
|
||||
var tools := HBoxContainer.new()
|
||||
add_child(tools)
|
||||
field = Widgets.input("Search " + ("movies and shows" if library_id.is_empty() else library_name), tools)
|
||||
field = Widgets.input("Search " + library_name, tools)
|
||||
field.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
field.text_submitted.connect(refresh)
|
||||
Widgets.button("Search", func(): refresh(field.text), tools)
|
||||
@@ -80,9 +80,9 @@ func _more(current: int) -> void:
|
||||
return
|
||||
var items: Array = result.data.get("Items", [])
|
||||
if offset == 0:
|
||||
if search.is_empty() and not items.is_empty():
|
||||
if search.is_empty() and items.any(func(item: Dictionary): return item.get("Type", "") != "Photo"):
|
||||
var hero := LibraryHero.new()
|
||||
hero.item = _choose_hero(items)
|
||||
hero.item = _choose_hero(items.filter(func(item: Dictionary): return item.get("Type", "") != "Photo"))
|
||||
hero.artwork = artwork
|
||||
hero.chosen.connect(func(item: Dictionary): selected.emit(item))
|
||||
content.add_child(hero)
|
||||
|
||||
@@ -32,6 +32,8 @@ func _ready() -> void:
|
||||
var poster := TextureRect.new()
|
||||
poster.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
poster.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED
|
||||
if item.get("Type", "") == "Photo":
|
||||
poster.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
poster.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
image_frame.add_child(poster)
|
||||
artwork.fetch(poster, item)
|
||||
@@ -41,8 +43,9 @@ func _ready() -> void:
|
||||
title.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
var user_data: Dictionary = item.get("UserData", {}) if item.get("UserData") else {}
|
||||
var detail := DetailsMetadata.year(item)
|
||||
if item.get("Type", "") == "Photo": detail = ""
|
||||
if user_data.get("Played", false):
|
||||
_watched_badge(image_frame)
|
||||
Widgets.watched_badge(image_frame)
|
||||
accessibility_name = tooltip_text + ", watched"
|
||||
if item.get("Type", "") == "Series": detail += " / Series"
|
||||
var subtitle := Widgets.label(detail.trim_prefix(" / "), body, 16)
|
||||
@@ -58,19 +61,3 @@ func _ready() -> void:
|
||||
bar.add_theme_stylebox_override("background", Widgets.surface(Color("29374b"), 2))
|
||||
bar.add_theme_stylebox_override("fill", Widgets.surface(Color("6de3cf"), 2))
|
||||
body.add_child(bar)
|
||||
|
||||
func _watched_badge(image_frame: PanelContainer) -> void:
|
||||
var overlay := Control.new()
|
||||
overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
image_frame.add_child(overlay)
|
||||
var badge := TextureRect.new()
|
||||
badge.texture = preload("res://assets/icons/watched.svg")
|
||||
badge.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
badge.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
badge.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
overlay.add_child(badge)
|
||||
badge.set_anchors_and_offsets_preset(Control.PRESET_TOP_RIGHT)
|
||||
badge.offset_left = -36
|
||||
badge.offset_top = 8
|
||||
badge.offset_right = -8
|
||||
badge.offset_bottom = 36
|
||||
@@ -22,7 +22,6 @@ func _connect_button(node: Node) -> void:
|
||||
if not button.pressed.is_connected(_play): button.pressed.connect(_play)
|
||||
|
||||
func _play() -> void:
|
||||
if OS.is_debug_build() and OS.get_name() == "Android": print("Jellyframe input: button activated")
|
||||
player.play()
|
||||
|
||||
func _click_sound() -> AudioStreamWAV:
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
class_name PhotoView
|
||||
extends Node
|
||||
|
||||
signal photo_ready(texture: Texture2D)
|
||||
signal failed(message: String)
|
||||
var api: JellyfinClient
|
||||
var item: Dictionary
|
||||
var request: HTTPRequest
|
||||
|
||||
static func stereo_mode(photo: Dictionary) -> Stereo.Mode:
|
||||
var source := {"Path": photo.get("Path", photo.get("Name", "")), "Video3DFormat": photo.get("Video3DFormat", "")}
|
||||
var detected := Stereo.detect(source)
|
||||
if detected != Stereo.Mode.MONO: return detected
|
||||
var pattern := RegEx.new()
|
||||
pattern.compile("(?i)(?:^|[ ._\\-])sbs(?:$|[ ._\\-])")
|
||||
return Stereo.Mode.FULL_SBS if pattern.search(str(source.Path).get_file()) else Stereo.Mode.MONO
|
||||
|
||||
func _ready() -> void:
|
||||
request = HTTPRequest.new()
|
||||
request.timeout = 30
|
||||
request.body_size_limit = 32 * 1024 * 1024
|
||||
request.request_completed.connect(_loaded)
|
||||
add_child(request)
|
||||
|
||||
func _load() -> void:
|
||||
var url := api.server + "/Items/%s/Images/Primary" % str(item.Id).uri_encode()
|
||||
url += api.query({"maxWidth": 3840, "maxHeight": 3840, "quality": 95, "format": "Jpg"})
|
||||
if request.request(url, ["Authorization: " + api.authorization()]) != OK:
|
||||
_failed("Could not start the photo request.")
|
||||
|
||||
func _loaded(result: int, code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
||||
if result != HTTPRequest.RESULT_SUCCESS:
|
||||
_failed("Could not load the photo. Check your connection and try again.")
|
||||
return
|
||||
if code != 200:
|
||||
_failed("Could not load the photo. Server returned HTTP %d." % code)
|
||||
return
|
||||
var image := Image.new()
|
||||
if image.load_jpg_from_buffer(body) != OK:
|
||||
_failed("The server returned an unreadable photo.")
|
||||
return
|
||||
photo_ready.emit(ImageTexture.create_from_image(image))
|
||||
|
||||
func _failed(text: String) -> void:
|
||||
failed.emit(text)
|
||||
|
||||
func _exit_tree() -> void:
|
||||
if is_instance_valid(request): request.cancel_request()
|
||||
@@ -0,0 +1 @@
|
||||
uid://wo7uui2jccph
|
||||
@@ -0,0 +1,28 @@
|
||||
extends RefCounted
|
||||
|
||||
static func snapshot(session: PlaybackSession, video: VideoBackend, playback_size := Vector2i.ZERO) -> Dictionary:
|
||||
var stream: Dictionary = {}
|
||||
for candidate in session.source.get("MediaStreams", []):
|
||||
if candidate.get("Type") == "Video":
|
||||
stream = candidate
|
||||
break
|
||||
var diagnostics: Dictionary = {}
|
||||
if video.bridge != null and video.bridge.has_method("playback_diagnostics"):
|
||||
diagnostics = video.bridge.playback_diagnostics()
|
||||
var width := int(stream.get("Width", 0))
|
||||
var height := int(stream.get("Height", 0))
|
||||
var fps := float(stream.get("AverageFrameRate", stream.get("RealFrameRate", 0.0)))
|
||||
var bitrate := int(session.source.get("Bitrate", 0))
|
||||
return {
|
||||
"Playback resolution": "%d x %d" % [playback_size.x, playback_size.y] if playback_size.x > 0 and playback_size.y > 0 else "Not reported",
|
||||
"Playback": session.method,
|
||||
"Source video": "%d x %d / %.3f fps" % [width, height, fps] if width > 0 else "Not reported",
|
||||
"Source codec": str(stream.get("Codec", "Not reported")).to_upper(),
|
||||
"Source bitrate": "%.2f Mbps" % (bitrate / 1000000.0) if bitrate > 0 else "Not reported",
|
||||
"Renderer / app FPS": "%s / %d" % [RenderingServer.get_current_rendering_method(), Engine.get_frames_per_second()],
|
||||
"Player state": "Buffering" if video.is_buffering() else ("Paused" if video.paused else "Playing"),
|
||||
"Video decoder": str(diagnostics.get("codec", "Platform decoder")),
|
||||
"Hardware decoding": str(diagnostics.get("hardware_device", "Not reported")),
|
||||
"Dropped frames": str(diagnostics.get("dropped_frames", "Not reported")),
|
||||
"Read ahead": "%.2f s" % (float(diagnostics.read_ahead_ms) / 1000.0) if diagnostics.has("read_ahead_ms") else "Not reported",
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://d146kg25o802p
|
||||
@@ -6,6 +6,7 @@ var slider: HSlider
|
||||
var elapsed_label: Label
|
||||
var duration_label: Label
|
||||
var dragging := false
|
||||
var buffered_bar: ProgressBar
|
||||
|
||||
func _ready() -> void:
|
||||
var times := HBoxContainer.new()
|
||||
@@ -13,13 +14,26 @@ func _ready() -> void:
|
||||
elapsed_label = Widgets.label("0:00", times, 20)
|
||||
elapsed_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
duration_label = Widgets.label("--:--", times, 20)
|
||||
var track_area := Control.new()
|
||||
track_area.custom_minimum_size.y = 56
|
||||
add_child(track_area)
|
||||
buffered_bar = ProgressBar.new()
|
||||
buffered_bar.show_percentage = false
|
||||
buffered_bar.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
buffered_bar.set_anchors_and_offsets_preset(Control.PRESET_TOP_WIDE)
|
||||
buffered_bar.offset_top = 23
|
||||
buffered_bar.offset_bottom = 33
|
||||
buffered_bar.add_theme_stylebox_override("background", Widgets.surface(Color("445268"), 5))
|
||||
buffered_bar.add_theme_stylebox_override("fill", Widgets.surface(Color("8998af"), 5))
|
||||
track_area.add_child(buffered_bar)
|
||||
slider = HSlider.new()
|
||||
slider.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
slider.custom_minimum_size.y = 56
|
||||
slider.step = 1000
|
||||
slider.scrollable = false
|
||||
slider.editable = false
|
||||
slider.tooltip_text = "Seek to a time"
|
||||
var track := Widgets.surface(Color("445268"), 5)
|
||||
var track := Widgets.surface(Color.TRANSPARENT, 5)
|
||||
track.content_margin_top = 5
|
||||
track.content_margin_bottom = 5
|
||||
slider.add_theme_stylebox_override("slider", track)
|
||||
@@ -27,7 +41,7 @@ func _ready() -> void:
|
||||
fill.bg_color = Color("6de3cf")
|
||||
slider.add_theme_stylebox_override("grabber_area", fill)
|
||||
slider.add_theme_stylebox_override("grabber_area_highlight", fill)
|
||||
add_child(slider)
|
||||
track_area.add_child(slider)
|
||||
slider.drag_started.connect(func(): dragging = true)
|
||||
slider.value_changed.connect(func(value: float):
|
||||
elapsed_label.text = format_time(int(value))
|
||||
@@ -48,6 +62,10 @@ func update_time(position_ms: int, duration_ms: int, ready: bool = true) -> void
|
||||
elapsed_label.text = format_time(maxi(0, position_ms))
|
||||
duration_label.text = format_time(duration_ms) if duration_ms > 0 else "--:--"
|
||||
|
||||
func update_buffer(buffered_ms: int, duration_ms: int, ready: bool) -> void:
|
||||
buffered_bar.max_value = maxi(1, duration_ms)
|
||||
buffered_bar.value = clampi(buffered_ms, 0, duration_ms) if ready and buffered_ms >= 0 and duration_ms > 0 else 0
|
||||
|
||||
static func format_time(milliseconds: int) -> String:
|
||||
var seconds := maxi(0, milliseconds / 1000)
|
||||
if seconds >= 3600: return "%d:%02d:%02d" % [seconds / 3600, (seconds / 60) % 60, seconds % 60]
|
||||
|
||||
@@ -33,9 +33,9 @@ func _ready() -> void:
|
||||
var material := StandardMaterial3D.new()
|
||||
material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||
material.no_depth_test = true
|
||||
material.render_priority = 1
|
||||
material.albedo_texture = viewport.get_texture()
|
||||
VrOverlayMaterial.apply_depth(material)
|
||||
material_override = material
|
||||
var panel := PanelContainer.new()
|
||||
panel.theme = Widgets.theme()
|
||||
@@ -62,6 +62,9 @@ func _ready() -> void:
|
||||
func show_loading() -> void:
|
||||
_show_waiting("Loading video...")
|
||||
|
||||
func show_photo_loading() -> void:
|
||||
_show_waiting("Loading photo...")
|
||||
|
||||
func show_buffering() -> void:
|
||||
_show_waiting("Buffering video...")
|
||||
|
||||
|
||||
@@ -12,6 +12,19 @@ var play_icon: Texture2D
|
||||
var pause_icon: Texture2D
|
||||
var tooltip: Label
|
||||
var hovered_control: Control
|
||||
var video_controls: Array[Button] = []
|
||||
var stop_button: Button
|
||||
var controls_spacer: Control
|
||||
var controls_row: HBoxContainer
|
||||
var photo_mode := false
|
||||
var previous_photo: Button
|
||||
var next_photo: Button
|
||||
var statistics_button: Button
|
||||
var statistics_panel: VBoxContainer
|
||||
var statistics_values: Dictionary = {}
|
||||
var statistics_provider: Callable
|
||||
var statistics_elapsed := 0.0
|
||||
var screen_dimensions := Vector3(3.6, 16.0 / 9.0, 0.0)
|
||||
|
||||
func _ready() -> void:
|
||||
play_icon = load("res://assets/icons/material/play_arrow.svg")
|
||||
@@ -25,6 +38,7 @@ func _ready() -> void:
|
||||
material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||
material.albedo_texture = viewport.get_texture()
|
||||
VrOverlayMaterial.apply_depth(material)
|
||||
material_override = material
|
||||
var panel := PanelContainer.new()
|
||||
panel.theme = Widgets.theme()
|
||||
@@ -45,27 +59,110 @@ func _ready() -> void:
|
||||
timeline.slider.mouse_exited.connect(_hide_tooltip.bind(timeline.slider))
|
||||
timeline.slider.focus_entered.connect(_show_tooltip.bind(timeline.slider))
|
||||
timeline.slider.focus_exited.connect(_hide_tooltip.bind(timeline.slider))
|
||||
var row := HBoxContainer.new()
|
||||
row.add_theme_constant_override("separation", 12)
|
||||
body.add_child(row)
|
||||
for entry in [["Pause", "pause", "pause"], ["Stop", "stop", "stop"], ["Back 10 seconds", "back", "replay_10"], ["Forward 30 seconds", "forward", "forward_30"], ["Settings", "settings", "settings"], ["Library", "library", "video_library"], ["Hide controls", "hide", "visibility_off"]]:
|
||||
var button := Widgets.button("", func(): action.emit(entry[1]), row)
|
||||
button.icon = load("res://assets/icons/material/%s.svg" % entry[2])
|
||||
button.accessibility_name = entry[0]
|
||||
button.custom_minimum_size = Vector2(140, 72)
|
||||
button.icon_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
button.add_theme_constant_override("icon_max_width", 44)
|
||||
button.mouse_entered.connect(_show_tooltip.bind(button))
|
||||
button.mouse_exited.connect(_hide_tooltip.bind(button))
|
||||
button.focus_entered.connect(_show_tooltip.bind(button))
|
||||
button.focus_exited.connect(_hide_tooltip.bind(button))
|
||||
if entry[1] == "pause": pause_button = button
|
||||
_build_controls(body)
|
||||
tooltip = Widgets.label("", body, 20)
|
||||
tooltip.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
tooltip.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
tooltip.custom_minimum_size.y = 28
|
||||
_build_statistics(body)
|
||||
visibility_changed.connect(func():
|
||||
if not is_visible_in_tree(): set_statistics_visible(false))
|
||||
hide()
|
||||
|
||||
func _build_controls(parent: Control) -> void:
|
||||
var row := HBoxContainer.new()
|
||||
controls_row = row
|
||||
row.add_theme_constant_override("separation", 12)
|
||||
parent.add_child(row)
|
||||
pause_button = _add_control("Pause", "pause", "pause", row)
|
||||
stop_button = _add_control("Stop", "stop", "stop", row)
|
||||
_add_control("Back 10 seconds", "back", "replay_10", row)
|
||||
_add_control("Forward 30 seconds", "forward", "forward_30", row)
|
||||
controls_spacer = row.add_spacer(false)
|
||||
_add_control("Audio and subtitles", "tracks", "subtitles", row)
|
||||
_add_control("Settings", "settings", "settings", row)
|
||||
_add_control("Hide controls", "hide", "visibility_off", row)
|
||||
previous_photo = _add_control("Previous photo", "previous_photo", "chevron_left", row)
|
||||
next_photo = _add_control("Next photo", "next_photo", "chevron_right", row)
|
||||
previous_photo.hide()
|
||||
next_photo.hide()
|
||||
statistics_button = _add_control("Statistics for nerds", "statistics", "statistics", row)
|
||||
statistics_button.toggle_mode = true
|
||||
statistics_button.pressed.connect(func(): set_statistics_visible(not statistics_panel.visible))
|
||||
video_controls.append(statistics_button)
|
||||
|
||||
func _build_statistics(parent: Control) -> void:
|
||||
statistics_panel = VBoxContainer.new()
|
||||
statistics_panel.add_theme_constant_override("separation", 8)
|
||||
parent.add_child(statistics_panel)
|
||||
var heading := HBoxContainer.new()
|
||||
statistics_panel.add_child(heading)
|
||||
Widgets.label("Statistics for nerds", heading, 26).size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
var grid := GridContainer.new()
|
||||
grid.columns = 4
|
||||
grid.add_theme_constant_override("h_separation", 24)
|
||||
grid.add_theme_constant_override("v_separation", 12)
|
||||
statistics_panel.add_child(grid)
|
||||
for key in ["Source video", "Playback resolution", "Playback", "Source codec", "Source bitrate", "Renderer / app FPS", "Player state", "Video decoder", "Hardware decoding", "Dropped frames", "Read ahead"]:
|
||||
Widgets.label(key, grid, 20)
|
||||
var value := Widgets.label("Not reported", grid, 20)
|
||||
value.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
value.custom_minimum_size.x = 210
|
||||
value.clip_text = true
|
||||
statistics_values[key] = value
|
||||
statistics_panel.hide()
|
||||
|
||||
func set_statistics_visible(enabled: bool) -> void:
|
||||
enabled = enabled and not photo_mode
|
||||
statistics_panel.visible = enabled
|
||||
statistics_button.set_pressed_no_signal(enabled)
|
||||
viewport.size = Vector2i(820, 210) if photo_mode else Vector2i(1400, 620 if enabled else 300)
|
||||
fit_screen(screen_dimensions.x, screen_dimensions.y, screen_dimensions.z)
|
||||
statistics_elapsed = 0.0
|
||||
if enabled: _refresh_statistics()
|
||||
|
||||
func _refresh_statistics() -> void:
|
||||
if not statistics_provider.is_valid(): return
|
||||
var values: Dictionary = statistics_provider.call()
|
||||
for key in statistics_values:
|
||||
statistics_values[key].text = str(values.get(key, "Not reported"))
|
||||
|
||||
func _add_control(label: String, action_name: String, icon_name: String, parent: Control) -> Button:
|
||||
var button := Widgets.button("", func(): action.emit(action_name), parent)
|
||||
if action_name in ["pause", "back", "forward", "tracks"]: video_controls.append(button)
|
||||
button.icon = load("res://assets/icons/material/%s.svg" % icon_name)
|
||||
button.accessibility_name = label
|
||||
button.custom_minimum_size = Vector2(140, 72)
|
||||
button.icon_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
button.add_theme_constant_override("icon_max_width", 44)
|
||||
button.mouse_entered.connect(_show_tooltip.bind(button))
|
||||
button.mouse_exited.connect(_hide_tooltip.bind(button))
|
||||
button.focus_entered.connect(_show_tooltip.bind(button))
|
||||
button.focus_exited.connect(_hide_tooltip.bind(button))
|
||||
return button
|
||||
|
||||
func set_photo_mode(enabled: bool) -> void:
|
||||
photo_mode = enabled
|
||||
set_statistics_visible(false)
|
||||
previous_photo.visible = enabled
|
||||
next_photo.visible = enabled
|
||||
controls_row.move_child(previous_photo, 0 if enabled else controls_row.get_child_count() - 1)
|
||||
controls_row.move_child(next_photo, 1 if enabled else controls_row.get_child_count() - 1)
|
||||
timeline.visible = not enabled
|
||||
for button in video_controls: button.visible = not enabled
|
||||
controls_spacer.visible = not enabled
|
||||
controls_row.alignment = BoxContainer.ALIGNMENT_CENTER if enabled else BoxContainer.ALIGNMENT_BEGIN
|
||||
stop_button.icon = load("res://assets/icons/material/logout.svg" if enabled else "res://assets/icons/material/stop.svg")
|
||||
stop_button.accessibility_name = "Exit" if enabled else "Stop"
|
||||
viewport.size = Vector2i(820, 210) if enabled else Vector2i(1400, 300)
|
||||
fit_screen(screen_dimensions.x, screen_dimensions.y, screen_dimensions.z)
|
||||
hovered_control = null
|
||||
tooltip.text = ""
|
||||
|
||||
func set_photo_navigation(has_previous: bool, has_next: bool) -> void:
|
||||
previous_photo.disabled = not has_previous
|
||||
next_photo.disabled = not has_next
|
||||
|
||||
func _show_tooltip(control: Control) -> void:
|
||||
hovered_control = control
|
||||
tooltip.text = control.accessibility_name
|
||||
@@ -76,6 +173,11 @@ func _hide_tooltip(control: Control) -> void:
|
||||
tooltip.text = ""
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if is_visible_in_tree() and statistics_panel.visible:
|
||||
statistics_elapsed += _delta
|
||||
if statistics_elapsed >= 1.0:
|
||||
statistics_elapsed = 0.0
|
||||
_refresh_statistics()
|
||||
if not is_visible_in_tree():
|
||||
hovered_control = null
|
||||
tooltip.text = ""
|
||||
@@ -83,7 +185,9 @@ func _process(_delta: float) -> void:
|
||||
tooltip.text = hovered_control.accessibility_name
|
||||
|
||||
func fit_screen(width: float, aspect: float, curvature: float) -> void:
|
||||
panel_size = Vector2(width * 0.94, width * 0.94 * 300.0 / 1400.0)
|
||||
screen_dimensions = Vector3(width, aspect, curvature)
|
||||
var panel_width := width * 0.94 * float(viewport.size.x) / 1400.0
|
||||
panel_size = Vector2(panel_width, panel_width * float(viewport.size.y) / float(viewport.size.x))
|
||||
var quad := QuadMesh.new()
|
||||
quad.size = panel_size
|
||||
mesh = quad
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
class_name PlayerSkipPopup
|
||||
extends MeshInstance3D
|
||||
|
||||
signal skip_requested
|
||||
|
||||
var viewport: SubViewport
|
||||
var button: Button
|
||||
var panel_size := Vector2.ZERO
|
||||
var available := false
|
||||
|
||||
func _ready() -> void:
|
||||
cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
||||
viewport = SubViewport.new()
|
||||
viewport.size = Vector2i(300, 90)
|
||||
viewport.transparent_bg = true
|
||||
viewport.render_target_update_mode = SubViewport.UPDATE_DISABLED
|
||||
add_child(viewport)
|
||||
var material := StandardMaterial3D.new()
|
||||
material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||
material.render_priority = 1
|
||||
material.albedo_texture = viewport.get_texture()
|
||||
VrOverlayMaterial.apply_depth(material)
|
||||
material_override = material
|
||||
button = Widgets.button("Skip intro", func(): skip_requested.emit(), viewport)
|
||||
button.theme = Widgets.theme()
|
||||
button.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
button.add_theme_font_size_override("font_size", 30)
|
||||
for state in ["normal", "hover", "pressed", "disabled"]:
|
||||
var style := Widgets.surface(Color("101624db"), 22, 16)
|
||||
if state in ["hover", "pressed"]:
|
||||
style.set_border_width_all(3)
|
||||
style.border_color = Color("6de3cf") if state == "hover" else Color("47bda9")
|
||||
button.add_theme_stylebox_override(state, style)
|
||||
var focus := Widgets.surface(Color.TRANSPARENT, 22, 16)
|
||||
focus.set_border_width_all(3)
|
||||
focus.border_color = Color("6de3cf")
|
||||
button.add_theme_stylebox_override("focus", focus)
|
||||
visibility_changed.connect(func():
|
||||
viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS if is_visible_in_tree() else SubViewport.UPDATE_DISABLED
|
||||
if not is_visible_in_tree(): button.release_focus())
|
||||
hide()
|
||||
|
||||
func update_segment(segment: Dictionary) -> void:
|
||||
available = not segment.is_empty()
|
||||
if not available:
|
||||
hide()
|
||||
return
|
||||
button.text = str(segment.label)
|
||||
button.accessibility_name = button.text
|
||||
|
||||
func fit_screen(width: float, aspect: float, curvature: float, controls_height: float = 0.0) -> void:
|
||||
panel_size = Vector2(width * 0.20, width * 0.06)
|
||||
if mesh == null: mesh = QuadMesh.new()
|
||||
mesh.size = panel_size
|
||||
position.x = width * 0.47 - panel_size.x / 2.0
|
||||
position.y = -width / aspect / 2.0 + width * 0.02 + panel_size.y / 2.0
|
||||
if controls_height > 0.0: position.y += controls_height + width * 0.02
|
||||
position.z = (1.0 - cos(curvature / 2.0)) * width / curvature + 0.035 if curvature > 0.001 else 0.035
|
||||