mirror of
https://github.com/Cuteminded/jellyframe.git
synced 2026-10-28 03:00:39 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
No files matched your search
@@ -0,0 +1,3 @@
|
||||
* @Cuteminded @LexiMallow
|
||||
|
||||
.py @Cuteminded
|
||||
+129
-140
@@ -1,30 +1,18 @@
|
||||
# Build from source
|
||||
# Development
|
||||
|
||||
Jellyframe uses Godot and OpenXR. It has an Android video plugin and a shared PC plugin for Windows and Linux. The PC plugin uses libmpv for OpenGL playback and audio. Vulkan video uses FFmpeg and Godot's RenderingDevice.
|
||||
Jellyframe 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`.
|
||||
|
||||
Check the [README's tested results](README.md#what-has-been-tested) and [current limitations](README.md#current-limitations) before making platform or performance claims. Windows SteamVR playback with Vulkan, HDR, 4K, and 8K has been confirmed on an RTX 4070. Other platforms and renderers need separate testing.
|
||||
Build the native video plugin for your platform before importing the project. Close Jellyframe before rebuilding the plugin.
|
||||
|
||||
## Android build requirements
|
||||
## 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. The PC Vulkan backend's hardware decoding and HDR conversion do not apply to Android.
|
||||
|
||||
## Build the Quest APK
|
||||
|
||||
Open **Actions > Build Android APK > Run workflow** to build a Quest 3 debug APK. The workflow builds the native video plugin, exports the APK, and checks its signature. It needs no repository secrets and keeps the APK artifact for 14 days.
|
||||
|
||||
Each workflow run creates a new debug signing key. If the installed APK has a different key, uninstall it before installing the new build. Uninstalling removes saved settings and sign-in. These APKs are test builds, not 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:
|
||||
|
||||
@@ -34,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:
|
||||
|
||||
@@ -44,11 +32,13 @@ 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. It downloads Godot OpenXR Vendors 5.1.0 for Quest passthrough support, checks the archive's SHA-256 checksum, and includes the vendor licences in the APK. The downloaded addon stays in the ignored `addons/godotopenxrvendors` directory.
|
||||
The scripts build the Android plugin and export `dist/jellyframe-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 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'
|
||||
@@ -57,107 +47,13 @@ godot --headless --xr-mode off --path . --editor --import --quit
|
||||
godot --headless --xr-mode off --path . --export-release 'Windows' dist/windows/jellyframe.exe
|
||||
```
|
||||
|
||||
The plugin build script downloads pinned CMake, LLVM-MinGW, godot-cpp, libmpv, and FFmpeg archives into `build/desktop` and verifies their checksums. It generates `addons/jellyframe_mpv` without a system-wide compiler installation. Close Jellyframe 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/jellyframe_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. Run the tests separately with the commands below. Build commands disable OpenXR; the exported app keeps it enabled. The workflow needs no repository secrets. 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).
|
||||
|
||||
The Windows preset exports one executable and defaults to OpenGL. With SteamVR and the headset running, launch Vulkan explicitly:
|
||||
## Build on Linux x64 or ARM64
|
||||
|
||||
```powershell
|
||||
.\dist\windows\jellyframe.exe --rendering-method mobile --rendering-driver vulkan
|
||||
```
|
||||
|
||||
The Linux presets select their renderer automatically. The workflow does not create separate Windows renderer packages or launchers.
|
||||
|
||||
After building the PC plugin, run its decoder tests without a headset:
|
||||
|
||||
```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'
|
||||
```
|
||||
|
||||
Run GPU playback checks 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 mobile --rendering-driver vulkan --path . --script tests/test_pc_video_transitions.gd
|
||||
godot --xr-mode off --rendering-method gl_compatibility --path . --script tests/test_pc_video_transitions.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 Vulkan test generates ten-bit video fixtures and checks BT.601 limited-range and BT.709 full-range colors, paused seeks, completion, and playback with audio. Pass `--video=<path>` and `--size=7680x4320` to `test_pc_gpu_video.gd` to check an 8K source and reopening at 1080p. These checks do not measure sustained frame rate or verify headset playback.
|
||||
|
||||
The transition test renders the PC video material before playback, after stopping, and when reopening a video. It checks that a missing texture produces black and that a decoded video becomes visible. Vulkan and OpenGL checks have passed locally. The black start/stop transition still needs headset confirmation.
|
||||
|
||||
OpenGL uses the existing libmpv renderer and shared GPU textures. Vulkan uses a separate FFmpeg decoder and a compute shader on Godot's main Vulkan device.
|
||||
|
||||
Native NV12, P010, and planar 4:2:0 frames keep their original plane layout. The decoder converts other formats to ten-bit planar data. Windows tries CUDA/NVDEC before D3D11VA. Linux tries VA-API or CUDA. CUDA downloads go directly into reusable pinned upload buffers.
|
||||
|
||||
A reader thread buffers compressed video packets independently of decoding. It reads ahead up to four seconds, 128 MiB, or 512 packets. If video falls behind, audio waits for it to catch up and for one second of compressed video to accumulate. Playback can also resume when the reader reaches the end or the buffer limit. Seeking clears queued packets and flushes the decoder.
|
||||
|
||||
Decoding and downloads run on another worker. Godot's render thread handles uploads, with one upload scheduled at a time. Uploads share the renderer's frame budget. Decoded planes pass through system memory, so this is not a zero-copy backend.
|
||||
|
||||
Desktop RenderingDevice settings allow up to 512 MiB of staging buffers and use larger upload regions to reduce stalls with 8K ten-bit frames. The engine allocates this memory as needed. OpenGL and Android Compatibility rendering do not use these buffers.
|
||||
|
||||
PQ and HLG video use Hable tone mapping to SDR. The conversion uses mastering or content-light metadata when available, or a 1,000-nit default. The HDR test compares gray patches with FFmpeg's reference conversion and checks their brightness through the 3D video material. HDR output and display calibration are not supported.
|
||||
|
||||
Measure presentation rate and frame times with a source long enough for the requested sample, including 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 result reports presented frames per second, dropped frames, decoder and transfer timings, and main-loop frame times. The default source is the locally generated `build/gpu-worker/8k-av1-test.mp4`. Supply your own file on a fresh checkout. This windowed benchmark does not measure SteamVR performance.
|
||||
|
||||
During ordinary playback, the startup log identifies the renderer, codec, hardware device, HDR transfer, and backend version. Set `JELLYFRAME_VIDEO_STATS=1` to log counters every five seconds. The decoder skips isolated corrupt video packets. Repeated errors trigger the software fallback.
|
||||
|
||||
To test buffering, supply a video with audio and at least 20 seconds of content. The test uses FFmpeg and `ffprobe` from the same directory and serves the file over local HTTP:
|
||||
|
||||
```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 interrupts delivery and checks recovery, audio synchronization, and a manual pause during buffering. The second delivers at twice the average bitrate, adds a 1.5-second interruption, and checks that read-ahead absorbs it without a buffer stop. Omitting `--video` generates a 4K HEVC fixture and requires FFmpeg's NVIDIA NVENC encoder.
|
||||
|
||||
These checks passed with an 8K AV1 source on the Windows RTX 4070 setup. A separate 15-second local benchmark measured about 60 presented frames per second without dropped frames. Headset confirmation of HDR, 4K, and 8K playback is recorded separately in the [README](README.md#what-has-been-tested).
|
||||
|
||||
For a Vulkan playback log in PowerShell, run the exported app with SteamVR active:
|
||||
|
||||
```powershell
|
||||
New-Item -ItemType Directory -Force logs | Out-Null
|
||||
$env:JELLYFRAME_VIDEO_STATS = '1'
|
||||
.\dist\windows\jellyframe.exe --rendering-method mobile --rendering-driver vulkan --log-file (Join-Path $PWD 'logs/vulkan-playback.log')
|
||||
```
|
||||
|
||||
The log includes the codec, hardware decoder, audio and video positions, read-ahead duration and size, dropped frames, and video buffering count. Remove access tokens and private server details before sharing a log.
|
||||
|
||||
For videos with audio, libmpv opens the source separately with video disabled and supplies the audio clock. Remote playback can therefore make two sets of media requests. Silent videos use the FFmpeg frames and a local playback clock.
|
||||
|
||||
If initialization, decoding, or GPU playback stalls or fails, playback falls back to the existing software renderer. `JELLYFRAME_PC_RENDERER=software` forces that fallback for troubleshooting.
|
||||
|
||||
With SteamVR and your headset running, test the stereo screen and controllers:
|
||||
|
||||
```powershell
|
||||
godot --rendering-method mobile --rendering-driver vulkan --path . --script tests/test_steamvr_video.gd
|
||||
```
|
||||
|
||||
The headset test loops a labelled stereo clip for two minutes. Close one eye at a time to check the LEFT and RIGHT labels. Press A or B to open the playback controls. Use `--rendering-method gl_compatibility` instead of the Vulkan arguments to check OpenGL. 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. It remembers sign-in through the local session store but does not play videos in the app. Decoder test scripts are separate from this desktop interface mode.
|
||||
|
||||
## 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 libavcodec-dev libavformat-dev libavutil-dev libswscale-dev
|
||||
@@ -165,45 +61,60 @@ 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`. It uses the system libmpv and FFmpeg development packages. FFmpeg 6 or newer is required. Close Jellyframe before rebuilding its plugin.
|
||||
The script detects x64 or ARM64 and generates `addons/jellyframe_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' dist/linux-x86_64/jellyframe.x86_64
|
||||
```
|
||||
|
||||
For ARM64:
|
||||
Export for ARM64:
|
||||
|
||||
```sh
|
||||
mkdir -p dist/linux-arm64
|
||||
godot --headless --xr-mode off --path . --export-release 'Linux ARM64' dist/linux-arm64/jellyframe.arm64
|
||||
```
|
||||
|
||||
Windows and Linux use the Mobile renderer with Vulkan. If Vulkan cannot initialize, Godot falls back to OpenGL Compatibility. Pass `--rendering-method gl_compatibility` to select OpenGL manually. Each Linux executable embeds its resources and loads the native decoder library from the output folder.
|
||||
Keep the executable and native decoder `.so` file together.
|
||||
|
||||
The Build Linux workflow uses native `ubuntu-24.04` and `ubuntu-24.04-arm` runners. Each job builds its plugin, exports one app with Vulkan as the default and OpenGL as the fallback, and checks output files and decoder dependencies. It then packages a system-libraries archive and one AppImage for its architecture. Run the tests separately with the commands in this guide.
|
||||
### Package an AppImage
|
||||
|
||||
The system-libraries artifact is a tar archive that preserves Linux executable permissions. GitHub keeps the build artifacts for 14 days. The workflow needs no repository secrets.
|
||||
|
||||
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/jellyframe.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 and includes its license notices and package manifest. The host supplies graphics drivers and OpenXR. The packager downloads appimagetool 1.9.1 for the selected architecture, verifies its SHA-256 checksum, and runs it without FUSE.
|
||||
The packager bundles decoder libraries and licence notices. It needs network access to download appimagetool and its AppImage runtime. The output is `dist/jellyframe-linux-<arch>.AppImage`. The host supplies graphics drivers and OpenXR.
|
||||
|
||||
appimagetool downloads its AppImage runtime during packaging, so packaging needs network access. The output is `dist/jellyframe-linux-<arch>.AppImage`.
|
||||
## Run the app during development
|
||||
|
||||
The decoder and headset tests in [Build and test on Windows](#build-and-test-on-windows) also work on Linux. Use `python3` for the HTTP streaming test and pass the path to your Linux Godot executable.
|
||||
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
|
||||
@@ -212,17 +123,95 @@ 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_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:JELLYFRAME_VIDEO_STATS = '1'
|
||||
.\dist\windows\jellyframe.exe --rendering-method mobile --rendering-driver vulkan --log-file (Join-Path $PWD 'logs/vulkan-playback.log')
|
||||
```
|
||||
|
||||
Set `JELLYFRAME_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
|
||||
|
||||
Render synthetic panoramas to check top-bottom eye selection and confirm 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 uses a generated texture to test the PC shader and saves panorama and settings previews in `dist`. It does not enable desktop video playback in the app. It cannot test 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
|
||||
|
||||
@@ -230,5 +219,5 @@ The check uses a generated texture to test the PC shader and saves panorama and
|
||||
- `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 OpenGL renderer, FFmpeg Vulkan decoder, GPU plane conversion, and playback synchronization.
|
||||
- `native/pc` contains the libmpv OpenGL renderer and FFmpeg Vulkan decoder.
|
||||
- `scripts/core` contains screen settings and stereo geometry.
|
||||
+20
-34
@@ -1,60 +1,48 @@
|
||||
# Install and start Jellyframe
|
||||
|
||||
## 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 Jellyframe 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 `jellyframe-android.apk`.
|
||||
|
||||
Download the Android APK from **Assets** on the [latest release page](https://github.com/Cuteminded/jellyframe/releases/latest). To build the APK yourself, follow the [build instructions](DEVELOPMENT.md).
|
||||
|
||||
Connect your headset to your computer and approve USB debugging in the headset. Install the APK with Android's `adb` tool. Run these commands from the folder containing the downloaded APK:
|
||||
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 jellyframe-android.apk
|
||||
```
|
||||
|
||||
If you built the APK yourself, use `dist/jellyframe-android.apk` as the install path instead.
|
||||
Open Jellyframe from **Unknown Sources** in your headset.
|
||||
|
||||
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.
|
||||
## Windows with SteamVR
|
||||
|
||||
## Try it on Windows with SteamVR
|
||||
Download the Windows ZIP and extract the entire archive. Keep all files together in the extracted folder.
|
||||
|
||||
Download the Windows ZIP from **Assets** on the [latest release page](https://github.com/Cuteminded/jellyframe/releases/latest). Extract the entire ZIP and keep the executable, PCK, and any DLLs together in the same folder.
|
||||
Install SteamVR and connect your headset. In SteamVR's **Settings > OpenXR**, set SteamVR as the active OpenXR runtime. Start SteamVR, then run `jellyframe.exe`.
|
||||
|
||||
Install SteamVR and connect your headset. In SteamVR's **Settings > OpenXR**, set SteamVR as the active OpenXR runtime. Start SteamVR, then 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).
|
||||
|
||||
Windows starts with Vulkan and falls back to OpenGL if Vulkan cannot initialize. To select OpenGL manually, run this from the extracted folder in PowerShell:
|
||||
If Jellyframe cannot start with Vulkan, try OpenGL from PowerShell in the extracted folder:
|
||||
|
||||
```powershell
|
||||
.\jellyframe.exe --rendering-method gl_compatibility
|
||||
```
|
||||
|
||||
Sign in to Jellyfin and select a title to play it in VR. Pause, seek, resume, and side-by-side 3D controls work as they do in the Quest build.
|
||||
## Linux x64 or ARM64
|
||||
|
||||
Keep all DLLs from the matching build beside the executable. Current source builds need `jellyframe_mpv.dll`, `libmpv-2.dll`, `avcodec-62.dll`, `avformat-62.dll`, `avutil-60.dll`, `swscale-9.dll`, and `swresample-6.dll`.
|
||||
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.
|
||||
|
||||
The confirmed Vulkan HDR, 4K, and 8K results apply to a Windows SteamVR setup with an NVIDIA RTX 4070. Check the [current limitations](README.md#current-limitations) for other configurations. A published release may predate these changes; use [the source build instructions](DEVELOPMENT.md#build-and-test-on-windows) when testing the current implementation.
|
||||
|
||||
## Try it on Linux x64 or ARM64
|
||||
|
||||
Download the Linux AppImage from **Assets** on the [latest release page](https://github.com/Cuteminded/jellyframe/releases/latest). Choose the file for your computer's architecture, `x86_64` or `arm64`. AppImages include libmpv and the bundled decoder libraries.
|
||||
|
||||
Make the downloaded AppImage executable and launch it. For x64:
|
||||
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.AppImage
|
||||
./jellyframe-linux-x86_64.AppImage
|
||||
```
|
||||
|
||||
Linux starts with Vulkan and falls back to OpenGL if Vulkan cannot initialize. Use the same AppImage when adding Jellyframe to Steam on SteamOS. For ARM64, use `jellyframe-linux-arm64.AppImage`.
|
||||
For ARM64, replace the filename with `jellyframe-linux-arm64.AppImage`. On SteamOS, add the AppImage to Steam as a non-Steam game.
|
||||
|
||||
To select OpenGL manually:
|
||||
If Jellyframe cannot start with Vulkan, try OpenGL:
|
||||
|
||||
```sh
|
||||
./jellyframe-linux-x86_64.AppImage --rendering-method gl_compatibility
|
||||
@@ -62,13 +50,9 @@ To select OpenGL manually:
|
||||
|
||||
If mounting an AppImage fails because FUSE is unavailable, run it with `--appimage-extract-and-run`.
|
||||
|
||||
The builds target Ubuntu 24.04. AppImages need compatible system libraries, graphics drivers, and an OpenXR runtime. They may not work on older Linux distributions.
|
||||
### Use system libraries instead of an AppImage
|
||||
|
||||
Connect your headset and start your OpenXR runtime before opening Jellyframe. On x64, set SteamVR as the active runtime. The runtime must support the selected graphics API. SteamVR on ARM64 and standalone Steam Frame support still need headset testing.
|
||||
|
||||
SteamOS Vulkan playback was confirmed with an earlier PC backend. The current native Vulkan decoder, HDR conversion, and high-resolution playback still need Linux headset testing. The Windows results do not confirm those cases on Linux. See the [current limitations](README.md#current-limitations).
|
||||
|
||||
The `system-libs` archive remains available if you prefer to install libmpv yourself. It contains the same executable with Vulkan as the default and OpenGL as the fallback. On Ubuntu x64:
|
||||
Download the `system-libs` archive for your architecture. On Ubuntu x64, install libmpv, extract the archive, and launch Jellyframe:
|
||||
|
||||
```sh
|
||||
sudo apt-get install libmpv2
|
||||
@@ -78,4 +62,6 @@ tar -xzf jellyframe-linux-x86_64-system-libs.tar.gz
|
||||
|
||||
Keep the executable and the `.so` file together. For ARM64, use `jellyframe-linux-arm64-system-libs.tar.gz` and launch `./jellyframe.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.
|
||||
@@ -4,36 +4,49 @@ Watch your Jellyfin movies and shows on a virtual screen in VR.
|
||||
|
||||
Jellyframe 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.
|
||||
|
||||
> I am still on the reservation queue for Steam Frame and have not received the device yet.
|
||||
>
|
||||
> When I do, I will update the documentation and testing status accordingly.
|
||||
|
||||
## From JellyQuest to Jellyframe
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
Steam Frame is now the main target for future development.
|
||||
I'm still undecided about keeping the name. It ties the app to Steam Frame, even though I want to support other VR platforms too.
|
||||
I haven't come up with a better name yet, so Jellyframe stays for now.
|
||||
Suggestions for a name that isn't tied to a specific platform are welcome.
|
||||
|
||||
> I do not have a Steam Frame yet, so I cannot test it on the headset. Development of the Quest version continues under the Jellyframe name.
|
||||
I want Jellyframe to work on as many VR platforms as possible, including:
|
||||
|
||||
- [SteamVR](https://store.steampowered.com/steamvr)
|
||||
- [Android XR](https://www.android.com/intl/en_us/xr/)
|
||||
- [Quest headsets running Horizon OS](https://www.meta.com/quest/)
|
||||
|
||||
Steam Frame remains the primary development target, with support for other platforms wherever possible.
|
||||
|
||||
## What has been tested
|
||||
|
||||
- Browsing and searching Jellyfin libraries.
|
||||
- Selecting titles and controlling playback with controllers.
|
||||
- Dark backgrounds, cinema environments, and passthrough on supported devices.
|
||||
- 2D, side-by-side 3D, 180° and 360° video.
|
||||
- 2D, side-by-side and top-bottom 3D, 180° and 360° video.
|
||||
- Automatic episode playback in series order.
|
||||
- HDR, 1080p, 4K, and 8K video playback.
|
||||
|
||||
## Current limitations
|
||||
|
||||
- Steam Frame installation and playback remain untested.
|
||||
- 180° projection and top-bottom 3D still need headset testing. Desktop projection tests cannot confirm stereo output or viewing comfort in VR.
|
||||
- Quest HLS seeking still needs in-headset confirmation after the timeline fix.
|
||||
- Linux VR playback was previously tested on a Steam Machine running SteamOS with Vulkan and SteamVR. The new native Vulkan decoder, HDR conversion, and 4K/8K playback still need Linux headset testing.
|
||||
- ARM64 and other Linux systems also need headset testing.
|
||||
- Quest and OpenGL HDR behaviour still need separate testing.
|
||||
- Native HDR output and display calibration are not implemented.
|
||||
- Some files need conversion by your Jellyfin server during playback. This can fail if the server cannot handle the conversion.
|
||||
- Audio and subtitle selection, favourites, and collections are not available yet.
|
||||
- Manually marking titles as watched and automatically hiding playback controls are planned but not available yet.
|
||||
|
||||
## Will it be available in a store?
|
||||
|
||||
If we think Jellyframe 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)
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
# Jellyfin feature checklist
|
||||
|
||||
This checklist compares Jellyframe 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.
|
||||
- [ ] 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.
|
||||
- [ ] Manually mark a movie, episode, season, or series as watched or unwatched.
|
||||
- [ ] Select between multiple media versions. Playback currently uses the first returned media source.
|
||||
- [ ] 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.
|
||||
- [ ] Enable Direct Stream/remuxing. Playback requests currently set `EnableDirectStream` to `false`.
|
||||
- [ ] Select audio tracks and languages. The audio/subtitle control is disabled.
|
||||
- [ ] Display and select embedded or external subtitles. The playback profile currently advertises no subtitle support.
|
||||
- [ ] Adjust subtitle appearance 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 Jellyframe'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.
|
||||
|
||||
## Jellyframe VR features
|
||||
|
||||
These are Jellyframe 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.
|
||||
@@ -31,11 +31,19 @@ To reset the saved app settings as well, delete `settings.cfg` from the same fol
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
## View photos
|
||||
|
||||
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.
|
||||
|
||||
## Control playback
|
||||
|
||||
Press **A** or **B** to show the playback controls. Point at a button to see its name beneath the controls.
|
||||
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.
|
||||
|
||||
| Control | What it does |
|
||||
| --- | --- |
|
||||
@@ -45,13 +53,19 @@ Press **A** or **B** to show the playback controls. Point at a button to see its
|
||||
| Forward 30 seconds | Skip ahead. |
|
||||
| Time bar | Choose a different point in the video. |
|
||||
| Settings | Change the screen, background, or playback quality. |
|
||||
| Library | Return to the current movie or series details while the video keeps playing. |
|
||||
| Hide controls | Remove the controls from your view. Press A or B to show them again. |
|
||||
| Audio and subtitles | Placeholder for future audio-track and subtitle selection. This button is currently disabled. |
|
||||
| Hide controls | Remove the controls from your view. Press A to show them again. |
|
||||
|
||||
After using **Library**, select the title that is already playing to return to the video. For a series, Jellyframe keeps the current season open.
|
||||
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
|
||||
|
||||
With [Intro Skipper](https://github.com/intro-skipper/intro-skipper) installed on Jellyfin 10.10 or newer, a **Skip intro** or **Skip credits** popup appears at the bottom-right of the video during a detected segment. Point at it and press the trigger to skip, even with the playback controls hidden. When the controls are open, the popup sits just above them. Recap and preview segments also have skip buttons when the server supplies them. Skipping is manual.
|
||||
|
||||
The server must finish analyzing the title before timestamps are available. Jellyframe reads Jellyfin's media segments, so other providers using the same API also work. If there are no timestamps or the request fails, playback continues without a skip button. Older plugin-specific APIs are not supported.
|
||||
|
||||
## Make the screen comfortable
|
||||
|
||||
Open **Settings**, then choose **Screen**. If your chosen background allows screen adjustments, you can change:
|
||||
@@ -104,7 +118,7 @@ These videos let you look around inside the scene instead of watching a normal s
|
||||
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 or B to bring up the controls.
|
||||
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.
|
||||
|
||||
@@ -130,7 +144,7 @@ Home always shows the newest additions first. The sort order does not affect Ran
|
||||
|
||||
## If something does not look right
|
||||
|
||||
- If the controls disappear, press A or B to show them again.
|
||||
- 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.
|
||||
|
||||
@@ -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 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
|
||||
+3
-3
@@ -7,7 +7,7 @@ dedicated_server=false
|
||||
custom_features="quest"
|
||||
export_filter="all_resources"
|
||||
include_filter="addons/godotopenxrvendors/*LICENSE*"
|
||||
exclude_filter="assets/cinema.glb,assets/theatre/materials.tres,native/*,android/*,docs/*,tests/*,tools/*,dist/*,build/*,addons/jellyframe_mpv/*,shaders/pc_video.gdshader"
|
||||
exclude_filter="assets/cinema.glb,assets/theatre/materials.tres,native/*,android/*,docs/*,tests/*,tools/*,dist/*,build/*,addons/jellyframe_mpv/*"
|
||||
export_path="dist/jellyframe-android.apk"
|
||||
script_export_mode=2
|
||||
|
||||
@@ -24,8 +24,8 @@ architectures/armeabi-v7a=false
|
||||
architectures/arm64-v8a=true
|
||||
architectures/x86=false
|
||||
architectures/x86_64=false
|
||||
version/code=2
|
||||
version/name="0.1.1"
|
||||
version/code=3
|
||||
version/name="0.1.2"
|
||||
package/unique_name="nu.lizzy.jellyframe"
|
||||
package/name="Jellyframe"
|
||||
package/signed=true
|
||||
|
||||
@@ -174,15 +174,6 @@ public final class JellyframeVideo extends GodotPlugin {
|
||||
@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;
|
||||
|
||||
+16
-5
@@ -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>
|
||||
@@ -32,6 +33,7 @@ void JellyframeMpv::_bind_methods() {
|
||||
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("rendering_backend"), &JellyframeMpv::rendering_backend);
|
||||
ClassDB::bind_method(D_METHOD("video_shader_parameters"), &JellyframeMpv::video_shader_parameters);
|
||||
ClassDB::bind_method(D_METHOD("playback_diagnostics"), &JellyframeMpv::playback_diagnostics);
|
||||
ClassDB::bind_method(D_METHOD("stop_video"), &JellyframeMpv::stop_video);
|
||||
ADD_SIGNAL(MethodInfo("prepared"));
|
||||
@@ -67,7 +69,9 @@ String JellyframeMpv::initialize(int64_t start_ms) {
|
||||
{"vo", vulkan_renderer.is_valid() ? "null" : "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"}
|
||||
{"idle", "yes"}, {"tls-verify", "yes"},
|
||||
{"cache", "yes"}, {"cache-secs", "30"}, {"demuxer-max-bytes", "256MiB"},
|
||||
{"user-agent", JELLYFRAME_STREAM_USER_AGENT}
|
||||
};
|
||||
for (const auto &option : options) {
|
||||
int error = mpv_set_option_string(player, option[0], option[1]);
|
||||
@@ -191,7 +195,7 @@ String JellyframeMpv::rendering_backend() const {
|
||||
Dictionary JellyframeMpv::playback_diagnostics() const {
|
||||
Dictionary values = vulkan_renderer.is_valid() ? vulkan_renderer->diagnostics() : Dictionary();
|
||||
values["renderer"] = rendering_backend();
|
||||
values["version"] = "vulkan-planes-4";
|
||||
values["version"] = "vulkan-direct-yuv-1";
|
||||
values["audio_position_ms"] = position;
|
||||
if (vulkan_renderer.is_valid()) {
|
||||
values["video_position_ms"] = vulkan_renderer->timestamp_ms();
|
||||
@@ -268,19 +272,22 @@ bool JellyframeMpv::update_size() {
|
||||
bool JellyframeMpv::upload_frame() {
|
||||
if (vulkan_renderer.is_valid()) {
|
||||
vulkan_renderer->set_clock(position);
|
||||
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->get_texture();
|
||||
Ref<Texture2D> next = vulkan_renderer->shader_parameters()["y_plane"];
|
||||
if (texture != next) {
|
||||
texture = next;
|
||||
emit_signal("size_changed", texture->get_width(), texture->get_height());
|
||||
emit_signal("texture_changed", texture);
|
||||
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()) {
|
||||
@@ -402,3 +409,7 @@ void JellyframeMpv::_process(double) {
|
||||
emit_signal("failed", "Video playback stalled.");
|
||||
}
|
||||
}
|
||||
|
||||
Dictionary JellyframeMpv::video_shader_parameters() const {
|
||||
return vulkan_renderer.is_valid() ? vulkan_renderer->shader_parameters() : Dictionary();
|
||||
}
|
||||
@@ -20,6 +20,8 @@ public:
|
||||
bool is_buffering() 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;
|
||||
|
||||
|
||||
+41
-90
@@ -1,15 +1,9 @@
|
||||
#include "render_vulkan.h"
|
||||
#include "video_conversion_shader.h"
|
||||
#include <godot_cpp/classes/rd_sampler_state.hpp>
|
||||
#include <godot_cpp/classes/rd_shader_source.hpp>
|
||||
#include <godot_cpp/classes/rd_shader_spirv.hpp>
|
||||
#include <godot_cpp/classes/rd_texture_format.hpp>
|
||||
#include <godot_cpp/classes/rd_texture_view.hpp>
|
||||
#include <godot_cpp/classes/rd_uniform.hpp>
|
||||
#include <godot_cpp/classes/rendering_server.hpp>
|
||||
#include <godot_cpp/variant/callable_method_pointer.hpp>
|
||||
#include <mpv/client.h>
|
||||
#include <cstring>
|
||||
#include <chrono>
|
||||
|
||||
using namespace godot;
|
||||
@@ -36,20 +30,6 @@ void RenderVulkan::dispatch_sync() {
|
||||
void RenderVulkan::initialize_on_render_thread() {
|
||||
device = RenderingServer::get_singleton()->get_rendering_device();
|
||||
if (!device) { render_error = MPV_ERROR_UNSUPPORTED; return; }
|
||||
Ref<RDShaderSource> source;
|
||||
source.instantiate();
|
||||
source->set_stage_source(RenderingDevice::SHADER_STAGE_COMPUTE, video_conversion_shader);
|
||||
Ref<RDShaderSPIRV> spirv = device->shader_compile_spirv_from_source(source);
|
||||
if (spirv.is_null() || !spirv->get_stage_compile_error(RenderingDevice::SHADER_STAGE_COMPUTE).is_empty()) {
|
||||
render_error = MPV_ERROR_UNSUPPORTED;
|
||||
return;
|
||||
}
|
||||
shader = device->shader_create_from_spirv(spirv);
|
||||
if (shader.is_valid()) pipeline = device->compute_pipeline_create(shader);
|
||||
Ref<RDSamplerState> state;
|
||||
state.instantiate();
|
||||
sampler = device->sampler_create(state);
|
||||
if (!pipeline.is_valid() || !sampler.is_valid()) render_error = MPV_ERROR_UNSUPPORTED;
|
||||
}
|
||||
|
||||
int RenderVulkan::start(String url, String authorization, int64_t start_ms) {
|
||||
@@ -61,12 +41,10 @@ int RenderVulkan::start(String url, String authorization, int64_t start_ms) {
|
||||
}
|
||||
|
||||
void RenderVulkan::destroy_textures() {
|
||||
if (texture.is_valid()) texture->set_texture_rd_rid(RID());
|
||||
texture.unref();
|
||||
if (uniforms.is_valid()) device->free_rid(uniforms);
|
||||
uniforms = RID();
|
||||
if (output.is_valid()) device->free_rid(output);
|
||||
output = RID();
|
||||
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();
|
||||
@@ -82,45 +60,17 @@ bool RenderVulkan::create_textures() {
|
||||
format->set_usage_bits(RenderingDevice::TEXTURE_USAGE_SAMPLING_BIT | RenderingDevice::TEXTURE_USAGE_CAN_UPDATE_BIT);
|
||||
Ref<RDTextureView> view;
|
||||
view.instantiate();
|
||||
TypedArray<RDUniform> bindings;
|
||||
for (int index = 0; index < 3; ++index) {
|
||||
RID plane;
|
||||
if (index < frame.plane_count) {
|
||||
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;
|
||||
plane = planes[index];
|
||||
} else plane = planes[0];
|
||||
Ref<RDUniform> uniform;
|
||||
uniform.instantiate();
|
||||
uniform->set_uniform_type(RenderingDevice::UNIFORM_TYPE_SAMPLER_WITH_TEXTURE);
|
||||
uniform->set_binding(index);
|
||||
uniform->add_id(sampler);
|
||||
uniform->add_id(plane);
|
||||
bindings.append(uniform);
|
||||
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]);
|
||||
}
|
||||
format->set_width(frame.display_width);
|
||||
format->set_height(frame.display_height);
|
||||
format->set_format(RenderingDevice::DATA_FORMAT_R8G8B8A8_UNORM);
|
||||
format->add_shareable_format(RenderingDevice::DATA_FORMAT_R8G8B8A8_UNORM);
|
||||
format->add_shareable_format(RenderingDevice::DATA_FORMAT_R8G8B8A8_SRGB);
|
||||
format->set_usage_bits(RenderingDevice::TEXTURE_USAGE_SAMPLING_BIT | RenderingDevice::TEXTURE_USAGE_STORAGE_BIT | RenderingDevice::TEXTURE_USAGE_CAN_COPY_FROM_BIT);
|
||||
output = device->texture_create(format, view);
|
||||
if (!output.is_valid()) return false;
|
||||
Ref<RDUniform> destination;
|
||||
destination.instantiate();
|
||||
destination->set_uniform_type(RenderingDevice::UNIFORM_TYPE_IMAGE);
|
||||
destination->set_binding(3);
|
||||
destination->add_id(output);
|
||||
bindings.append(destination);
|
||||
uniforms = device->uniform_set_create(bindings, shader, 0);
|
||||
if (!uniforms.is_valid()) return false;
|
||||
texture.instantiate();
|
||||
texture->set_texture_rd_rid(output);
|
||||
source_width = frame.width;
|
||||
source_height = frame.height;
|
||||
storage_width = frame.storage_width;
|
||||
@@ -137,8 +87,10 @@ void RenderVulkan::present_on_render_thread() {
|
||||
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_texture = texture;
|
||||
ready_parameters = parameters;
|
||||
ready_size = Vector2i(frame.display_width, frame.display_height);
|
||||
ready_generation = frame.generation;
|
||||
ready_timestamp = frame.timestamp;
|
||||
++presented_frames;
|
||||
@@ -169,31 +121,14 @@ void RenderVulkan::present_frame() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
struct Parameters {
|
||||
int32_t width, height, source_width, source_height;
|
||||
float kr, kb, full_range, transfer;
|
||||
int32_t rotation, interleaved;
|
||||
float sample_scale, code_max;
|
||||
float source_peak, padding[3];
|
||||
} parameters{frame.display_width, frame.display_height, frame.width, frame.height,
|
||||
frame.kr, frame.kb, frame.full_range, frame.transfer, frame.rotation, frame.interleaved ? 1 : 0, frame.sample_scale, frame.code_max,
|
||||
frame.source_peak, {0, 0, 0}};
|
||||
PackedByteArray constants;
|
||||
constants.resize(sizeof(parameters));
|
||||
std::memcpy(constants.ptrw(), ¶meters, sizeof(parameters));
|
||||
int64_t list = device->compute_list_begin();
|
||||
device->compute_list_bind_compute_pipeline(list, pipeline);
|
||||
device->compute_list_bind_uniform_set(list, uniforms, 0);
|
||||
device->compute_list_set_push_constant(list, constants, constants.size());
|
||||
device->compute_list_dispatch(list, (frame.display_width + 15) / 16, (frame.display_height + 15) / 16, 1);
|
||||
device->compute_list_end();
|
||||
}
|
||||
|
||||
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_texture = ready_texture;
|
||||
presented_parameters = ready_parameters;
|
||||
presented_size = ready_size;
|
||||
presented_timestamp = ready_timestamp;
|
||||
return true;
|
||||
}
|
||||
@@ -201,6 +136,7 @@ bool RenderVulkan::take_frame() {
|
||||
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;
|
||||
@@ -213,10 +149,6 @@ int RenderVulkan::error() const {
|
||||
void RenderVulkan::destroy_on_render_thread() {
|
||||
if (!device) return;
|
||||
destroy_textures();
|
||||
if (pipeline.is_valid()) device->free_rid(pipeline);
|
||||
if (shader.is_valid()) device->free_rid(shader);
|
||||
if (sampler.is_valid()) device->free_rid(sampler);
|
||||
pipeline = shader = sampler = RID();
|
||||
device = nullptr;
|
||||
}
|
||||
|
||||
@@ -224,7 +156,26 @@ void RenderVulkan::stop() {
|
||||
stopping = true;
|
||||
decoder.stop();
|
||||
run_sync(&RenderVulkan::destroy_on_render_thread);
|
||||
presented_texture.unref();
|
||||
ready_texture.unref();
|
||||
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;
|
||||
}
|
||||
@@ -17,13 +17,13 @@ public:
|
||||
bool take_frame();
|
||||
int error() const;
|
||||
bool has_audio() const { return decoder.has_audio(); }
|
||||
int width() const { return decoder.width(); }
|
||||
int height() const { return decoder.height(); }
|
||||
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::Ref<godot::Texture2DRD> get_texture() const { return presented_texture; }
|
||||
godot::Dictionary shader_parameters() const { return presented_parameters; }
|
||||
protected:
|
||||
static void _bind_methods() {}
|
||||
private:
|
||||
@@ -40,18 +40,16 @@ private:
|
||||
VideoDecoder decoder;
|
||||
VideoPlanes frame;
|
||||
godot::RenderingDevice *device = nullptr;
|
||||
godot::Ref<godot::Texture2DRD> texture;
|
||||
godot::Ref<godot::Texture2DRD> presented_texture;
|
||||
godot::Ref<godot::Texture2DRD> ready_texture;
|
||||
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;
|
||||
godot::RID shader;
|
||||
godot::RID pipeline;
|
||||
godot::RID sampler;
|
||||
godot::RID uniforms;
|
||||
godot::RID output;
|
||||
std::array<godot::RID, 3> planes;
|
||||
int target_width = 0;
|
||||
int target_height = 0;
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
#pragma once
|
||||
|
||||
inline constexpr const char *JELLYFRAME_STREAM_USER_AGENT = "Jellyframe";
|
||||
@@ -1,72 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
static const char *video_conversion_shader = R"glsl(
|
||||
#version 450
|
||||
layout(local_size_x = 16, local_size_y = 16) in;
|
||||
layout(set = 0, binding = 0) uniform sampler2D y_plane;
|
||||
layout(set = 0, binding = 1) uniform sampler2D u_plane;
|
||||
layout(set = 0, binding = 2) uniform sampler2D v_plane;
|
||||
layout(rgba8, set = 0, binding = 3) uniform writeonly image2D destination;
|
||||
layout(push_constant, std430) uniform Parameters {
|
||||
ivec2 size;
|
||||
ivec2 source_size;
|
||||
vec2 coefficients;
|
||||
float full_range;
|
||||
float transfer;
|
||||
int rotation;
|
||||
int interleaved;
|
||||
float sample_scale;
|
||||
float code_max;
|
||||
float source_peak;
|
||||
float padding[3];
|
||||
} parameters;
|
||||
|
||||
float hable(float value) {
|
||||
return ((value * (0.15 * value + 0.05) + 0.004) /
|
||||
(value * (0.15 * value + 0.5) + 0.06)) - (0.02 / 0.3);
|
||||
}
|
||||
|
||||
vec3 hdr_to_sdr(vec3 rgb) {
|
||||
rgb = clamp(rgb, 0.0, 1.0);
|
||||
if (parameters.transfer == 1.0) {
|
||||
vec3 p = pow(rgb, vec3(1.0 / 78.84375));
|
||||
rgb = pow(max(p - 0.8359375, 0.0) / max(18.8515625 - 18.6875 * p, 0.000001), vec3(1.0 / 0.1593017578125)) * (10000.0 / 203.0);
|
||||
} else {
|
||||
rgb = mix(rgb * rgb / 3.0, (exp((rgb - 0.55991073) / 0.17883277) + 0.28466892) / 12.0, greaterThan(rgb, vec3(0.5)));
|
||||
float scene_luminance = dot(rgb, vec3(0.2627, 0.6780, 0.0593));
|
||||
rgb *= pow(max(scene_luminance, 0.0), 0.2) * (1000.0 / 203.0);
|
||||
}
|
||||
if (parameters.coefficients.x > 0.26) {
|
||||
rgb = mat3(1.6605, -0.1246, -0.0182, -0.5876, 1.1329, -0.1006, -0.0728, -0.0083, 1.1187) * rgb;
|
||||
}
|
||||
rgb = max(rgb, vec3(0.0));
|
||||
float signal = max(max(rgb.r, rgb.g), rgb.b);
|
||||
float mapped = hable(signal) / max(hable(parameters.source_peak), 0.000001);
|
||||
rgb *= mapped / max(signal, 0.000001);
|
||||
return mix(rgb * 12.92, 1.055 * pow(rgb, vec3(1.0 / 2.4)) - 0.055, greaterThan(rgb, vec3(0.0031308)));
|
||||
}
|
||||
|
||||
void main() {
|
||||
ivec2 pixel = ivec2(gl_GlobalInvocationID.xy);
|
||||
if (any(greaterThanEqual(pixel, parameters.size))) return;
|
||||
vec2 uv = (vec2(pixel) + 0.5) / vec2(parameters.size);
|
||||
if (parameters.rotation == 1) uv = vec2(uv.y, 1.0 - uv.x);
|
||||
else if (parameters.rotation == 2) uv = 1.0 - uv;
|
||||
else if (parameters.rotation == 3) uv = vec2(1.0 - uv.y, uv.x);
|
||||
ivec2 source = clamp(ivec2(uv * vec2(parameters.source_size)), ivec2(0), parameters.source_size - 1);
|
||||
float y = texelFetch(y_plane, source, 0).r * parameters.sample_scale;
|
||||
vec2 chroma = texelFetch(u_plane, source / 2, 0).rg;
|
||||
float u = chroma.r * parameters.sample_scale - 512.0;
|
||||
float v = (parameters.interleaved == 1 ? chroma.g : texelFetch(v_plane, source / 2, 0).r) * parameters.sample_scale - 512.0;
|
||||
if (parameters.full_range > 0.5) { y /= parameters.code_max; u /= parameters.code_max; v /= parameters.code_max; }
|
||||
else { y = (y - 64.0) / 876.0; u /= 896.0; v /= 896.0; }
|
||||
float kr = parameters.coefficients.x;
|
||||
float kb = parameters.coefficients.y;
|
||||
float kg = 1.0 - kr - kb;
|
||||
vec3 rgb = vec3(y + 2.0 * (1.0 - kr) * v,
|
||||
y - 2.0 * kb * (1.0 - kb) / kg * u - 2.0 * kr * (1.0 - kr) / kg * v,
|
||||
y + 2.0 * (1.0 - kb) * u);
|
||||
if (parameters.transfer > 0.0) rgb = hdr_to_sdr(rgb);
|
||||
imageStore(destination, pixel, vec4(clamp(rgb, 0.0, 1.0), 1.0));
|
||||
}
|
||||
)glsl";
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "video_decoder.h"
|
||||
#include "stream_identity.h"
|
||||
extern "C" {
|
||||
#include <libavcodec/avcodec.h>
|
||||
#include <libavformat/avformat.h>
|
||||
@@ -69,6 +70,7 @@ int VideoDecoder::open(const std::string &url, const std::string &authorization)
|
||||
av_dict_set(&options, "headers", header.c_str(), 0);
|
||||
}
|
||||
av_dict_set(&options, "tls_verify", "1", 0);
|
||||
av_dict_set(&options, "user_agent", JELLYFRAME_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);
|
||||
|
||||
@@ -56,7 +56,7 @@ public:
|
||||
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 >= 128 * 1048576 || buffered_packets >= 512;
|
||||
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; }
|
||||
@@ -64,6 +64,9 @@ public:
|
||||
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);
|
||||
|
||||
@@ -56,7 +56,7 @@ void VideoDecoder::read_loop() {
|
||||
std::unique_lock<std::mutex> lock(mutex);
|
||||
wake.wait(lock, [this] {
|
||||
return stopping || seek_target >= 0 || (!reader_ended &&
|
||||
packets.size() < 512 && buffered_bytes < 128 * 1048576 && buffered_ms < 4000);
|
||||
packets.size() < max_buffer_packets && buffered_bytes < max_buffer_bytes && buffered_ms < max_buffer_ms);
|
||||
});
|
||||
if (stopping) break;
|
||||
target = seek_target;
|
||||
|
||||
+3
-4
@@ -15,8 +15,8 @@ compatibility/default_parent_skeleton_in_mesh_instance_3d=true
|
||||
[application]
|
||||
|
||||
config/name="Jellyframe"
|
||||
config/version="0.1.1"
|
||||
config/description="A Jellyfin cinema for standalone VR."
|
||||
config/version="0.1.2"
|
||||
run/main_scene="res://scenes/main.tscn"
|
||||
config/use_custom_user_dir=true
|
||||
config/features=PackedStringArray("4.7", "GL Compatibility")
|
||||
@@ -36,11 +36,9 @@ enabled=PackedStringArray("res://addons/jellyframe_video/plugin.cfg")
|
||||
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)
|
||||
environment/defaults/default_clear_color=Color(0, 0, 0, 1)
|
||||
renderer/rendering_method.windows="mobile"
|
||||
renderer/rendering_method.linux="mobile"
|
||||
rendering_device/driver.windows="vulkan"
|
||||
rendering_device/driver.linuxbsd="vulkan"
|
||||
rendering_device/fallback_to_d3d12.windows=false
|
||||
rendering_device/fallback_to_opengl3.windows=true
|
||||
rendering_device/fallback_to_opengl3.linux=true
|
||||
@@ -54,5 +52,6 @@ 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
|
||||
@@ -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
|
||||
@@ -21,6 +21,7 @@ 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"
|
||||
@@ -35,6 +36,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"
|
||||
@@ -69,6 +71,7 @@ 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)
|
||||
|
||||
@@ -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.1"' % [name, device_id]
|
||||
var value := 'MediaBrowser Client="Jellyframe", Device="%s", DeviceId="%s", Version="0.1.2"' % [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"
|
||||
|
||||
@@ -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
|
||||
+217
-59
@@ -14,6 +14,9 @@ var libraries_loaded := false
|
||||
var library_generation := 0
|
||||
var page_generation := 0
|
||||
var page: VBoxContainer
|
||||
var settings_return_page: Control
|
||||
var settings_return_heading := ""
|
||||
var settings_return_status := ""
|
||||
var status: Label
|
||||
var keyboard: VRKeyboard
|
||||
var material: ShaderMaterial
|
||||
@@ -31,11 +34,17 @@ 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()
|
||||
|
||||
func _ready() -> void:
|
||||
settings.load_settings()
|
||||
@@ -56,9 +65,6 @@ func _ready() -> void:
|
||||
video.size_changed.connect(func(w: int, h: int): video_size = Vector2i(w, h); _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()
|
||||
@@ -73,10 +79,13 @@ func _setup_world() -> void:
|
||||
player_overlay.action.connect(_player_action)
|
||||
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 +102,17 @@ func _setup_ui() -> void:
|
||||
status = shell.status
|
||||
shell.navigate.connect(_open_library)
|
||||
shell.screen_settings.connect(_screen_settings)
|
||||
shell.return_from_settings.connect(_return_from_settings)
|
||||
shell.sign_out.connect(_sign_out)
|
||||
keyboard = VRKeyboard.new()
|
||||
viewport.add_child(keyboard)
|
||||
keyboard.hide()
|
||||
|
||||
func _clear_page() -> void:
|
||||
func _clear_page(keep_settings_return: bool = false) -> void:
|
||||
if not keep_settings_return and is_instance_valid(settings_return_page):
|
||||
settings_return_page.queue_free()
|
||||
settings_return_page = null
|
||||
shell.set_settings_mode(false)
|
||||
page_generation += 1
|
||||
countdown_generation += 1
|
||||
_cancel_countdown()
|
||||
@@ -141,8 +155,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 +248,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 SettingsView:
|
||||
_return_from_settings()
|
||||
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 +275,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 == settings_return_page:
|
||||
settings_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 +301,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 +323,94 @@ 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.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:
|
||||
if not from_start and playing and str(session.item.get("Id", "")) == str(item.get("Id", "")):
|
||||
if video.paused:
|
||||
video.toggle_pause()
|
||||
session.report("Progress", false)
|
||||
@@ -300,20 +420,26 @@ 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 _play_stream(url: String, source: Dictionary, start_ms: int) -> void:
|
||||
var keep_menu_open := playing and panel.visible
|
||||
decoder_ready = false
|
||||
if not seeking and not stereo_overridden: mode = Stereo.detect(source)
|
||||
if not seeking and not projection_overridden:
|
||||
@@ -323,10 +449,10 @@ func _play_stream(url: String, source: Dictionary, start_ms: int) -> void:
|
||||
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()
|
||||
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 +460,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 +469,33 @@ 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()
|
||||
"settings": _screen_settings()
|
||||
"library": _return_to_title()
|
||||
"hide": player_overlay.hide()
|
||||
|
||||
func _return_to_title() -> void:
|
||||
if photo_active:
|
||||
_show_library()
|
||||
return
|
||||
var item := session.item
|
||||
if item.is_empty():
|
||||
_show_library()
|
||||
@@ -382,11 +516,29 @@ 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
|
||||
for child in page.get_children():
|
||||
if child is LibraryView or child is DetailsView:
|
||||
settings_return_page = child
|
||||
settings_return_heading = shell.heading.text
|
||||
settings_return_status = status.text
|
||||
child.hide()
|
||||
child.reparent(viewport)
|
||||
_clear_page(true)
|
||||
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 +546,28 @@ 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 _return_from_settings() -> void:
|
||||
if not is_instance_valid(settings_return_page):
|
||||
_show_library()
|
||||
return
|
||||
var previous := settings_return_page
|
||||
settings_return_page = null
|
||||
_clear_page()
|
||||
previous.reparent(page)
|
||||
previous.show()
|
||||
shell.heading.text = settings_return_heading
|
||||
status.text = settings_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 +581,7 @@ 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)
|
||||
|
||||
func _select_projection(value: int) -> void:
|
||||
projection_mode = value
|
||||
@@ -446,9 +608,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 +621,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 +641,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 +648,24 @@ 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)
|
||||
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 +675,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 != "Transcode" or session.is_hls():
|
||||
video.seek(target)
|
||||
return
|
||||
seeking = true
|
||||
@@ -515,6 +692,9 @@ func _seek_to(milliseconds: int) -> void:
|
||||
await session.open(session.item, true)
|
||||
|
||||
func _stop(show_library: bool = true, completed: bool = false) -> void:
|
||||
_close_photo()
|
||||
media_segments.clear()
|
||||
world.player_skip_popup.update_segment({})
|
||||
countdown_generation += 1
|
||||
_cancel_countdown()
|
||||
world.player_loading.hide()
|
||||
@@ -532,12 +712,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 +754,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 +762,8 @@ func _video_failed(message: String) -> void:
|
||||
await session.retry_transcode()
|
||||
|
||||
func _error(message: String) -> void:
|
||||
media_segments.clear()
|
||||
world.player_skip_popup.update_segment({})
|
||||
countdown_generation += 1
|
||||
_cancel_countdown()
|
||||
world.player_loading.hide()
|
||||
@@ -618,6 +791,7 @@ func _sign_out() -> void:
|
||||
_show_login()
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
_update_screen_follow()
|
||||
if not playing: return
|
||||
_update_loading()
|
||||
timeline_elapsed += delta
|
||||
@@ -633,19 +807,3 @@ 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 _calibrate() -> void:
|
||||
transcode_offset = 0
|
||||
decoder_ready = false
|
||||
calibration = true
|
||||
mode = Stereo.Mode.HALF_SBS
|
||||
video_size = Vector2i(1920, 1080)
|
||||
_resize_screen()
|
||||
_recenter()
|
||||
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()
|
||||
@@ -29,7 +29,17 @@ func open(url: String, authorization: String, start_ms: int) -> void:
|
||||
if not error.is_empty(): failed.emit(error)
|
||||
|
||||
func _set_texture(texture: Texture2D) -> void:
|
||||
if material != null: material.set_shader_parameter("video_texture", texture)
|
||||
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:
|
||||
|
||||
@@ -2,6 +2,7 @@ class_name DetailsView
|
||||
extends VBoxContainer
|
||||
|
||||
signal play_requested(item: Dictionary)
|
||||
signal play_from_start_requested(item: Dictionary)
|
||||
signal back_requested
|
||||
|
||||
var item: Dictionary
|
||||
@@ -12,6 +13,7 @@ var scroll: ScrollContainer
|
||||
var body: VBoxContainer
|
||||
var series_browser: SeriesBrowser
|
||||
var play_button: Button
|
||||
var play_from_start_button: Button
|
||||
var back_button: Button
|
||||
var title_label: Label
|
||||
var overview_label: Label
|
||||
@@ -111,7 +113,9 @@ func _build_playback(parent: VBoxContainer) -> void:
|
||||
return
|
||||
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.custom_minimum_size = Vector2(200, 64)
|
||||
@@ -126,6 +130,8 @@ func _build_playback(parent: VBoxContainer) -> void:
|
||||
focus.border_color = Color("071c20")
|
||||
play_button.add_theme_stylebox_override("focus", focus)
|
||||
if position > 0:
|
||||
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)
|
||||
_muted("Continue from " + DetailsMetadata.duration(position), parent, 20)
|
||||
|
||||
func _build_information() -> void:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -42,6 +42,8 @@ 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 {}
|
||||
if user.get("Played", false):
|
||||
Widgets.watched_badge(frame)
|
||||
var position := int(user.get("PlaybackPositionTicks", 0))
|
||||
if position > 0: facts.append("Continue from " + DetailsMetadata.duration(position))
|
||||
if not facts.is_empty():
|
||||
@@ -50,6 +52,8 @@ func _ready() -> void:
|
||||
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"
|
||||
|
||||
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
|
||||
@@ -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
|
||||
@@ -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,14 @@ 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 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 +33,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 +54,69 @@ 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
|
||||
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)
|
||||
var tracks := _add_control("Audio and subtitles (coming soon)", "tracks", "subtitles", row)
|
||||
tracks.disabled = true
|
||||
tracks.mouse_default_cursor_shape = Control.CURSOR_ARROW
|
||||
tracks.add_theme_color_override("icon_disabled_color", Color("8793a8"))
|
||||
_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()
|
||||
|
||||
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
|
||||
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
|
||||
@@ -83,7 +134,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
|
||||
@@ -0,0 +1 @@
|
||||
uid://xa6vnyg643ki
|
||||
@@ -0,0 +1,42 @@
|
||||
extends HSlider
|
||||
|
||||
var pending_steps := 0.0
|
||||
|
||||
func _init() -> void:
|
||||
scrollable = false
|
||||
tooltip_text = "Point here and move the joystick left or right to adjust."
|
||||
var track := Widgets.surface(Color("445268"), 7)
|
||||
track.content_margin_top = 7
|
||||
track.content_margin_bottom = 7
|
||||
add_theme_stylebox_override("slider", track)
|
||||
var fill := track.duplicate() as StyleBoxFlat
|
||||
fill.bg_color = Color("6de3cf")
|
||||
add_theme_stylebox_override("grabber_area", fill)
|
||||
add_theme_stylebox_override("grabber_area_highlight", fill)
|
||||
_style_grabbers()
|
||||
mouse_exited.connect(func(): pending_steps = 0.0)
|
||||
|
||||
func _style_grabbers() -> void:
|
||||
for state in ["grabber", "grabber_highlight", "grabber_disabled"]:
|
||||
var color := Color("8793a8") if state == "grabber_disabled" else Color("6de3cf")
|
||||
if state == "grabber_highlight": color = Color.WHITE
|
||||
var gradient := Gradient.new()
|
||||
gradient.colors = PackedColorArray([color, color, Color(color, 0)])
|
||||
gradient.offsets = PackedFloat32Array([0.0, 0.85, 1.0])
|
||||
var texture := GradientTexture2D.new()
|
||||
texture.gradient = gradient
|
||||
texture.width = 26
|
||||
texture.height = 26
|
||||
texture.fill = GradientTexture2D.FILL_RADIAL
|
||||
texture.fill_from = Vector2(0.5, 0.5)
|
||||
texture.fill_to = Vector2(1.0, 0.5)
|
||||
add_theme_icon_override(state, texture)
|
||||
|
||||
func adjust_horizontal(amount: float) -> void:
|
||||
if not editable: return
|
||||
if signf(amount) != signf(pending_steps): pending_steps = 0.0
|
||||
pending_steps += amount
|
||||
var steps := int(pending_steps)
|
||||
if steps == 0: return
|
||||
pending_steps -= steps
|
||||
value += steps * step
|
||||
+138
-69
@@ -1,8 +1,8 @@
|
||||
class_name SettingsView
|
||||
extends VBoxContainer
|
||||
|
||||
signal back_requested
|
||||
signal screen_changed
|
||||
signal screen_follow_changed
|
||||
signal environment_changed
|
||||
signal stereo_selected(value: int)
|
||||
signal projection_selected(value: int)
|
||||
@@ -24,34 +24,38 @@ var transcode_choices: HFlowContainer
|
||||
var sections: Array[VBoxContainer] = []
|
||||
var scroll: ScrollContainer
|
||||
var dragging_slider := false
|
||||
var navigation_host: Container
|
||||
var navigation: GridContainer
|
||||
var navigation_buttons: Array[Button] = []
|
||||
var section_heading: Label
|
||||
var sort_order_choices: HBoxContainer
|
||||
var environment_choices: VBoxContainer
|
||||
var environment_buttons: Dictionary = {}
|
||||
var follow_switch: CheckButton
|
||||
|
||||
const SECTION_TITLES := ["Library sorting", "Screen", "Environment", "Playback"]
|
||||
const SECTION_ICONS := [
|
||||
preload("res://assets/icons/material/video_library.svg"),
|
||||
preload("res://assets/icons/material/tv.svg"),
|
||||
preload("res://assets/icons/material/home.svg"),
|
||||
preload("res://assets/icons/material/play_arrow.svg")
|
||||
]
|
||||
|
||||
func _ready() -> void:
|
||||
add_theme_constant_override("separation", 18)
|
||||
var navigation := HFlowContainer.new()
|
||||
navigation.add_theme_constant_override("h_separation", 12)
|
||||
navigation.add_theme_constant_override("v_separation", 12)
|
||||
add_child(navigation)
|
||||
var group := ButtonGroup.new()
|
||||
for index in range(4):
|
||||
var title: String = ["Library sorting", "Screen", "Environment", "Playback"][index]
|
||||
var button := Widgets.button(title, func(): _show_section(index), navigation)
|
||||
button.toggle_mode = true
|
||||
button.button_group = group
|
||||
button.button_pressed = index == 0
|
||||
button.accessibility_name = title
|
||||
button.text = ("● " if index == 0 else "") + title
|
||||
button.toggled.connect(func(selected: bool): button.text = ("● " if selected else "") + title)
|
||||
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
add_theme_constant_override("separation", 20)
|
||||
_build_navigation()
|
||||
section_heading = Widgets.label(SECTION_TITLES[0], self, 28)
|
||||
scroll = ScrollContainer.new()
|
||||
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
||||
scroll.follow_focus = true
|
||||
add_child(scroll)
|
||||
var body := VBoxContainer.new()
|
||||
body.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
scroll.add_child(body)
|
||||
for index in range(4):
|
||||
var section := VBoxContainer.new()
|
||||
section.add_theme_constant_override("separation", 18)
|
||||
section.add_theme_constant_override("separation", 24)
|
||||
section.visible = index == 0
|
||||
body.add_child(section)
|
||||
sections.append(section)
|
||||
@@ -59,46 +63,93 @@ func _ready() -> void:
|
||||
_build_screen(sections[1])
|
||||
_build_environment(sections[2])
|
||||
_build_playback(sections[3])
|
||||
Widgets.button("Back", func(): back_requested.emit(), self)
|
||||
resized.connect(_adapt_columns)
|
||||
scroll.resized.connect(_adapt_columns)
|
||||
_adapt_columns()
|
||||
|
||||
func _build_navigation() -> void:
|
||||
navigation = GridContainer.new()
|
||||
navigation.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
|
||||
navigation.add_theme_constant_override("v_separation", 8)
|
||||
navigation.add_theme_constant_override("h_separation", 8)
|
||||
navigation_host.add_child(navigation)
|
||||
var group := ButtonGroup.new()
|
||||
for index in range(4):
|
||||
var title: String = SECTION_TITLES[index]
|
||||
var button := Widgets.button(title, func(): _show_section(index), navigation)
|
||||
button.toggle_mode = true
|
||||
button.button_group = group
|
||||
button.button_pressed = index == 0
|
||||
button.accessibility_name = title
|
||||
button.alignment = HORIZONTAL_ALIGNMENT_LEFT
|
||||
button.icon = SECTION_ICONS[index]
|
||||
button.expand_icon = true
|
||||
button.add_theme_constant_override("icon_max_width", 24)
|
||||
button.add_theme_constant_override("h_separation", 12)
|
||||
button.add_theme_font_size_override("font_size", 19)
|
||||
button.custom_minimum_size = Vector2(0, 64)
|
||||
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_style_control(button)
|
||||
navigation_buttons.append(button)
|
||||
|
||||
func _show_section(index: int) -> void:
|
||||
for section_index in range(sections.size()):
|
||||
sections[section_index].visible = section_index == index
|
||||
navigation_buttons[index].button_pressed = true
|
||||
section_heading.text = SECTION_TITLES[index]
|
||||
scroll.scroll_vertical = 0
|
||||
|
||||
func _card(parent: VBoxContainer, title: String = "") -> VBoxContainer:
|
||||
var panel := PanelContainer.new()
|
||||
panel.add_theme_stylebox_override("panel", Widgets.surface(Color("162133"), 20, 20))
|
||||
parent.add_child(panel)
|
||||
var body := VBoxContainer.new()
|
||||
body.add_theme_constant_override("separation", 12)
|
||||
panel.add_child(body)
|
||||
if not title.is_empty(): Widgets.label(title, body, 22)
|
||||
return body
|
||||
|
||||
func _style_control(control: Button) -> void:
|
||||
for state in ["normal", "hover", "pressed", "hover_pressed", "disabled", "focus"]:
|
||||
var color := Color("202a3d")
|
||||
if state in ["hover", "disabled"]: color = Color("34435b")
|
||||
if state in ["pressed", "hover_pressed"]: color = Color("2b4b50")
|
||||
if state == "focus": color = Color(0, 0, 0, 0)
|
||||
var style := Widgets.surface(color, 12, 12)
|
||||
if state in ["hover", "hover_pressed", "focus"]:
|
||||
style.set_border_width_all(3)
|
||||
style.border_color = Color("6de3cf")
|
||||
control.add_theme_stylebox_override(state, style)
|
||||
control.add_theme_color_override("font_disabled_color", Color("8793a8"))
|
||||
|
||||
func _hint(message: String, parent: Node) -> void:
|
||||
var label := Widgets.label(message, parent, 18)
|
||||
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
label.add_theme_color_override("font_color", Color("a4aec0"))
|
||||
|
||||
func _build_library(parent: VBoxContainer) -> void:
|
||||
_hint("Choose how to sort your libraries. Home always shows the newest additions first.", parent)
|
||||
Widgets.label("Sort by", parent, 26)
|
||||
_hint("Choose how to sort your libraries. Home shows the newest additions first.", parent)
|
||||
var order_card := _card(parent, "Sort order")
|
||||
sort_order_choices = HBoxContainer.new()
|
||||
sort_order_choices.add_theme_constant_override("separation", 12)
|
||||
order_card.add_child(sort_order_choices)
|
||||
var order_group := ButtonGroup.new()
|
||||
for value in ["Ascending", "Descending"]:
|
||||
_choice(value, value == settings.library_sort_order, order_group, sort_order_choices,
|
||||
func(): settings.library_sort_order = value; _save())
|
||||
_hint("Ascending sorts A to Z, lowest to highest, or oldest to newest. Descending reverses the order. This setting does not affect Random.", order_card)
|
||||
var sort_card := _card(parent, "Sort by")
|
||||
sort_choices = GridContainer.new()
|
||||
sort_choices.columns = 2
|
||||
sort_choices.add_theme_constant_override("h_separation", 18)
|
||||
sort_choices.add_theme_constant_override("h_separation", 8)
|
||||
sort_choices.add_theme_constant_override("v_separation", 8)
|
||||
parent.add_child(sort_choices)
|
||||
sort_card.add_child(sort_choices)
|
||||
var group := ButtonGroup.new()
|
||||
for option in JellyframeSettings.LIBRARY_SORT_OPTIONS:
|
||||
_choice(option[0], option[1] == settings.library_sort_by, group, sort_choices,
|
||||
func(): settings.library_sort_by = option[1]; _save())
|
||||
var order_heading := Widgets.label("Sort order", parent, 26)
|
||||
var order := HBoxContainer.new()
|
||||
order.add_theme_constant_override("separation", 18)
|
||||
parent.add_child(order)
|
||||
var order_group := ButtonGroup.new()
|
||||
for value in ["Ascending", "Descending"]:
|
||||
_choice(value, value == settings.library_sort_order, order_group, order,
|
||||
func(): settings.library_sort_order = value; _save())
|
||||
parent.move_child(order_heading, 1)
|
||||
parent.move_child(order, 2)
|
||||
_hint("Ascending sorts A to Z, low to high or oldest first. Descending reverses that order. Sort order does not affect Random.", parent)
|
||||
|
||||
func _build_playback(parent: VBoxContainer) -> void:
|
||||
Widgets.label("Playback quality", parent, 26)
|
||||
parent = _card(parent, "Playback quality")
|
||||
_hint("Choose the video quality. Your choice applies to the next video you start.", parent)
|
||||
playback_choices = VBoxContainer.new()
|
||||
playback_choices.add_theme_constant_override("separation", 12)
|
||||
@@ -148,12 +199,7 @@ func _choice(title: String, selected: bool, group: ButtonGroup, parent: Node, ac
|
||||
choice.custom_minimum_size = Vector2(0, 56)
|
||||
choice.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
choice.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
|
||||
for state in ["normal", "hover", "pressed", "hover_pressed", "focus"]:
|
||||
var style := Widgets.surface(Color("2b4b50") if state in ["pressed", "hover_pressed"] else Color("202a3d"), 12, 8)
|
||||
if state != "normal":
|
||||
style.set_border_width_all(2)
|
||||
style.border_color = Color("6de3cf")
|
||||
choice.add_theme_stylebox_override(state, style)
|
||||
_style_control(choice)
|
||||
choice.pressed.connect(action)
|
||||
parent.add_child(choice)
|
||||
return choice
|
||||
@@ -164,6 +210,7 @@ func _build_screen(parent: VBoxContainer) -> void:
|
||||
_build_screen_position(parent)
|
||||
|
||||
func _build_screen_position(parent: VBoxContainer) -> void:
|
||||
parent = _card(parent)
|
||||
var header := HFlowContainer.new()
|
||||
header.add_theme_constant_override("h_separation", 18)
|
||||
header.add_theme_constant_override("v_separation", 12)
|
||||
@@ -188,7 +235,7 @@ func _screen_position_hint() -> String:
|
||||
return "Set the screen size, distance, height and curve."
|
||||
|
||||
func _build_video_format(parent: VBoxContainer) -> void:
|
||||
Widgets.label("Video format", parent, 26)
|
||||
parent = _card(parent, "Picture format")
|
||||
_hint("Jellyframe detects the 3D format. Choose a different format if the picture looks wrong.", parent)
|
||||
stereo_choices = HFlowContainer.new()
|
||||
stereo_choices.add_theme_constant_override("h_separation", 12)
|
||||
@@ -196,18 +243,21 @@ func _build_video_format(parent: VBoxContainer) -> void:
|
||||
var group := ButtonGroup.new()
|
||||
for entry in [["2D", Stereo.Mode.MONO], ["Half-SBS", Stereo.Mode.HALF_SBS], ["Full-SBS", Stereo.Mode.FULL_SBS], ["Top-bottom", Stereo.Mode.TOP_BOTTOM]]:
|
||||
_choice(entry[0], entry[1] == stereo_mode, group, stereo_choices, func(): stereo_mode = entry[1]; stereo_selected.emit(entry[1]))
|
||||
var swap := CheckBox.new()
|
||||
var swap := CheckButton.new()
|
||||
swap.text = "Swap left and right eyes"
|
||||
swap.accessibility_name = swap.text
|
||||
_style_control(swap)
|
||||
swap.custom_minimum_size.y = 56
|
||||
swap.button_pressed = swap_eyes
|
||||
swap.toggled.connect(func(enabled: bool): swap_eyes = enabled; eyes_swapped.emit())
|
||||
parent.add_child(swap)
|
||||
|
||||
func _build_projection(parent: VBoxContainer) -> void:
|
||||
Widgets.label("Video projection", parent, 26)
|
||||
parent = _card(parent, "Video projection")
|
||||
_hint("180° fills the view in front of you. 360° surrounds you. Both use equirectangular video and hide the environment during playback. Fisheye and cubemap layouts are not supported.", parent)
|
||||
var choices := HBoxContainer.new()
|
||||
choices.add_theme_constant_override("separation", 12)
|
||||
var choices := HFlowContainer.new()
|
||||
choices.add_theme_constant_override("h_separation", 12)
|
||||
choices.add_theme_constant_override("v_separation", 12)
|
||||
parent.add_child(choices)
|
||||
var group := ButtonGroup.new()
|
||||
for entry in [["Screen", VideoProjection.Mode.SCREEN], ["180°", VideoProjection.Mode.EQUIRECTANGULAR_180], ["360°", VideoProjection.Mode.EQUIRECTANGULAR]]:
|
||||
@@ -217,28 +267,46 @@ func _build_projection(parent: VBoxContainer) -> void:
|
||||
_rebuild_screen.call_deferred())
|
||||
|
||||
func _build_environment(parent: VBoxContainer) -> void:
|
||||
Widgets.label("Environment", parent, 26)
|
||||
_hint("Choose a background for your video screen.", parent)
|
||||
var environments := GridContainer.new()
|
||||
environments.columns = 2
|
||||
environments.add_theme_constant_override("h_separation", 12)
|
||||
environments.add_theme_constant_override("v_separation", 12)
|
||||
parent.add_child(environments)
|
||||
var environment_group := ButtonGroup.new()
|
||||
for value in ["Void", "Cinema", "Theatre", "Passthrough"]:
|
||||
var choice := _choice(value, value == settings.environment, environment_group, environments,
|
||||
_hint("Choose a background for your screen.", parent)
|
||||
environment_choices = VBoxContainer.new()
|
||||
environment_choices.add_theme_constant_override("separation", 16)
|
||||
parent.add_child(environment_choices)
|
||||
var adjustable := _card(environment_choices, "Void & Passthrough")
|
||||
var fixed := _card(environment_choices, "Cinema & Theatre")
|
||||
var group := ButtonGroup.new()
|
||||
var descriptions := [
|
||||
"A dark background. Change the screen size and position in Screen settings.",
|
||||
"See your room around the screen. Change its size and position in Screen settings." if passthrough_supported else "This device does not support passthrough.",
|
||||
"A cinema with a fixed screen. Recenter to face it.",
|
||||
"A simpler cinema with a fixed screen. Recenter to face it."
|
||||
]
|
||||
var values := ["Void", "Passthrough", "Cinema", "Theatre"]
|
||||
for index in range(values.size()):
|
||||
var value: String = values[index]
|
||||
var card := adjustable if index < 2 else fixed
|
||||
var choice := _choice(value, value == settings.environment, group, card,
|
||||
func(): settings.environment = value; _rebuild_screen(); environment_changed.emit(); _save())
|
||||
environment_buttons[value] = choice
|
||||
_hint(descriptions[index], card)
|
||||
if value == "Passthrough" and not passthrough_supported:
|
||||
choice.disabled = true
|
||||
choice.tooltip_text = "This device does not support passthrough."
|
||||
Widgets.label("Void", parent, 24)
|
||||
_hint("A dark background with an adjustable screen. Use the Screen page to change its size and position.", parent)
|
||||
Widgets.label("Cinema", parent, 24)
|
||||
_hint("A cinema with a fixed screen. Black bars keep the picture in proportion. Recenter to face the screen.", parent)
|
||||
Widgets.label("Theatre", parent, 24)
|
||||
_hint("The original simple cinema uses fewer polygons. It has its own fixed screen and supports recentering.", parent)
|
||||
Widgets.label("Passthrough", parent, 24)
|
||||
_hint("Your room surrounds an adjustable screen. Use the Screen page to change its size and position." if passthrough_supported else "This device does not support passthrough.", parent)
|
||||
choice.tooltip_text = descriptions[index]
|
||||
_build_follow_switch(adjustable)
|
||||
|
||||
func _build_follow_switch(parent: VBoxContainer) -> void:
|
||||
var toggle := CheckButton.new()
|
||||
toggle.text = "Follow your movement"
|
||||
toggle.accessibility_name = toggle.text
|
||||
toggle.button_pressed = settings.follow_screen
|
||||
toggle.custom_minimum_size.y = 56
|
||||
_style_control(toggle)
|
||||
parent.add_child(toggle)
|
||||
follow_switch = toggle
|
||||
toggle.toggled.connect(func(enabled: bool):
|
||||
settings.follow_screen = enabled
|
||||
screen_follow_changed.emit()
|
||||
_save())
|
||||
_hint("The screen follows you as you move or turn. Applies to both Void and Passthrough. Does not affect 180° or 360° video.", parent)
|
||||
|
||||
func _reset_screen() -> void:
|
||||
settings.reset_screen()
|
||||
@@ -266,7 +334,7 @@ func _slider(entry: Array, parent: Node) -> void:
|
||||
var name_label := Widgets.label(entry[0], labels, 22)
|
||||
name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
var value_label := Widgets.label("%.2f%s" % [settings.get(entry[1]), entry[4]], labels, 20)
|
||||
var slider := HSlider.new()
|
||||
var slider := preload("res://scripts/ui/screen_position_slider.gd").new()
|
||||
slider.accessibility_name = entry[0]
|
||||
slider.custom_minimum_size.y = 48
|
||||
slider.min_value = entry[2]
|
||||
@@ -287,7 +355,8 @@ func _save() -> void:
|
||||
status.emit("Settings saved." if settings.save() == OK else "Could not save settings. Check available storage and try again.")
|
||||
|
||||
func _adapt_columns() -> void:
|
||||
var content_width := scroll.size.x - 40
|
||||
if sort_choices != null:
|
||||
sort_choices.columns = 3 if size.x >= 980 else (2 if size.x >= 650 else 1)
|
||||
sort_choices.columns = 2 if content_width >= 540 else 1
|
||||
if screen_dimensions != null:
|
||||
screen_dimensions.columns = 2 if size.x >= 650 else 1
|
||||
screen_dimensions.columns = 2 if content_width >= 540 else 1
|
||||
+40
-4
@@ -4,15 +4,20 @@ extends HBoxContainer
|
||||
signal navigate(id: String, title: String)
|
||||
signal screen_settings
|
||||
signal sign_out
|
||||
signal return_from_settings
|
||||
var page: VBoxContainer
|
||||
var status: Label
|
||||
var libraries: VBoxContainer
|
||||
var settings_navigation: VBoxContainer
|
||||
var navigation_heading: Label
|
||||
var home: Button
|
||||
var settings_button: Button
|
||||
var logout: Button
|
||||
var heading: Label
|
||||
var rail: PanelContainer
|
||||
var selected_id := ""
|
||||
var signed_in := false
|
||||
var settings_mode := false
|
||||
var buttons: Dictionary = {}
|
||||
var rail_background: StyleBoxFlat
|
||||
var main_background: StyleBoxFlat
|
||||
@@ -41,17 +46,25 @@ func _ready() -> void:
|
||||
logo.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
logo.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
navigation.add_child(logo)
|
||||
home = Widgets.button("Home", func(): navigate.emit("", "Home"), navigation)
|
||||
home = Widgets.button("Home", _home_pressed, navigation)
|
||||
_style_navigation_button(home, preload("res://assets/icons/material/home.svg"))
|
||||
Widgets.label("LIBRARIES", navigation, 14).add_theme_color_override("font_color", Color("a4aec0"))
|
||||
navigation_heading = Widgets.label("LIBRARIES", navigation, 14)
|
||||
navigation_heading.add_theme_color_override("font_color", Color("a4aec0"))
|
||||
var scroll := ScrollContainer.new()
|
||||
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
||||
scroll.follow_focus = true
|
||||
navigation.add_child(scroll)
|
||||
var lists := VBoxContainer.new()
|
||||
lists.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
scroll.add_child(lists)
|
||||
libraries = VBoxContainer.new()
|
||||
libraries.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
libraries.add_theme_constant_override("separation", 10)
|
||||
scroll.add_child(libraries)
|
||||
lists.add_child(libraries)
|
||||
settings_navigation = VBoxContainer.new()
|
||||
settings_navigation.hide()
|
||||
lists.add_child(settings_navigation)
|
||||
settings_button = Widgets.button("Settings", func(): screen_settings.emit(), navigation)
|
||||
_style_navigation_button(settings_button, preload("res://assets/icons/material/settings.svg"))
|
||||
logout = Widgets.button("Sign out", func(): sign_out.emit(), navigation)
|
||||
@@ -75,14 +88,37 @@ func _ready() -> void:
|
||||
set_signed_in(false)
|
||||
|
||||
func set_signed_in(value: bool) -> void:
|
||||
set_settings_mode(false)
|
||||
signed_in = value
|
||||
rail.visible = value
|
||||
home.disabled = not value
|
||||
settings_button.disabled = not value
|
||||
logout.visible = value
|
||||
if not value:
|
||||
heading.text = ""
|
||||
clear_libraries()
|
||||
Widgets.label("Sign in to browse", libraries, 17)
|
||||
|
||||
func set_settings_mode(enabled: bool) -> void:
|
||||
settings_mode = enabled
|
||||
home.text = "Library" if enabled else "Home"
|
||||
home.icon = preload("res://assets/icons/material/video_library.svg") if enabled else preload("res://assets/icons/material/home.svg")
|
||||
libraries.visible = not enabled
|
||||
settings_navigation.visible = enabled
|
||||
navigation_heading.text = "SETTINGS" if enabled else "LIBRARIES"
|
||||
settings_button.visible = not enabled
|
||||
logout.visible = signed_in and not enabled
|
||||
if not enabled:
|
||||
for child in settings_navigation.get_children():
|
||||
settings_navigation.remove_child(child)
|
||||
child.queue_free()
|
||||
|
||||
func _home_pressed() -> void:
|
||||
if settings_mode:
|
||||
return_from_settings.emit()
|
||||
else:
|
||||
navigate.emit("", "Home")
|
||||
|
||||
func clear_libraries() -> void:
|
||||
buttons.clear()
|
||||
for child in libraries.get_children():
|
||||
@@ -93,7 +129,7 @@ func set_libraries(items: Array) -> void:
|
||||
clear_libraries()
|
||||
for item in items:
|
||||
var library_type := str(item.get("CollectionType") if item.get("CollectionType") != null else "").to_lower()
|
||||
if library_type not in ["", "mixed", "movies", "tvshows", "homevideos"]: continue
|
||||
if library_type not in ["", "mixed", "movies", "tvshows", "homevideos", "photos"]: continue
|
||||
var id := str(item.get("Id", ""))
|
||||
var title := str(item.get("Name", "Library"))
|
||||
var button := Widgets.button(title, func(): navigate.emit(id, title), libraries)
|
||||
|
||||
@@ -26,6 +26,22 @@ static func input(placeholder: String, parent: Node, secret: bool = false) -> Li
|
||||
parent.add_child(result)
|
||||
return result
|
||||
|
||||
static 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
|
||||
|
||||
static func theme() -> Theme:
|
||||
var result := Theme.new()
|
||||
result.default_font_size = 22
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
class_name VrOverlayMaterial
|
||||
extends RefCounted
|
||||
|
||||
const DEPTH_SCALE := 0.01
|
||||
|
||||
static func apply_depth(material: BaseMaterial3D) -> void:
|
||||
# Menus and controllers share a depth range in front of the room.
|
||||
material.use_z_clip_scale = true
|
||||
material.z_clip_scale = DEPTH_SCALE
|
||||
material.no_depth_test = false
|
||||
@@ -0,0 +1 @@
|
||||
uid://bf207wru26usd
|
||||
+10
-1
@@ -44,13 +44,22 @@ func set_target(target: Node3D, target_viewport: SubViewport, target_size: Vecto
|
||||
input.viewport = viewport
|
||||
panel_size = target_size
|
||||
|
||||
func points_at(target: Node3D, target_size: Vector2) -> bool:
|
||||
var inverse := target.global_transform.affine_inverse()
|
||||
var origin := inverse * global_position
|
||||
var direction := inverse.basis * -global_basis.z
|
||||
if absf(direction.z) <= 0.00001: return false
|
||||
var distance := -origin.z / direction.z
|
||||
var hit := origin + direction * distance
|
||||
return distance > 0 and absf(hit.x) < target_size.x / 2 and absf(hit.y) < target_size.y / 2
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
input.sample_trigger(get_float("trigger"))
|
||||
if input.just_pressed and get_is_active():
|
||||
if is_instance_valid(active_pointer) and active_pointer != self:
|
||||
active_pointer._cancel()
|
||||
active_pointer = self
|
||||
if (is_instance_valid(active_pointer) and active_pointer != self) or not get_is_active() or not panel.is_visible_in_tree():
|
||||
if not is_visible_in_tree() or (is_instance_valid(active_pointer) and active_pointer != self) or not get_is_active() or not panel.is_visible_in_tree():
|
||||
_cancel()
|
||||
return
|
||||
var inverse := panel.global_transform.affine_inverse()
|
||||
|
||||
@@ -29,11 +29,11 @@ func aim(point: Vector2, inside: bool) -> void:
|
||||
_button(false)
|
||||
|
||||
func scroll(point: Vector2, amount: float, horizontal: bool = false) -> void:
|
||||
var wheel := InputEventMouseButton.new()
|
||||
if horizontal:
|
||||
wheel.button_index = MOUSE_BUTTON_WHEEL_RIGHT if amount > 0 else MOUSE_BUTTON_WHEEL_LEFT
|
||||
else:
|
||||
wheel.button_index = MOUSE_BUTTON_WHEEL_UP if amount > 0 else MOUSE_BUTTON_WHEEL_DOWN
|
||||
_scroll_horizontal(amount)
|
||||
return
|
||||
var wheel := InputEventMouseButton.new()
|
||||
wheel.button_index = MOUSE_BUTTON_WHEEL_UP if amount > 0 else MOUSE_BUTTON_WHEEL_DOWN
|
||||
wheel.pressed = true
|
||||
wheel.position = point
|
||||
wheel.global_position = point
|
||||
@@ -43,6 +43,20 @@ func scroll(point: Vector2, amount: float, horizontal: bool = false) -> void:
|
||||
release.pressed = false
|
||||
viewport.push_input(release, true)
|
||||
|
||||
func _scroll_horizontal(amount: float) -> void:
|
||||
var target: Node = viewport.gui_get_hovered_control()
|
||||
while target != null:
|
||||
if target.has_method("adjust_horizontal"):
|
||||
target.adjust_horizontal(amount)
|
||||
return
|
||||
if target is ScrollContainer and target.horizontal_scroll_mode != ScrollContainer.SCROLL_MODE_DISABLED:
|
||||
target.scroll_horizontal += int(signf(amount) * maxf(1.0, absf(amount) * 80.0))
|
||||
return
|
||||
if target is CastCarousel:
|
||||
target.scroll.scroll_horizontal += int(signf(amount) * maxf(1.0, absf(amount) * 80.0))
|
||||
return
|
||||
target = target.get_parent()
|
||||
|
||||
func cancel() -> void:
|
||||
if previous != Vector2(-1, -1): _move(Vector2(-1, -1))
|
||||
if mouse_down:
|
||||
|
||||
+48
-4
@@ -2,6 +2,8 @@ class_name ViewingWorld
|
||||
extends Node3D
|
||||
|
||||
signal menu_requested
|
||||
signal settings_requested
|
||||
signal library_requested
|
||||
var settings: JellyframeSettings
|
||||
var camera: Camera3D
|
||||
var viewport: SubViewport
|
||||
@@ -15,7 +17,10 @@ var theatre: TheatreEnvironment
|
||||
var passthrough := PassthroughEnvironment.new()
|
||||
var player_overlay: PlayerOverlay
|
||||
var player_loading: PlayerLoading
|
||||
var player_skip_popup: PlayerSkipPopup
|
||||
var skip_screen_size := Vector3(4.0, 16.0 / 9.0, 0.0)
|
||||
var pointers: Array[LaserPointer] = []
|
||||
var controller_models: OpenXRRenderModelManager
|
||||
|
||||
func setup() -> void:
|
||||
var xr := XRServer.find_interface("OpenXR")
|
||||
@@ -53,8 +58,8 @@ func setup() -> void:
|
||||
ui_material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
ui_material.albedo_texture = viewport.get_texture()
|
||||
ui_material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||
ui_material.no_depth_test = true
|
||||
ui_material.render_priority = 100
|
||||
VrOverlayMaterial.apply_depth(ui_material)
|
||||
panel.material_override = ui_material
|
||||
add_child(panel)
|
||||
screen = MeshInstance3D.new()
|
||||
@@ -72,16 +77,20 @@ func setup() -> void:
|
||||
screen.add_child(player_overlay)
|
||||
player_loading = PlayerLoading.new()
|
||||
screen.add_child(player_loading)
|
||||
player_skip_popup = PlayerSkipPopup.new()
|
||||
screen.add_child(player_skip_popup)
|
||||
_build_theatre()
|
||||
if immersive:
|
||||
controller_models = OpenXRRenderModelManager.new()
|
||||
controller_models.render_model_added.connect(_prepare_controller_model)
|
||||
origin.add_child(controller_models)
|
||||
for hand in ["left_hand", "right_hand"]:
|
||||
var pointer := LaserPointer.new()
|
||||
pointer.tracker = hand
|
||||
pointer.pose = "aim"
|
||||
pointer.panel = panel
|
||||
pointer.viewport = viewport
|
||||
pointer.button_pressed.connect(func(action: String):
|
||||
if action in ["menu_button", "ax_button", "by_button"]: menu_requested.emit())
|
||||
pointer.button_pressed.connect(_controller_button_pressed.bind(pointer.tracker))
|
||||
origin.add_child(pointer)
|
||||
pointers.append(pointer)
|
||||
else:
|
||||
@@ -94,6 +103,15 @@ func setup() -> void:
|
||||
overlay.add_child(container)
|
||||
viewport.reparent(container)
|
||||
|
||||
func _controller_button_pressed(action: String, hand: StringName) -> void:
|
||||
if action == "menu_button" and hand == &"left_hand": return
|
||||
if action == "by_button" and hand == &"left_hand":
|
||||
settings_requested.emit()
|
||||
elif action == "by_button" and hand == &"right_hand":
|
||||
library_requested.emit()
|
||||
elif action in ["menu_button", "ax_button", "by_button"]:
|
||||
menu_requested.emit()
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if not player_overlay: return
|
||||
var show_panorama := panoramic and screen.visible
|
||||
@@ -101,14 +119,41 @@ func _process(_delta: float) -> void:
|
||||
panorama.visible = show_panorama
|
||||
apply_environment()
|
||||
if show_panorama: panorama.global_position = camera.global_position
|
||||
var loading_menu_visible := player_loading.is_visible_in_tree() and player_loading.cancel_button.visible
|
||||
_update_skip_popup()
|
||||
var menu_visible := panel.is_visible_in_tree() or player_overlay.is_visible_in_tree() or loading_menu_visible or player_skip_popup.is_visible_in_tree()
|
||||
var controllers_visible := not screen.visible or menu_visible
|
||||
if controller_models != null:
|
||||
controller_models.visible = controllers_visible
|
||||
for pointer in pointers:
|
||||
pointer.visible = controllers_visible
|
||||
if player_loading.is_visible_in_tree() and player_loading.cancel_button.visible:
|
||||
pointer.set_target(player_loading, player_loading.viewport, player_loading.panel_size)
|
||||
elif player_skip_popup.is_visible_in_tree() and (not player_overlay.is_visible_in_tree() or pointer.points_at(player_skip_popup, player_skip_popup.panel_size)):
|
||||
pointer.set_target(player_skip_popup, player_skip_popup.viewport, player_skip_popup.panel_size)
|
||||
elif player_overlay.is_visible_in_tree():
|
||||
pointer.set_target(player_overlay, player_overlay.viewport, player_overlay.panel_size)
|
||||
else:
|
||||
pointer.set_target(panel, viewport, Vector2(4.4, 2.75))
|
||||
|
||||
func _update_skip_popup() -> void:
|
||||
player_skip_popup.visible = player_skip_popup.available and not panel.is_visible_in_tree() and not player_loading.is_visible_in_tree()
|
||||
if not player_skip_popup.is_visible_in_tree(): return
|
||||
var controls_height := player_overlay.panel_size.y if player_overlay.is_visible_in_tree() else 0.0
|
||||
player_skip_popup.fit_screen(skip_screen_size.x, skip_screen_size.y, skip_screen_size.z, controls_height)
|
||||
|
||||
func _prepare_controller_model(node: Node) -> void:
|
||||
if node is MeshInstance3D and node.mesh != null:
|
||||
for surface in range(node.mesh.get_surface_count()):
|
||||
var source := node.get_active_material(surface) as BaseMaterial3D
|
||||
if source == null: continue
|
||||
var unshaded := source.duplicate() as BaseMaterial3D
|
||||
unshaded.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
VrOverlayMaterial.apply_depth(unshaded)
|
||||
node.set_surface_override_material(surface, unshaded)
|
||||
for child in node.get_children():
|
||||
_prepare_controller_model(child)
|
||||
|
||||
func _build_theatre() -> void:
|
||||
theatre = TheatreEnvironment.new()
|
||||
add_child(theatre)
|
||||
@@ -130,7 +175,6 @@ func set_panoramic(enabled: bool, horizontal_angle: float = TAU) -> void:
|
||||
panorama.visible = enabled and screen.visible
|
||||
if enabled:
|
||||
panorama.global_position = camera.global_position
|
||||
panorama.global_rotation = Vector3(0.0, camera.global_rotation.y, 0.0)
|
||||
apply_environment()
|
||||
|
||||
func _exit_tree() -> void:
|
||||
|
||||
@@ -6,16 +6,24 @@ uniform bool stereo = false;
|
||||
uniform bool top_bottom = false;
|
||||
uniform bool swap_eyes = false;
|
||||
|
||||
#include "pc_video_yuv.gdshaderinc"
|
||||
|
||||
void fragment() {
|
||||
vec2 coordinate = UV;
|
||||
vec2 eye_min = vec2(0.0);
|
||||
vec2 eye_max = vec2(1.0);
|
||||
if (stereo) {
|
||||
int eye = int(VIEW_INDEX);
|
||||
if (swap_eyes) { eye = 1 - eye; }
|
||||
if (top_bottom) {
|
||||
coordinate.y = coordinate.y * 0.5 + float(eye) * 0.5;
|
||||
eye_min.y = float(eye) * 0.5;
|
||||
eye_max.y = eye_min.y + 0.5;
|
||||
} else {
|
||||
coordinate.x = coordinate.x * 0.5 + float(eye) * 0.5;
|
||||
eye_min.x = float(eye) * 0.5;
|
||||
eye_max.x = eye_min.x + 0.5;
|
||||
}
|
||||
}
|
||||
ALBEDO = texture(video_texture, coordinate).rgb;
|
||||
ALBEDO = direct_yuv ? sample_video_yuv(coordinate, eye_min, eye_max) : texture(video_texture, coordinate).rgb;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
uniform bool direct_yuv = false;
|
||||
uniform sampler2D y_plane : filter_linear, repeat_disable;
|
||||
uniform sampler2D u_plane : filter_linear, repeat_disable;
|
||||
uniform sampler2D v_plane : filter_linear, repeat_disable;
|
||||
uniform vec2 source_size = vec2(1.0);
|
||||
uniform int video_rotation = 0;
|
||||
uniform bool interleaved_chroma = false;
|
||||
uniform bool chroma_in_alpha = false;
|
||||
uniform vec2 color_coefficients = vec2(0.2126, 0.0722);
|
||||
uniform float full_range = 0.0;
|
||||
uniform float transfer = 0.0;
|
||||
uniform float sample_scale = 1020.0;
|
||||
uniform float code_max = 1020.0;
|
||||
uniform float source_peak = 4.926108;
|
||||
|
||||
float hable(float value) {
|
||||
return ((value * (0.15 * value + 0.05) + 0.004) /
|
||||
(value * (0.15 * value + 0.5) + 0.06)) - (0.02 / 0.3);
|
||||
}
|
||||
|
||||
vec3 hdr_to_sdr(vec3 rgb) {
|
||||
rgb = clamp(rgb, 0.0, 1.0);
|
||||
if (transfer == 1.0) {
|
||||
vec3 p = pow(rgb, vec3(1.0 / 78.84375));
|
||||
rgb = pow(max(p - 0.8359375, 0.0) / max(18.8515625 - 18.6875 * p, 0.000001), vec3(1.0 / 0.1593017578125)) * (10000.0 / 203.0);
|
||||
} else {
|
||||
rgb = mix(rgb * rgb / 3.0, (exp((rgb - 0.55991073) / 0.17883277) + 0.28466892) / 12.0, greaterThan(rgb, vec3(0.5)));
|
||||
float scene_luminance = dot(rgb, vec3(0.2627, 0.6780, 0.0593));
|
||||
rgb *= pow(max(scene_luminance, 0.0), 0.2) * (1000.0 / 203.0);
|
||||
}
|
||||
if (color_coefficients.x > 0.26) {
|
||||
rgb = mat3(vec3(1.6605, -0.1246, -0.0182), vec3(-0.5876, 1.1329, -0.1006), vec3(-0.0728, -0.0083, 1.1187)) * rgb;
|
||||
}
|
||||
rgb = max(rgb, vec3(0.0));
|
||||
float signal = max(max(rgb.r, rgb.g), rgb.b);
|
||||
float mapped = hable(signal) / max(hable(source_peak), 0.000001);
|
||||
rgb *= mapped / max(signal, 0.000001);
|
||||
return clamp(rgb, vec3(0.0), vec3(1.0));
|
||||
}
|
||||
|
||||
vec2 rotate_video_uv(vec2 uv) {
|
||||
if (video_rotation == 1) uv = vec2(uv.y, 1.0 - uv.x);
|
||||
else if (video_rotation == 2) uv = 1.0 - uv;
|
||||
else if (video_rotation == 3) uv = vec2(1.0 - uv.y, uv.x);
|
||||
return uv;
|
||||
}
|
||||
|
||||
vec3 sample_video_yuv(vec2 uv, vec2 eye_min, vec2 eye_max) {
|
||||
vec2 first = rotate_video_uv(eye_min) * source_size;
|
||||
vec2 last = rotate_video_uv(eye_max) * source_size;
|
||||
vec2 lower = min(first, last);
|
||||
vec2 upper = max(first, last);
|
||||
vec2 pixel = rotate_video_uv(uv) * source_size;
|
||||
vec2 y_uv = clamp(pixel, lower + 0.5, upper - 0.5) / vec2(textureSize(y_plane, 0));
|
||||
// Keep filtering inside the selected eye and outside decoder padding.
|
||||
vec2 chroma_uv = clamp(pixel * 0.5, ceil(lower * 0.5) + 0.5, ceil(upper * 0.5) - 0.5) / vec2(textureSize(u_plane, 0));
|
||||
float y = texture(y_plane, y_uv).r * sample_scale;
|
||||
vec4 chroma = texture(u_plane, chroma_uv);
|
||||
float u = chroma.r * sample_scale - 512.0;
|
||||
// Texture2DRD exposes R8G8 as luminance-alpha; R16G16 keeps red-green.
|
||||
float v_sample = interleaved_chroma ? (chroma_in_alpha ? chroma.a : chroma.g) : texture(v_plane, chroma_uv).r;
|
||||
float v = v_sample * sample_scale - 512.0;
|
||||
if (full_range > 0.5) { y /= code_max; u /= code_max; v /= code_max; }
|
||||
else { y = (y - 64.0) / 876.0; u /= 896.0; v /= 896.0; }
|
||||
float kr = color_coefficients.x;
|
||||
float kb = color_coefficients.y;
|
||||
float kg = 1.0 - kr - kb;
|
||||
vec3 rgb = vec3(y + 2.0 * (1.0 - kr) * v,
|
||||
y - 2.0 * kb * (1.0 - kb) / kg * u - 2.0 * kr * (1.0 - kr) / kg * v,
|
||||
y + 2.0 * (1.0 - kb) * u);
|
||||
if (transfer > 0.0) {
|
||||
rgb = hdr_to_sdr(rgb);
|
||||
if (OUTPUT_IS_SRGB) rgb = mix(rgb * 12.92, 1.055 * pow(rgb, vec3(1.0 / 2.4)) - 0.055, greaterThan(rgb, vec3(0.0031308)));
|
||||
return rgb;
|
||||
}
|
||||
rgb = clamp(rgb, vec3(0.0), vec3(1.0));
|
||||
// Spatial materials expect linear RGB on the Mobile and Forward+ renderers.
|
||||
if (!OUTPUT_IS_SRGB) rgb = mix(rgb / 12.92, pow((rgb + 0.055) / 1.055, vec3(2.4)), greaterThan(rgb, vec3(0.04045)));
|
||||
return rgb;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://dg7chfym1ebt2
|
||||
@@ -44,6 +44,8 @@ func capture() -> void:
|
||||
quit(1)
|
||||
return
|
||||
if not await check_180_rendering(app, grid_texture): return
|
||||
app.api.token = "fixture"
|
||||
app.shell.set_signed_in(true)
|
||||
app._screen_settings()
|
||||
app.viewport.get_parent().show()
|
||||
var settings: SettingsView = app.page.get_child(0)
|
||||
|
||||
@@ -19,6 +19,8 @@ func capture() -> void:
|
||||
material.cull_mode = BaseMaterial3D.CULL_DISABLED
|
||||
material.albedo_color = Color("876143")
|
||||
app.screen.material_override = material
|
||||
app.api.token = "fixture"
|
||||
app.shell.set_signed_in(true)
|
||||
app._screen_settings()
|
||||
app.panel.show()
|
||||
await create_timer(0.5).timeout
|
||||
|
||||
File renamed without changes.
@@ -0,0 +1,29 @@
|
||||
extends SubViewport
|
||||
|
||||
var backend := PcVideo.new()
|
||||
var material := ShaderMaterial.new()
|
||||
|
||||
func _init(decoder: Node, dimensions := Vector2i(640, 360)) -> void:
|
||||
size = dimensions
|
||||
own_world_3d = true
|
||||
render_target_update_mode = SubViewport.UPDATE_ALWAYS
|
||||
var camera := Camera3D.new()
|
||||
camera.projection = Camera3D.PROJECTION_ORTHOGONAL
|
||||
camera.size = 2.0
|
||||
camera.position.z = 1.0
|
||||
camera.current = true
|
||||
add_child(camera)
|
||||
var mesh := MeshInstance3D.new()
|
||||
var quad := QuadMesh.new()
|
||||
quad.size = Vector2(2.0 * dimensions.x / dimensions.y, 2.0)
|
||||
mesh.mesh = quad
|
||||
material.shader = load("res://shaders/pc_video.gdshader")
|
||||
mesh.material_override = material
|
||||
add_child(mesh)
|
||||
backend.material = material
|
||||
backend.bridge = decoder
|
||||
add_child(backend)
|
||||
decoder.texture_changed.connect(backend._set_texture)
|
||||
|
||||
func image() -> Image:
|
||||
return get_texture().get_image()
|
||||
@@ -0,0 +1 @@
|
||||
uid://ty8d8u06c5qk
|
||||
@@ -46,6 +46,8 @@ func run_checks() -> void:
|
||||
var app: Node = load("res://scenes/main.tscn").instantiate()
|
||||
root.add_child(app)
|
||||
app.set_process(false)
|
||||
app.api.token = "fixture"
|
||||
app.shell.set_signed_in(true)
|
||||
app.video.queue_free()
|
||||
var video := FixtureVideo.new()
|
||||
app.add_child(video)
|
||||
|
||||
@@ -78,6 +78,7 @@ func run_checks() -> void:
|
||||
app.add_child(video)
|
||||
app.add_child(session)
|
||||
app.api = api
|
||||
app.shell.set_signed_in(true)
|
||||
app.video = video
|
||||
app.session = session
|
||||
video.ended.connect(app._ended)
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
extends SceneTree
|
||||
|
||||
var failures := 0
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("run_checks")
|
||||
|
||||
func run_checks() -> void:
|
||||
if DisplayServer.get_name() == "headless":
|
||||
push_error("Controller depth checks require a rendering window.")
|
||||
quit(1)
|
||||
return
|
||||
var app: Node = load("res://scenes/main.tscn").instantiate()
|
||||
root.add_child(app)
|
||||
app.set_process(false)
|
||||
var view := SubViewport.new()
|
||||
view.size = Vector2i(64, 64)
|
||||
view.own_world_3d = true
|
||||
view.render_target_update_mode = SubViewport.UPDATE_ALWAYS
|
||||
root.add_child(view)
|
||||
view.add_child(Camera3D.new())
|
||||
var room := MeshInstance3D.new()
|
||||
room.mesh = QuadMesh.new()
|
||||
room.position.z = -0.4
|
||||
var blue := StandardMaterial3D.new()
|
||||
blue.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
blue.albedo_color = Color.BLUE
|
||||
room.material_override = blue
|
||||
view.add_child(room)
|
||||
var panel := MeshInstance3D.new()
|
||||
panel.mesh = QuadMesh.new()
|
||||
panel.position.z = -2.0
|
||||
view.add_child(panel)
|
||||
var controller := MeshInstance3D.new()
|
||||
controller.mesh = BoxMesh.new()
|
||||
controller.scale = Vector3.ONE * 0.2
|
||||
var red := StandardMaterial3D.new()
|
||||
red.albedo_color = Color.RED
|
||||
controller.mesh.material = red
|
||||
app.world._prepare_controller_model(controller)
|
||||
view.add_child(controller)
|
||||
var panels := [app.world.panel, app.world.player_loading, app.player_overlay]
|
||||
for source in panels:
|
||||
var material: StandardMaterial3D = source.material_override.duplicate()
|
||||
material.albedo_texture = null
|
||||
material.albedo_color = Color.GREEN
|
||||
panel.material_override = material
|
||||
for room_visible in [false, true]:
|
||||
room.visible = room_visible
|
||||
for depth in [-1.0, -3.0]:
|
||||
controller.position.z = depth
|
||||
await process_frame
|
||||
await RenderingServer.frame_post_draw
|
||||
var pixels := view.get_texture().get_image()
|
||||
var center := pixels.get_pixel(32, 32)
|
||||
var expected := Color.RED if depth == -1.0 else Color.GREEN
|
||||
check(center.is_equal_approx(expected), "Controller depth is preserved with room visibility %s at %s: %s" % [room_visible, depth, center])
|
||||
check(pixels.get_pixel(23, 32).is_equal_approx(Color.GREEN), "The menu draws over closer room geometry")
|
||||
if room_visible:
|
||||
check(pixels.get_pixel(1, 1).is_equal_approx(Color.BLUE), "Room geometry outside the menu remains visible")
|
||||
app.queue_free()
|
||||
view.queue_free()
|
||||
await process_frame
|
||||
print("Rendered controller depth checks: ", "PASS" if failures == 0 else str(failures) + " failures")
|
||||
quit(1 if failures else 0)
|
||||
|
||||
func check(condition: bool, message: String) -> void:
|
||||
if not condition:
|
||||
failures += 1
|
||||
push_error(message)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cn6nx2tgy8qbx
|
||||
@@ -0,0 +1,78 @@
|
||||
extends SceneTree
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("run_checks")
|
||||
|
||||
func run_checks() -> void:
|
||||
var app: Node = load("res://scenes/main.tscn").instantiate()
|
||||
root.add_child(app)
|
||||
app.set_process(false)
|
||||
app.api.token = "fixture"
|
||||
app.shell.set_signed_in(true)
|
||||
var world: ViewingWorld = app.world
|
||||
check_controller_materials(world)
|
||||
var origin := world.camera.get_parent()
|
||||
var pointer := LaserPointer.new()
|
||||
pointer.panel = world.panel
|
||||
pointer.viewport = world.viewport
|
||||
origin.add_child(pointer)
|
||||
world.pointers.append(pointer)
|
||||
assert(ProjectSettings.get_setting("xr/openxr/extensions/render_model"), "OpenXR render models are enabled")
|
||||
world._process(0.0)
|
||||
assert(pointer.visible, "Controllers are visible in the library")
|
||||
app.playing = true
|
||||
app.screen.show()
|
||||
app._show_playback()
|
||||
app._video_prepared()
|
||||
app._player_action("hide")
|
||||
world._process(0.0)
|
||||
assert(not pointer.visible, "Video without menus hides the laser pointer")
|
||||
assert(pointer.is_processing(), "Hidden controllers keep processing input")
|
||||
app._show_playback()
|
||||
world._process(0.0)
|
||||
assert(pointer.visible, "Opening playback controls restores controllers")
|
||||
app._screen_settings()
|
||||
world.panel.show()
|
||||
world._process(0.0)
|
||||
assert(pointer.visible, "Settings keep controllers visible during playback")
|
||||
app._show_playback()
|
||||
app._player_action("hide")
|
||||
world.player_loading.show_buffering()
|
||||
world._process(0.0)
|
||||
assert(not pointer.visible, "Buffering without buttons keeps controllers hidden")
|
||||
world.player_loading.show_countdown("Next episode", 5)
|
||||
world._process(0.0)
|
||||
assert(pointer.visible, "Autoplay cancellation keeps controllers visible")
|
||||
world.player_loading.hide()
|
||||
world.set_panoramic(true)
|
||||
world._process(0.0)
|
||||
assert(not pointer.visible, "Panoramic playback also hides controllers")
|
||||
app.playing = false
|
||||
app.screen.hide()
|
||||
world._process(0.0)
|
||||
assert(pointer.visible, "Stopping playback restores controllers")
|
||||
app.queue_free()
|
||||
await process_frame
|
||||
print("Controller visibility checks: PASS")
|
||||
quit()
|
||||
|
||||
func check_controller_materials(world: ViewingWorld) -> void:
|
||||
var model := Node3D.new()
|
||||
var component := Node3D.new()
|
||||
model.add_child(component)
|
||||
var mesh := MeshInstance3D.new()
|
||||
mesh.mesh = BoxMesh.new()
|
||||
var source := StandardMaterial3D.new()
|
||||
source.albedo_color = Color("b7c4d6")
|
||||
source.albedo_texture = GradientTexture2D.new()
|
||||
source.metallic = 0.5
|
||||
mesh.mesh.material = source
|
||||
component.add_child(mesh)
|
||||
world._prepare_controller_model(model)
|
||||
var result := mesh.get_active_material(0) as BaseMaterial3D
|
||||
assert(result.shading_mode == BaseMaterial3D.SHADING_MODE_UNSHADED, "Nested controller meshes do not require scene lighting")
|
||||
assert(result.albedo_color == source.albedo_color and result.albedo_texture == source.albedo_texture, "Controller colors and textures are preserved")
|
||||
assert(result.metallic == source.metallic, "Other controller material properties are preserved")
|
||||
assert(result.use_z_clip_scale and is_equal_approx(result.z_clip_scale, VrOverlayMaterial.DEPTH_SCALE), "Controllers share the menu depth range")
|
||||
assert(source.shading_mode == BaseMaterial3D.SHADING_MODE_PER_PIXEL, "Shared source materials remain unchanged")
|
||||
model.free()
|
||||
@@ -0,0 +1 @@
|
||||
uid://lndphkh6u8to
|
||||
@@ -6,6 +6,7 @@ class FixtureApi extends JellyfinClient:
|
||||
|
||||
var failures := 0
|
||||
var selected: Array = []
|
||||
var restarted: Array = []
|
||||
var back_count := 0
|
||||
|
||||
func _initialize() -> void:
|
||||
@@ -42,6 +43,7 @@ func show_details(shell: UiShell, item: Dictionary) -> DetailsView:
|
||||
view.item = item
|
||||
view.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
view.play_requested.connect(func(chosen: Dictionary): selected.append(chosen))
|
||||
view.play_from_start_requested.connect(func(chosen: Dictionary): restarted.append(chosen))
|
||||
view.back_requested.connect(func(): back_count += 1)
|
||||
shell.page.add_child(view)
|
||||
return view
|
||||
@@ -60,6 +62,10 @@ func run_checks() -> void:
|
||||
check(view.play_button.text == "Resume", "Saved playback must offer Resume.")
|
||||
view.play_button.pressed.emit()
|
||||
check(selected == [item], "Resume must pass the original item to playback.")
|
||||
check(view.play_from_start_button != null and view.play_from_start_button.text == "Play from start", "Saved playback also offers Play from start.")
|
||||
view.play_from_start_button.pressed.emit()
|
||||
check(restarted == [item] and selected == [item], "Play from start emits a separate playback request.")
|
||||
check(item.UserData.PlaybackPositionTicks == 7200000000, "The details page keeps the original saved progress.")
|
||||
view.back_button.pressed.emit()
|
||||
check(back_count == 1, "Back must return to the library.")
|
||||
check(DetailsMetadata.facts(item).has("2h 09m"), "Runtime must use readable hours and minutes.")
|
||||
@@ -77,14 +83,17 @@ func run_checks() -> void:
|
||||
await settle()
|
||||
check(view.body.size.x <= view.scroll.size.x, "Details must fit the available width at %d pixels." % width)
|
||||
check(view.title_label.get_global_rect().end.x <= view.scroll.get_global_rect().end.x, "Long titles must not escape the details panel.")
|
||||
check(view.play_from_start_button.get_global_rect().end.x <= view.scroll.get_global_rect().end.x, "The restart button fits narrow details panels.")
|
||||
root.size.x = 1536
|
||||
view = show_details(shell, {"Id": "plain", "Name": "A movie without artwork", "Type": "Movie", "Overview": null, "UserData": null, "Genres": null, "People": null})
|
||||
await settle()
|
||||
check(view.play_button.text == "Play movie", "Unstarted movies must offer Play movie.")
|
||||
check(view.play_from_start_button == null, "Titles without progress do not need a restart button.")
|
||||
check(view.overview_label.text == "No description available.", "Missing metadata must have a readable fallback.")
|
||||
view = show_details(shell, {"Id": "series", "Name": "Example series", "Type": "Series"})
|
||||
await settle()
|
||||
check(view.play_button == null, "Series must select episodes instead of playing the series itself.")
|
||||
check(view.play_from_start_button == null, "Series headers do not offer title playback actions.")
|
||||
check(view.series_browser != null, "Series must show a season browser.")
|
||||
check(view.series_browser.season_grid.get_child(0).text == "No seasons available.", "An empty series must have a readable fallback.")
|
||||
print("Details checks: ", "PASS" if failures == 0 else str(failures) + " failures")
|
||||
|
||||
@@ -54,6 +54,7 @@ func run_checks() -> void:
|
||||
check('Token="fixture-token"' in last_request, "Authenticated calls send the token header")
|
||||
check("StartIndex=24" in last_request, "Library pagination is sent")
|
||||
check("Limit=25" in last_request, "Library pages request 25 titles")
|
||||
check("IncludeItemTypes=Movie%2CSeries%2CVideo%2CPhoto" in last_request, "Browsing and search include photos")
|
||||
await api.library("space", 48, "movies-id")
|
||||
check("ParentId=movies-id" in last_request and "StartIndex=48" in last_request and "SearchTerm=space" in last_request, "Search and pagination stay inside the selected library")
|
||||
await api.views()
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
extends SceneTree
|
||||
|
||||
class FakeClient extends JellyfinClient:
|
||||
signal respond
|
||||
var paths: Array[String] = []
|
||||
var response := {"ok": true, "data": {"Items": [{"Type": "Intro", "StartTicks": 100000000, "EndTicks": 400000000}]}}
|
||||
func request(path: String, _method: HTTPClient.Method = HTTPClient.METHOD_GET, _body: Dictionary = {}) -> Dictionary:
|
||||
paths.append(path)
|
||||
await respond
|
||||
return response
|
||||
|
||||
class FakeVideo extends AndroidVideo:
|
||||
var position := 10000
|
||||
var last_seek := -1
|
||||
func position_ms() -> int: return position
|
||||
func duration_ms() -> int: return 120000
|
||||
func seek(value: int) -> void: last_seek = value
|
||||
func stop() -> void: pass
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("run_checks")
|
||||
|
||||
func run_checks() -> void:
|
||||
var segments := MediaSegments.new()
|
||||
segments.segments = MediaSegments.parse({"Items": [
|
||||
{"Type": "Outro", "StartTicks": 900000000, "EndTicks": 1200000000},
|
||||
{"Type": "Intro", "StartTicks": 100000000, "EndTicks": 400000000},
|
||||
{"Type": "Recap", "StartTicks": 0, "EndTicks": 100000000},
|
||||
{"Type": "Commercial", "StartTicks": 0, "EndTicks": 100000000},
|
||||
{"Type": "Intro", "StartTicks": -1, "EndTicks": 100},
|
||||
{"Type": "Intro", "StartTicks": 100, "EndTicks": 1},
|
||||
{"Type": "Intro", "StartTicks": "bad", "EndTicks": 100}, null
|
||||
]})
|
||||
assert(segments.segments.size() == 3)
|
||||
assert(segments.active(0, 120000).label == "Skip recap")
|
||||
assert(segments.active(10000, 120000).target_ms == 40000)
|
||||
assert(segments.active(40000, 120000).is_empty())
|
||||
assert(segments.active(90000, 120000).target_ms == 119000)
|
||||
assert(segments.active(119000, 120000).is_empty())
|
||||
assert(segments.active(10000, 0).is_empty())
|
||||
assert(MediaSegments.parse(null).is_empty())
|
||||
assert(MediaSegments.parse({"Items": null}).is_empty())
|
||||
var api := FakeClient.new()
|
||||
root.add_child(api)
|
||||
segments.load_item(api, "episode/id")
|
||||
assert(api.paths == ["/MediaSegments/episode%2Fid"])
|
||||
segments.clear()
|
||||
api.respond.emit()
|
||||
assert(segments.segments.is_empty(), "Stopped playback rejects late responses")
|
||||
segments.load_item(api, "episode")
|
||||
api.respond.emit()
|
||||
assert(segments.segments.size() == 1)
|
||||
api.response = {"ok": false, "status": 404}
|
||||
segments.load_item(api, "missing")
|
||||
api.respond.emit()
|
||||
assert(segments.segments.is_empty(), "Missing API clears old timestamps")
|
||||
var app: Node = load("res://scenes/main.tscn").instantiate()
|
||||
root.add_child(app)
|
||||
var video := FakeVideo.new()
|
||||
app.add_child(video)
|
||||
app.video = video
|
||||
app.media_segments.segments = MediaSegments.parse({"Items": [{"Type": "Intro", "StartTicks": 100000000, "EndTicks": 400000000}]})
|
||||
app.playing = true
|
||||
app.decoder_ready = true
|
||||
app._update_timeline()
|
||||
assert(app.world.player_skip_popup.available)
|
||||
app.screen.show()
|
||||
app.panel.hide()
|
||||
app.player_overlay.hide()
|
||||
app.world._process(0.0)
|
||||
var popup: PlayerSkipPopup = app.world.player_skip_popup
|
||||
assert(popup.is_visible_in_tree(), "Skip popup works with playback controls hidden")
|
||||
assert(popup.position.x > 0 and popup.position.y < 0, "Popup sits at the bottom-right")
|
||||
var bottom := popup.position.y
|
||||
app.player_overlay.show()
|
||||
app.world._process(0.0)
|
||||
assert(popup.position.y > bottom, "Popup moves above visible controls")
|
||||
var pointer := LaserPointer.new()
|
||||
app.add_child(pointer)
|
||||
pointer.global_position = popup.global_position + Vector3(0, 0, 1)
|
||||
assert(pointer.points_at(popup, popup.panel_size), "Controller ray can target the popup")
|
||||
pointer.global_position.x += 10
|
||||
assert(not pointer.points_at(popup, popup.panel_size), "Other controls remain reachable outside the popup")
|
||||
app.panel.show()
|
||||
app.world._process(0.0)
|
||||
assert(not popup.visible, "Library and settings hide the popup")
|
||||
app.panel.hide()
|
||||
app.world._process(0.0)
|
||||
app.world.player_skip_popup.button.pressed.emit()
|
||||
assert(video.last_seek == 40000, "The button seeks to the intro end")
|
||||
app.session.method = "Transcode"
|
||||
app.session.source = {"TranscodingUrl": "/master.m3u8"}
|
||||
app._skip_segment()
|
||||
assert(video.last_seek == 40000, "HLS uses the same absolute seek")
|
||||
app.transcode_offset = 400000000
|
||||
app._update_timeline()
|
||||
assert(not app.world.player_skip_popup.available, "Progressive transcode uses absolute position")
|
||||
app.transcode_offset = 0
|
||||
app.decoder_ready = false
|
||||
app._update_timeline()
|
||||
assert(not app.world.player_skip_popup.available, "Loading hides skip actions")
|
||||
app.playing = false
|
||||
await app._stop(false)
|
||||
assert(app.media_segments.segments.is_empty())
|
||||
app.queue_free()
|
||||
api.queue_free()
|
||||
await process_frame
|
||||
print("Media segment checks: PASS")
|
||||
quit()
|
||||
@@ -0,0 +1 @@
|
||||
uid://wmflbhqeuv0y
|
||||
@@ -2,6 +2,8 @@ extends SceneTree
|
||||
|
||||
var decoder: Node
|
||||
var preview: TextureRect
|
||||
var screen: SubViewport
|
||||
var video_size := Vector2i.ZERO
|
||||
var video_texture: Texture2D
|
||||
var errors: Array[String] = []
|
||||
var expected_renderer := "opengl"
|
||||
@@ -42,11 +44,11 @@ func check_controls_and_reopen(url: String) -> bool:
|
||||
deadline = Time.get_ticks_msec() + 10000
|
||||
while video_texture == null and errors.is_empty() and Time.get_ticks_msec() < deadline:
|
||||
await process_frame
|
||||
return require(video_texture != null and video_texture.get_size() == Vector2(1920, 1080) and errors.is_empty(), "Reopening replaces high-resolution GPU buffers")
|
||||
return require(video_texture != null and video_size == Vector2i(1920, 1080) and errors.is_empty(), "Reopening replaces high-resolution GPU buffers")
|
||||
|
||||
func run_check() -> void:
|
||||
expected_renderer = "vulkan" if RenderingServer.get_current_rendering_driver_name() == "vulkan" else "opengl"
|
||||
var url := ProjectSettings.globalize_path("res://native/android/src/main/res/raw/stereo_test.mp4")
|
||||
var url := ProjectSettings.globalize_path("res://tests/fixtures/stereo_test.mp4")
|
||||
var reopen_url := url
|
||||
var expected_size := Vector2i(1920, 1080)
|
||||
for argument in OS.get_cmdline_user_args():
|
||||
@@ -63,11 +65,15 @@ func run_check() -> void:
|
||||
Engine.max_fps = 120
|
||||
decoder = ClassDB.instantiate("JellyframeMpv")
|
||||
root.add_child(decoder)
|
||||
screen = preload("res://tests/pc_video_test_screen.gd").new(decoder)
|
||||
root.add_child(screen)
|
||||
decoder.size_changed.connect(func(w: int, h: int): video_size = Vector2i(w, h))
|
||||
preview = TextureRect.new()
|
||||
preview.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
preview.size = Vector2(640, 360)
|
||||
root.add_child(preview)
|
||||
decoder.texture_changed.connect(func(value: Texture2D): video_texture = value; preview.texture = value)
|
||||
preview.texture = screen.get_texture()
|
||||
decoder.texture_changed.connect(func(value: Texture2D): video_texture = value)
|
||||
decoder.failed.connect(func(message: String): errors.append(message))
|
||||
if not require(decoder.open_video(url, "", 0).is_empty(), "Open the GPU test video"):
|
||||
return
|
||||
@@ -76,7 +82,7 @@ func run_check() -> void:
|
||||
await process_frame
|
||||
if not require(video_texture != null and errors.is_empty(), "GPU produces a frame: " + str(errors)):
|
||||
return
|
||||
if not require(video_texture.get_size() == Vector2(expected_size), "GPU rendering preserves the source resolution"):
|
||||
if not require(video_size == expected_size, "GPU rendering preserves the source resolution"):
|
||||
return
|
||||
var before := int(decoder.position_ms())
|
||||
await create_timer(3.0).timeout
|
||||
@@ -84,8 +90,8 @@ func run_check() -> void:
|
||||
return
|
||||
if not require(decoder.position_ms() > before + 2000, "High-resolution playback continues advancing"):
|
||||
return
|
||||
var image := video_texture.get_image()
|
||||
if not require(image != null and image.get_size() == expected_size, "Read the full-resolution GPU result"):
|
||||
var image: Image = screen.image()
|
||||
if not require(image != null and image.get_size() == screen.size, "Read the rendered video material"):
|
||||
return
|
||||
var screen_image := root.get_texture().get_image()
|
||||
var brightest := 0.0
|
||||
|
||||
@@ -16,29 +16,6 @@ func require(condition: bool, message: String) -> bool:
|
||||
quit(1)
|
||||
return false
|
||||
|
||||
func make_screen() -> SubViewport:
|
||||
var viewport := SubViewport.new()
|
||||
viewport.size = Vector2i(640, 192)
|
||||
viewport.own_world_3d = true
|
||||
viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
|
||||
root.add_child(viewport)
|
||||
var camera := Camera3D.new()
|
||||
camera.projection = Camera3D.PROJECTION_ORTHOGONAL
|
||||
camera.size = 3.0
|
||||
camera.position.z = 1.0
|
||||
viewport.add_child(camera)
|
||||
camera.current = true
|
||||
var mesh := MeshInstance3D.new()
|
||||
var quad := QuadMesh.new()
|
||||
quad.size = Vector2(10, 3)
|
||||
mesh.mesh = quad
|
||||
var material := ShaderMaterial.new()
|
||||
material.shader = load("res://shaders/pc_video.gdshader")
|
||||
material.set_shader_parameter("video_texture", texture)
|
||||
mesh.material_override = material
|
||||
viewport.add_child(mesh)
|
||||
return viewport
|
||||
|
||||
func run_check() -> void:
|
||||
var args := OS.get_cmdline_user_args()
|
||||
if not require(args.size() == 3, "Pass a video, reference PNG, and expected transfer"):
|
||||
@@ -48,6 +25,8 @@ func run_check() -> void:
|
||||
return
|
||||
decoder = ClassDB.instantiate("JellyframeMpv")
|
||||
root.add_child(decoder)
|
||||
var screen := preload("res://tests/pc_video_test_screen.gd").new(decoder, Vector2i(640, 192))
|
||||
root.add_child(screen)
|
||||
decoder.texture_changed.connect(func(value: Texture2D): texture = value)
|
||||
decoder.failed.connect(func(value: String): errors.append(value))
|
||||
if not require(decoder.open_video(args[0], "", 0).is_empty(), "Open the HDR fixture"):
|
||||
@@ -61,23 +40,18 @@ func run_check() -> void:
|
||||
var diagnostics: Dictionary = decoder.playback_diagnostics()
|
||||
if not require(diagnostics.get("renderer") == "vulkan" and diagnostics.get("transfer") == args[2], "The decoder preserves transfer metadata: " + str(diagnostics)):
|
||||
return
|
||||
var screen := make_screen()
|
||||
await create_timer(0.2).timeout
|
||||
var raw := texture.get_image()
|
||||
var rendered := screen.get_texture().get_image()
|
||||
for index in range(10):
|
||||
var point := Vector2i(index * 64 + 32, 96)
|
||||
var expected := reference.get_pixelv(point)
|
||||
var direct := raw.get_pixelv(point)
|
||||
var visible := rendered.get_pixelv(point)
|
||||
for channel in range(3):
|
||||
if not require(absf(direct[channel] - expected[channel]) < 0.016, "HDR patch %d matches FFmpeg: expected %s, got %s" % [index, str(expected), str(direct)]):
|
||||
return
|
||||
if not require(absf(visible[channel] - expected[channel]) < 0.02, "The 3D video screen preserves patch %d brightness: expected %s, got %s" % [index, str(expected), str(visible)]):
|
||||
return
|
||||
decoder.stop_video()
|
||||
decoder.queue_free()
|
||||
screen.queue_free()
|
||||
await process_frame
|
||||
print("Vulkan HDR texture and 3D screen checks: PASS")
|
||||
print("Vulkan direct YUV HDR and 3D screen checks: PASS")
|
||||
quit()
|
||||
@@ -71,7 +71,7 @@ def main():
|
||||
graphics.add_argument("--vulkan", action="store_true", help="Test Vulkan GPU playback in a window")
|
||||
args = parser.parse_args()
|
||||
project = Path(__file__).resolve().parents[1]
|
||||
media = [(project / "native/android/src/main/res/raw", "stereo_test.mp4")]
|
||||
media = [(project / "tests/fixtures", "stereo_test.mp4")]
|
||||
if args.hls_directory:
|
||||
media.append((args.hls_directory.resolve(), "stream.m3u8"))
|
||||
for directory, name in media:
|
||||
|
||||
@@ -5,6 +5,7 @@ var ready_count := 0
|
||||
var ended_count := 0
|
||||
var errors: Array[String] = []
|
||||
var texture: Texture2D
|
||||
var screen: SubViewport
|
||||
var dimensions := Vector2i.ZERO
|
||||
var failures := 0
|
||||
|
||||
@@ -34,12 +35,15 @@ func run_checks() -> void:
|
||||
backend.queue_free()
|
||||
decoder = ClassDB.instantiate("JellyframeMpv")
|
||||
root.add_child(decoder)
|
||||
if DisplayServer.get_name() != "headless":
|
||||
screen = preload("res://tests/pc_video_test_screen.gd").new(decoder, Vector2i(1920, 1080))
|
||||
root.add_child(screen)
|
||||
decoder.prepared.connect(func(): ready_count += 1)
|
||||
decoder.ended.connect(func(): ended_count += 1)
|
||||
decoder.failed.connect(func(message: String): errors.append(message))
|
||||
decoder.size_changed.connect(func(w: int, h: int): dimensions = Vector2i(w, h))
|
||||
decoder.texture_changed.connect(func(value: Texture2D): texture = value)
|
||||
var url := ProjectSettings.globalize_path("res://native/android/src/main/res/raw/stereo_test.mp4")
|
||||
var url := ProjectSettings.globalize_path("res://tests/fixtures/stereo_test.mp4")
|
||||
var authorization := ""
|
||||
var expected_renderer := "software" if DisplayServer.get_name() == "headless" or OS.get_environment("JELLYFRAME_PC_RENDERER") == "software" else "opengl"
|
||||
if expected_renderer != "software" and RenderingServer.get_current_rendering_driver_name() == "vulkan":
|
||||
@@ -66,7 +70,7 @@ func run_checks() -> void:
|
||||
check(await wait_until(func(): return decoder.position_ms() > 1400), "Playback resumes and advances")
|
||||
check(decoder.duration_ms() > 2000, "Duration is available for seeking")
|
||||
if texture != null and DisplayServer.get_name() != "headless":
|
||||
var image := texture.get_image()
|
||||
var image: Image = screen.image()
|
||||
check(not image.is_empty(), "The decoder uploads a readable texture")
|
||||
if not image.is_empty():
|
||||
check(image.get_pixel(240, 300).get_luminance() > 0.7, "The left stereo frame reaches the texture")
|
||||
|
||||
@@ -3,6 +3,7 @@ extends SceneTree
|
||||
var decoder: Node
|
||||
var texture: Texture2D
|
||||
var preview: TextureRect
|
||||
var screen: SubViewport
|
||||
var errors: Array[String] = []
|
||||
|
||||
func _initialize() -> void:
|
||||
@@ -21,11 +22,14 @@ func run_check() -> void:
|
||||
Engine.max_fps = 120
|
||||
decoder = ClassDB.instantiate("JellyframeMpv")
|
||||
root.add_child(decoder)
|
||||
screen = preload("res://tests/pc_video_test_screen.gd").new(decoder)
|
||||
root.add_child(screen)
|
||||
preview = TextureRect.new()
|
||||
preview.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
preview.size = Vector2(640, 360)
|
||||
root.add_child(preview)
|
||||
decoder.texture_changed.connect(func(value: Texture2D): texture = value; preview.texture = value)
|
||||
preview.texture = screen.get_texture()
|
||||
decoder.texture_changed.connect(func(value: Texture2D): texture = value)
|
||||
decoder.failed.connect(func(value: String): errors.append(value))
|
||||
var error: String = decoder.open_video(ProjectSettings.globalize_path(url), "", 0)
|
||||
if not error.is_empty(): errors.append(error)
|
||||
|
||||
@@ -12,7 +12,7 @@ import time
|
||||
|
||||
class RecoveryHandler(SimpleHTTPRequestHandler):
|
||||
lock = threading.Lock()
|
||||
video_agent = None
|
||||
video_connection = None
|
||||
stalled = False
|
||||
byte_rate = 1
|
||||
stall_after = 4
|
||||
@@ -23,8 +23,8 @@ class RecoveryHandler(SimpleHTTPRequestHandler):
|
||||
|
||||
def do_GET(self):
|
||||
with self.lock:
|
||||
if type(self).video_agent is None:
|
||||
type(self).video_agent = self.headers.get("User-Agent")
|
||||
if type(self).video_connection is None:
|
||||
type(self).video_connection = self.connection
|
||||
super().do_GET()
|
||||
|
||||
def send_head(self):
|
||||
@@ -56,7 +56,7 @@ class RecoveryHandler(SimpleHTTPRequestHandler):
|
||||
return stream
|
||||
|
||||
def copyfile(self, source, output):
|
||||
video = self.pace_video and self.headers.get("User-Agent") == self.video_agent
|
||||
video = self.pace_video and self.connection is self.video_connection
|
||||
started = time.monotonic()
|
||||
sent = 0
|
||||
try:
|
||||
|
||||
@@ -63,7 +63,7 @@ func run_check() -> void:
|
||||
video.failed.connect(func(message: String): errors.append(message))
|
||||
root.add_child(video)
|
||||
if not require(await screen_brightness() < 0.001, "The screen is black before the first video"): return
|
||||
var url := ProjectSettings.globalize_path("res://native/android/src/main/res/raw/stereo_test.mp4")
|
||||
var url := ProjectSettings.globalize_path("res://tests/fixtures/stereo_test.mp4")
|
||||
for cycle in range(2):
|
||||
video.open(url, "", 0)
|
||||
if not await wait_for_video(): return
|
||||
|
||||
+10
-4
@@ -1,4 +1,4 @@
|
||||
"""Generate ten-bit fixtures and verify Vulkan colors, seeking, and audio timing."""
|
||||
"""Verify planar ten-bit and hardware H.264 colors, seeking, and audio timing."""
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
@@ -15,7 +15,10 @@ def main():
|
||||
output = project / "build" / "vulkan-tests"
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(dir=output) as directory:
|
||||
for matrix, full_range, audio in [("bt601", False, False), ("bt709", True, False), ("bt709", False, True)]:
|
||||
cases = [("ffv1", "bt601", False, False), ("ffv1", "bt709", True, False),
|
||||
("ffv1", "bt709", False, True), ("libx264", "bt601", False, False),
|
||||
("libx264", "bt709", True, False), ("libx264", "bt709", False, True)]
|
||||
for codec, matrix, full_range, audio in cases:
|
||||
video = Path(directory) / "colors.mkv"
|
||||
command = [str(args.ffmpeg.resolve()), "-v", "error"]
|
||||
for color in ("red", "lime", "blue"):
|
||||
@@ -23,11 +26,14 @@ def main():
|
||||
if audio:
|
||||
command += ["-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000:duration=6"]
|
||||
target_range = "pc" if full_range else "tv"
|
||||
pixel_format = "yuv420p" if codec == "libx264" else "yuv420p10le"
|
||||
filters = (f"[0:v][1:v][2:v]concat=n=3:v=1:a=0,"
|
||||
f"scale=in_color_matrix=bt601:out_color_matrix={matrix}:in_range=tv:out_range={target_range},"
|
||||
"format=yuv420p10le[video]")
|
||||
command += ["-filter_complex", filters, "-map", "[video]", "-c:v", "ffv1", "-threads", "2",
|
||||
f"format={pixel_format}[video]")
|
||||
command += ["-filter_complex", filters, "-map", "[video]", "-c:v", codec, "-threads", "2",
|
||||
"-color_range", target_range, "-colorspace", "bt709" if matrix == "bt709" else "smpte170m"]
|
||||
if codec == "libx264":
|
||||
command += ["-preset", "ultrafast", "-crf", "0"]
|
||||
if audio:
|
||||
command += ["-map", "3:a", "-c:a", "pcm_s16le"]
|
||||
subprocess.run([*command, "-y", str(video)], check=True, timeout=60)
|
||||
|
||||
@@ -2,6 +2,7 @@ extends SceneTree
|
||||
|
||||
var decoder: Node
|
||||
var texture: Texture2D
|
||||
var screen: SubViewport
|
||||
var errors: Array[String] = []
|
||||
var ended := false
|
||||
|
||||
@@ -25,7 +26,7 @@ func wait_for_frame() -> bool:
|
||||
return require(texture != null and errors.is_empty(), "Vulkan produces a frame: " + str(errors))
|
||||
|
||||
func check_color(expected: Color) -> bool:
|
||||
var image := texture.get_image()
|
||||
var image: Image = screen.image()
|
||||
var actual := image.get_pixel(image.get_width() / 2, image.get_height() / 2)
|
||||
return require(absf(actual.r - expected.r) < 0.04 and absf(actual.g - expected.g) < 0.04 and absf(actual.b - expected.b) < 0.04 and actual.a > 0.99,
|
||||
"The Vulkan shader converts the expected color: %s, got %s" % [str(expected), str(actual)])
|
||||
@@ -39,10 +40,12 @@ func run_check() -> void:
|
||||
url = argument.trim_prefix("--video=")
|
||||
decoder = ClassDB.instantiate("JellyframeMpv")
|
||||
root.add_child(decoder)
|
||||
screen = preload("res://tests/pc_video_test_screen.gd").new(decoder)
|
||||
root.add_child(screen)
|
||||
decoder.texture_changed.connect(func(value: Texture2D): texture = value)
|
||||
decoder.failed.connect(func(message: String): errors.append(message))
|
||||
decoder.ended.connect(func(): ended = true)
|
||||
if not require(decoder.open_video(ProjectSettings.globalize_path(url), "", 0).is_empty(), "Open the ten-bit color fixture"):
|
||||
if not require(decoder.open_video(ProjectSettings.globalize_path(url), "", 0).is_empty(), "Open the color fixture"):
|
||||
return
|
||||
if not await wait_for_frame():
|
||||
return
|
||||
@@ -65,5 +68,5 @@ func run_check() -> void:
|
||||
decoder.stop_video()
|
||||
decoder.queue_free()
|
||||
await process_frame
|
||||
print("Vulkan ten-bit color and seek checks: PASS")
|
||||
print("Vulkan color and seek checks: PASS")
|
||||
quit()
|
||||
@@ -0,0 +1,71 @@
|
||||
extends SceneTree
|
||||
|
||||
var screen: SubViewport
|
||||
var material: ShaderMaterial
|
||||
var failures := 0
|
||||
const COLORS = [Color.RED, Color.GREEN, Color.BLUE, Color.WHITE]
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("run_checks")
|
||||
|
||||
func make_planes(interleaved: bool) -> void:
|
||||
var y_plane := Image.create(12, 8, false, Image.FORMAT_R8)
|
||||
var u_plane := Image.create(6, 4, false, Image.FORMAT_RG8 if interleaved else Image.FORMAT_R8)
|
||||
var v_plane := Image.create(6, 4, false, Image.FORMAT_R8)
|
||||
y_plane.fill(Color.WHITE)
|
||||
u_plane.fill(Color.BLACK)
|
||||
v_plane.fill(Color.BLACK)
|
||||
for y in range(4):
|
||||
for x in range(8):
|
||||
var color: Color = COLORS[(y / 2) * 2 + x / 4]
|
||||
var luma := color.r * 0.2126 + color.g * 0.7152 + color.b * 0.0722
|
||||
var u := (color.b - luma) / (2.0 * (1.0 - 0.0722))
|
||||
var v := (color.r - luma) / (2.0 * (1.0 - 0.2126))
|
||||
y_plane.set_pixel(x, y, Color((16.0 + 219.0 * luma) / 255.0, 0, 0))
|
||||
u_plane.set_pixel(x / 2, y / 2, Color((128.0 + 224.0 * u) / 255.0, (128.0 + 224.0 * v) / 255.0, 0))
|
||||
v_plane.set_pixel(x / 2, y / 2, Color((128.0 + 224.0 * v) / 255.0, 0, 0))
|
||||
material.set_shader_parameter("y_plane", ImageTexture.create_from_image(y_plane))
|
||||
material.set_shader_parameter("u_plane", ImageTexture.create_from_image(u_plane))
|
||||
material.set_shader_parameter("v_plane", ImageTexture.create_from_image(v_plane))
|
||||
material.set_shader_parameter("source_size", Vector2(8, 4))
|
||||
material.set_shader_parameter("direct_yuv", true)
|
||||
material.set_shader_parameter("interleaved_chroma", interleaved)
|
||||
|
||||
func check_corners(expected: Array, label: String) -> void:
|
||||
await process_frame
|
||||
await RenderingServer.frame_post_draw
|
||||
var image: Image = screen.image()
|
||||
var points := [Vector2i(8, 8), Vector2i(55, 8), Vector2i(8, 55), Vector2i(55, 55)]
|
||||
for index in range(points.size()):
|
||||
var actual := image.get_pixelv(points[index])
|
||||
var color: Color = expected[index]
|
||||
if absf(actual.r - color.r) > 0.04 or absf(actual.g - color.g) > 0.04 or absf(actual.b - color.b) > 0.04:
|
||||
failures += 1
|
||||
push_error("%s corner %d: expected %s, got %s" % [label, index, color, actual])
|
||||
|
||||
func run_checks() -> void:
|
||||
var decoder := ClassDB.instantiate("JellyframeMpv") as Node
|
||||
root.add_child(decoder)
|
||||
screen = preload("res://tests/pc_video_test_screen.gd").new(decoder, Vector2i(64, 64))
|
||||
root.add_child(screen)
|
||||
material = screen.material
|
||||
for interleaved in [false, true]:
|
||||
make_planes(interleaved)
|
||||
material.set_shader_parameter("stereo", false)
|
||||
for rotation in range(4):
|
||||
material.set_shader_parameter("video_rotation", rotation)
|
||||
var order: Array = [[0, 1, 2, 3], [2, 0, 3, 1], [3, 2, 1, 0], [1, 3, 0, 2]][rotation]
|
||||
await check_corners(order.map(func(index: int): return COLORS[index]), "Padded YUV rotation %d, interleaved %s" % [rotation, interleaved])
|
||||
material.set_shader_parameter("video_rotation", 0)
|
||||
material.set_shader_parameter("stereo", true)
|
||||
for top_bottom in [false, true]:
|
||||
material.set_shader_parameter("top_bottom", top_bottom)
|
||||
for swap in [false, true]:
|
||||
material.set_shader_parameter("swap_eyes", swap)
|
||||
var order: Array = ([2, 3, 2, 3] if swap else [0, 1, 0, 1]) if top_bottom else ([1, 1, 3, 3] if swap else [0, 0, 2, 2])
|
||||
await check_corners(order.map(func(index: int): return COLORS[index]), "Stereo top-bottom %s, swap %s" % [top_bottom, swap])
|
||||
screen.queue_free()
|
||||
decoder.queue_free()
|
||||
await process_frame
|
||||
print("Direct YUV padded planes, rotation, and stereo checks: ", "PASS" if failures == 0 else "FAIL")
|
||||
quit(0 if failures == 0 else 1)
|
||||
@@ -0,0 +1 @@
|
||||
uid://w53ywcq56k6c
|
||||
@@ -0,0 +1,105 @@
|
||||
extends SceneTree
|
||||
|
||||
class FixtureView extends PhotoView:
|
||||
func _load() -> void:
|
||||
pass
|
||||
|
||||
class SequenceApi extends JellyfinClient:
|
||||
var calls: Array = []
|
||||
func library(term: String = "", start: int = 0, parent: String = "") -> Dictionary:
|
||||
calls.append([term, start, parent])
|
||||
return {"ok": true, "data": {"Items": [{"Id": "video", "Type": "Video"}] if start == 25 else [{"Id": "next", "Type": "Photo"}], "TotalRecordCount": 27}}
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("run_checks")
|
||||
|
||||
func run_checks() -> void:
|
||||
var sequence := PhotoSequence.new()
|
||||
sequence.items = [{"Id": "first", "Type": "Photo"}]
|
||||
sequence.index = 0
|
||||
sequence.offset = 25
|
||||
sequence.has_more = true
|
||||
sequence.search = "holiday"
|
||||
sequence.library_id = "album"
|
||||
var sequence_api := SequenceApi.new()
|
||||
root.add_child(sequence_api)
|
||||
var next := await sequence.step(1, sequence_api)
|
||||
assert(next.item.Id == "next" and not sequence.can_next())
|
||||
assert(sequence_api.calls == [["holiday", 25, "album"], ["holiday", 26, "album"]])
|
||||
var previous := await sequence.step(-1, sequence_api)
|
||||
assert(previous.item.Id == "first")
|
||||
assert((await sequence.step(-1, sequence_api)).is_empty())
|
||||
sequence_api.free()
|
||||
var view := FixtureView.new()
|
||||
view.item = {"Id": "photo", "Name": "Portrait", "Type": "Photo"}
|
||||
root.add_child(view)
|
||||
var textures: Array[Texture2D] = []
|
||||
var errors: Array[String] = []
|
||||
view.photo_ready.connect(func(texture: Texture2D): textures.append(texture))
|
||||
view.failed.connect(func(message: String): errors.append(message))
|
||||
var image := Image.create(40, 80, false, Image.FORMAT_RGB8)
|
||||
image.fill(Color.RED)
|
||||
view._loaded(HTTPRequest.RESULT_SUCCESS, 200, [], image.save_jpg_to_buffer())
|
||||
assert(textures[0].get_size() == Vector2(40, 80))
|
||||
assert(errors.is_empty())
|
||||
view._loaded(HTTPRequest.RESULT_SUCCESS, 403, [], [])
|
||||
assert("403" in errors[0])
|
||||
view._loaded(HTTPRequest.RESULT_CANT_CONNECT, 0, [], [])
|
||||
assert(errors.size() == 2)
|
||||
view.free()
|
||||
assert(PhotoView.stereo_mode({"Name": "Holiday.SBS.jpg"}) == Stereo.Mode.FULL_SBS)
|
||||
assert(PhotoView.stereo_mode({"Name": "Holiday.HSBS.jpg"}) == Stereo.Mode.HALF_SBS)
|
||||
assert(PhotoView.stereo_mode({"Name": "Holiday.TB.jpg"}) == Stereo.Mode.TOP_BOTTOM)
|
||||
assert(PhotoView.stereo_mode({"Name": "Holiday.jpg"}) == Stereo.Mode.MONO)
|
||||
var app: Node = load("res://scenes/main.tscn").instantiate()
|
||||
root.add_child(app)
|
||||
app.set_process(false)
|
||||
var original: ShaderMaterial = app.material
|
||||
app.api.server = "http://127.0.0.1:18964"
|
||||
var previous_page: Array = app.page.get_children()
|
||||
app.panel.show()
|
||||
app.keyboard.show()
|
||||
await app._show_photo({"Id": "photo", "Name": "Holiday.SBS.jpg", "Type": "Photo"})
|
||||
assert(not app.panel.visible and not app.keyboard.visible, "Opening a photo hides the library and keyboard before loading finishes")
|
||||
var indicator: PlayerLoading = app.world.player_loading
|
||||
assert(indicator.is_visible_in_tree(), "Photo loading is visible while the image request is pending")
|
||||
assert(indicator.label.text == "Loading photo...")
|
||||
app._process(0.01)
|
||||
assert(indicator.is_visible_in_tree(), "Frame updates preserve photo loading feedback")
|
||||
assert(app.page.get_children() == previous_page, "Photos preserve the library instead of opening a preview page")
|
||||
var texture := ImageTexture.create_from_image(image)
|
||||
var screen_transform: Transform3D = app.screen.global_transform
|
||||
var theatre_transform: Transform3D = app.theatre.global_transform
|
||||
app.camera.position += Vector3(0.5, 0.2, 0.3)
|
||||
app.camera.rotation.y += 0.6
|
||||
app._photo_ready(texture)
|
||||
assert(not indicator.visible, "A ready photo clears loading feedback")
|
||||
assert(app.screen.global_transform.is_equal_approx(screen_transform), "Opening a photo preserves screen placement after head movement")
|
||||
assert(app.theatre.global_transform.is_equal_approx(theatre_transform), "Opening a photo does not recenter the theatre")
|
||||
assert(app.photo_active and not app.playing)
|
||||
assert(app.screen.visible and app.screen.material_override == app.material)
|
||||
assert(app.world.panorama.material_override == app.material)
|
||||
assert(app.material.get_shader_parameter("video_texture") == texture)
|
||||
assert(app.material.get_shader_parameter("stereo") == true)
|
||||
assert(app.mode == Stereo.Mode.FULL_SBS)
|
||||
assert(not app.player_overlay.timeline.visible and not app.player_overlay.pause_button.visible)
|
||||
app._select_stereo(Stereo.Mode.TOP_BOTTOM)
|
||||
assert(app.material.get_shader_parameter("top_bottom") == true)
|
||||
app._select_projection(VideoProjection.Mode.EQUIRECTANGULAR)
|
||||
assert(app.world.panoramic)
|
||||
await app._stop(false)
|
||||
assert(not app.photo_active and not app.screen.visible)
|
||||
assert(app.material == original and app.screen.material_override == original)
|
||||
assert(app.world.panorama.material_override == original and not app.world.panoramic)
|
||||
assert(app.player_overlay.timeline.visible and app.player_overlay.pause_button.visible)
|
||||
await app._show_photo({"Id": "pending", "Name": "Pending", "Type": "Photo"})
|
||||
assert(indicator.is_visible_in_tree())
|
||||
app._photo_failed("Test failure")
|
||||
assert(app.panel.visible, "A failed photo returns to the library")
|
||||
assert(not indicator.visible and not app.photo_loading, "Failed photos clear loading feedback")
|
||||
await app._show_photo({"Id": "cancelled", "Name": "Cancelled", "Type": "Photo"})
|
||||
await app._stop(false)
|
||||
assert(not indicator.visible and not app.photo_loading, "Stopping a pending photo clears loading feedback")
|
||||
app.free()
|
||||
print("Photo viewer checks: PASS")
|
||||
quit()
|
||||
@@ -0,0 +1 @@
|
||||
uid://07rc56en65mn
|
||||
@@ -32,7 +32,7 @@ func run_checks() -> void:
|
||||
assert(indicator.label.text == "Buffering video...", "Buffering identifies why playback is waiting")
|
||||
assert(indicator.viewport.render_target_update_mode == SubViewport.UPDATE_ALWAYS, "The visible popup renders independently of video frames")
|
||||
var popup_material: StandardMaterial3D = indicator.material_override
|
||||
assert(popup_material.no_depth_test and popup_material.render_priority > 0, "The loading popup draws over video and playback controls")
|
||||
assert(not popup_material.no_depth_test and popup_material.render_priority > 0, "The loading popup respects closer geometry while drawing over playback controls")
|
||||
app.screen.hide()
|
||||
assert(indicator.viewport.render_target_update_mode == SubViewport.UPDATE_DISABLED, "Hiding the video also stops popup rendering")
|
||||
app.screen.show()
|
||||
|
||||
+159
-1
@@ -11,7 +11,7 @@ class FixtureApi extends JellyfinClient:
|
||||
func details(id: String) -> Dictionary:
|
||||
detail_calls.append(id)
|
||||
await get_tree().process_frame
|
||||
return {"ok": true, "data": {"Id": id, "Name": "Selected series" if id == "show" else "Selected movie", "Type": "Series" if id == "show" else "Movie"}}
|
||||
return {"ok": true, "data": {"Id": id, "Name": "Selected series" if id == "show" else "Selected movie", "Type": "Series" if id == "show" else "Movie", "UserData": {"PlaybackPositionTicks": 7200000000}}}
|
||||
|
||||
func seasons(_series_id: String) -> Dictionary:
|
||||
await get_tree().process_frame
|
||||
@@ -61,8 +61,39 @@ func run_checks() -> void:
|
||||
app.add_child(video)
|
||||
app.add_child(session)
|
||||
app.api = api
|
||||
api.token = "fixture"
|
||||
app.shell.set_signed_in(true)
|
||||
app.video = video
|
||||
app.session = session
|
||||
app.libraries_loaded = true
|
||||
app.shell.set_libraries([{"Id": "movies", "Name": "Movies", "CollectionType": "movies"}])
|
||||
var library_button: Button = app.shell.buttons["movies"]
|
||||
app._open_library("movies", "Movies")
|
||||
await settle()
|
||||
var browsing: LibraryView = app.page.get_child(0)
|
||||
browsing.field.text = "Selected movie"
|
||||
await browsing.refresh(browsing.field.text)
|
||||
var scroll_content := Control.new()
|
||||
scroll_content.custom_minimum_size.y = 2000
|
||||
browsing.content.add_child(scroll_content)
|
||||
await settle()
|
||||
browsing.scroll.scroll_vertical = 300
|
||||
var browsing_scroll := browsing.scroll.scroll_vertical
|
||||
check(browsing_scroll > 0, "The return fixture starts partway down a library")
|
||||
for attempt in range(2):
|
||||
app._screen_settings()
|
||||
check(app.shell.home.text == "Library", "Settings label the return button Library")
|
||||
check(app.shell.settings_navigation.visible and not app.shell.libraries.visible, "Settings categories replace the library list")
|
||||
check(app.shell.home.visible and not app.shell.settings_button.visible and not app.shell.logout.visible, "Library is the only regular navigation action shown in settings")
|
||||
check(app.shell.settings_navigation.get_child_count() == 1, "Reopening settings does not duplicate category buttons")
|
||||
app.shell.home.pressed.emit()
|
||||
await settle()
|
||||
check(app.page.get_child(0) == browsing and app.shell.libraries.visible, "Library returns to the same browsing page and restores the libraries")
|
||||
check(app.shell.selected_id == "movies" and app.shell.heading.text == "Movies", "Returning restores the selected library and heading")
|
||||
check(browsing.search == "Selected movie" and browsing.field.text == browsing.search and browsing.scroll.scroll_vertical == browsing_scroll, "Returning preserves the search and scroll position")
|
||||
check(app.shell.home.text == "Home", "Normal browsing restores the Home button")
|
||||
check(app.shell.buttons["movies"] == library_button, "Returning Home preserves the loaded library buttons")
|
||||
check(app.shell.settings_navigation.get_child_count() == 0 and app.shell.settings_button.visible and app.shell.logout.visible, "Leaving settings clears categories and restores normal navigation")
|
||||
|
||||
await app._details({"Id": "movie"})
|
||||
var movie_view: DetailsView = app.page.get_child(0)
|
||||
@@ -77,6 +108,15 @@ func run_checks() -> void:
|
||||
await settle()
|
||||
check(session.opens == 1 and video.stops == 0 and not video.paused, "Returning to the active movie resumes the current decoder without restarting")
|
||||
check(app.player_overlay.visible, "The movie's play button returns to the playback controls")
|
||||
movie_view.play_from_start_button.pressed.emit()
|
||||
await settle()
|
||||
check(session.opens == 2 and video.stops == 1, "Play from start restarts an already playing title")
|
||||
check(session.item.UserData.PlaybackPositionTicks == 0, "Restarted playback receives a zero start position")
|
||||
check(movie_view.item.UserData.PlaybackPositionTicks == 7200000000, "Restarting does not overwrite the details page's saved progress")
|
||||
await app._stop(false)
|
||||
movie_view.play_from_start_button.pressed.emit()
|
||||
await settle()
|
||||
check(session.opens == 3 and session.item.UserData.PlaybackPositionTicks == 0, "Play from start also starts stopped titles at zero")
|
||||
|
||||
await app._details({"Id": "show"})
|
||||
await settle()
|
||||
@@ -84,6 +124,10 @@ func run_checks() -> void:
|
||||
series_view.series_browser.open_season(api.seasons_list[1])
|
||||
await settle()
|
||||
var card: EpisodeCard = series_view.series_browser.episode_list.get_child(0)
|
||||
app._screen_settings()
|
||||
app.shell.home.pressed.emit()
|
||||
await settle()
|
||||
check(app.page.get_child(0) == series_view and series_view.series_browser.selected_season.Id == "season-two", "Library returns to the same series and selected season from settings")
|
||||
card.play_button.pressed.emit()
|
||||
await settle()
|
||||
check(session.item.get("SeasonId") == "season-two", "Playback remembers which season the selected episode belongs to")
|
||||
@@ -114,6 +158,120 @@ func run_checks() -> void:
|
||||
check(app.page.get_child(0) is LibraryView, "Back to library still opens the library grid")
|
||||
await settle()
|
||||
|
||||
api.token = "fixture"
|
||||
app.libraries_loaded = true
|
||||
root.use_xr = true
|
||||
app.playing = true
|
||||
app.screen.show()
|
||||
app._show_playback()
|
||||
app.player_overlay.hide()
|
||||
var stops_before_library := video.stops
|
||||
var opens_before_library := session.opens
|
||||
var paused_before_library := video.paused
|
||||
app.world._controller_button_pressed("by_button", &"right_hand")
|
||||
check(app.page.get_child(0) is LibraryView and app.panel.visible, "B opens the library while video controls are hidden")
|
||||
check(app.playing and app.screen.visible and not app.player_overlay.visible, "B preserves playback and hides the player controls")
|
||||
check(video.stops == stops_before_library and session.opens == opens_before_library and video.paused == paused_before_library, "B does not stop, restart, or pause the video")
|
||||
await app._play_stream("", {}, 0)
|
||||
check(app.panel.visible and not app.player_overlay.visible, "A stream becoming ready does not replace a library opened with B")
|
||||
app._video_prepared()
|
||||
check(app.panel.visible, "Video preparation does not hide a library opened with B")
|
||||
var controller_library: Node = app.page.get_child(0)
|
||||
app.world._controller_button_pressed("by_button", &"right_hand")
|
||||
check(not app.panel.visible and not app.player_overlay.visible and app.playing, "Pressing B again closes the library and returns to the video")
|
||||
app.world._controller_button_pressed("by_button", &"right_hand")
|
||||
check(app.panel.visible and app.page.get_child(0) == controller_library, "B reopens the library without rebuilding the page")
|
||||
app.world._controller_button_pressed("ax_button", &"right_hand")
|
||||
check(app.player_overlay.visible and not app.panel.visible, "A still opens the playback controls")
|
||||
app.world._controller_button_pressed("menu_button", &"left_hand")
|
||||
check(app.player_overlay.visible and not app.panel.visible, "Left menu is reserved for the runtime and does not change the UI")
|
||||
app.world._controller_button_pressed("by_button", &"left_hand")
|
||||
check(app.page.get_child(0) is SettingsView and app.panel.visible, "Y opens settings while playback controls are hidden")
|
||||
check(not app.player_overlay.visible, "Opening settings hides playback controls")
|
||||
app.world._controller_button_pressed("by_button", &"left_hand")
|
||||
check(not app.panel.visible and not app.player_overlay.visible, "Y closes settings without showing playback controls, just like B")
|
||||
app.world._controller_button_pressed("by_button", &"left_hand")
|
||||
check(app.panel.visible and app.page.get_child(0) is SettingsView, "Y reopens settings during playback")
|
||||
check(app.playing and video.stops == stops_before_library and session.opens == opens_before_library and video.paused == paused_before_library, "Settings toggling leaves playback uninterrupted")
|
||||
app.world._controller_button_pressed("by_button", &"left_hand")
|
||||
app.world._controller_button_pressed("by_button", &"right_hand")
|
||||
check(app.panel.visible and app.page.get_child(0) == controller_library, "After Y opens and closes settings, B restores the previous library page")
|
||||
check(app.shell.libraries.visible and not app.shell.settings_navigation.visible, "B restores library navigation after hidden settings")
|
||||
check(app.playing and video.stops == stops_before_library and session.opens == opens_before_library and video.paused == paused_before_library, "Returning from settings with B leaves playback uninterrupted")
|
||||
app.panel.hide()
|
||||
app.playing = false
|
||||
app.screen.hide()
|
||||
app.world._controller_button_pressed("by_button", &"right_hand")
|
||||
check(app.panel.visible and app.page.get_child(0) is LibraryView, "B also opens the library outside playback")
|
||||
app.world._controller_button_pressed("by_button", &"right_hand")
|
||||
check(not app.panel.visible, "B also closes the library outside playback")
|
||||
app.world._controller_button_pressed("by_button", &"left_hand")
|
||||
check(app.panel.visible and app.page.get_child(0) is SettingsView, "Y opens settings from a hidden library")
|
||||
var settings_view := app.page.get_child(0) as SettingsView
|
||||
settings_view._show_section(3)
|
||||
var settings_generation: int = app.page_generation
|
||||
app.world._controller_button_pressed("by_button", &"left_hand")
|
||||
check(not app.panel.visible and not app.player_overlay.visible and app.page.get_child(0) == settings_view, "Y hides settings from any section, just like B")
|
||||
app.world._controller_button_pressed("by_button", &"left_hand")
|
||||
check(app.panel.visible and app.page.get_child(0) == settings_view and settings_view.sections[3].visible, "Y restores the same settings page and selected section")
|
||||
check(app.page_generation == settings_generation, "Toggling settings does not rebuild the page")
|
||||
app._screen_settings()
|
||||
app.world._controller_button_pressed("by_button", &"right_hand")
|
||||
check(app.panel.visible and app.page.get_child(0) is LibraryView, "B returns to browsing when settings are visible")
|
||||
var returned_library: Node = app.page.get_child(0)
|
||||
app.world._controller_button_pressed("by_button", &"right_hand")
|
||||
check(not app.panel.visible and app.page.get_child(0) == returned_library, "Pressing B again hides the restored library")
|
||||
app.world._controller_button_pressed("by_button", &"right_hand")
|
||||
check(app.panel.visible and app.page.get_child(0) == returned_library, "B reopens the restored library without rebuilding it")
|
||||
await app._details({"Id": "movie"})
|
||||
app.playing = true
|
||||
app.screen.show()
|
||||
var controller_details: Node = app.page.get_child(0)
|
||||
var details_generation: int = app.page_generation
|
||||
var detail_calls_before_toggle := api.detail_calls.size()
|
||||
app.world._controller_button_pressed("by_button", &"right_hand")
|
||||
check(not app.panel.visible and app.page.get_child(0) == controller_details, "B hides title details instead of navigating Home")
|
||||
app.world._controller_button_pressed("by_button", &"right_hand")
|
||||
check(app.panel.visible and app.page.get_child(0) == controller_details, "B restores the same title details during playback")
|
||||
check(app.page_generation == details_generation and api.detail_calls.size() == detail_calls_before_toggle, "Toggling details does not rebuild or reload the page")
|
||||
check(app.playing and app.screen.visible and video.stops == stops_before_library and session.opens == opens_before_library, "Toggling details leaves the playing video untouched")
|
||||
app.playing = false
|
||||
app.autoplay_pending = true
|
||||
app.panel.hide()
|
||||
app.screen.show()
|
||||
app.world.player_loading.show_countdown("Next episode", 5)
|
||||
app.world._controller_button_pressed("by_button", &"right_hand")
|
||||
check(app.panel.visible and not app.autoplay_pending and not app.screen.visible, "B cancels autoplay when opening the library")
|
||||
api.token = ""
|
||||
app._show_login()
|
||||
var login_generation: int = app.page_generation
|
||||
var login_page: Node = app.page.get_child(0)
|
||||
check(app.shell.settings_button.disabled, "Settings navigation is disabled before sign-in")
|
||||
app.world._controller_button_pressed("by_button", &"right_hand")
|
||||
check(app.page_generation == login_generation, "B cannot bypass sign-in")
|
||||
app.world._controller_button_pressed("by_button", &"left_hand")
|
||||
check(app.page_generation == login_generation and app.page.get_child(0) == login_page, "Y cannot open settings before sign-in")
|
||||
app.world._controller_button_pressed("by_button", &"left_hand")
|
||||
app._screen_settings()
|
||||
app.shell.screen_settings.emit()
|
||||
app._player_action("settings")
|
||||
check(app.page_generation == login_generation and app.page.get_child(0) == login_page and app.panel.visible, "Every settings entry point leaves sign-in visible without a session")
|
||||
api.token = "fixture"
|
||||
app.world._controller_button_pressed("by_button", &"left_hand")
|
||||
app._screen_settings()
|
||||
check(app.page_generation == login_generation, "A token awaiting session validation cannot open settings from sign-in")
|
||||
app._show_library()
|
||||
check(not app.shell.settings_button.disabled, "Settings navigation is enabled after sign-in")
|
||||
app.world._controller_button_pressed("by_button", &"left_hand")
|
||||
check(app.panel.visible and app.page.get_child(0) is SettingsView, "Y opens settings again after sign-in")
|
||||
api.token = ""
|
||||
app.panel.hide()
|
||||
var settings_generation_after_logout: int = app.page_generation
|
||||
app.world._controller_button_pressed("by_button", &"left_hand")
|
||||
check(not app.panel.visible and app.page_generation == settings_generation_after_logout, "Y cannot reopen retained settings after a session is cleared")
|
||||
root.use_xr = false
|
||||
await settle()
|
||||
|
||||
app.playing = false
|
||||
app.queue_free()
|
||||
await process_frame
|
||||
|
||||
@@ -36,6 +36,20 @@ func run_checks() -> void:
|
||||
await process_frame
|
||||
check(overlay.tooltip.text == "Seek to a time", "The playback timeline also shows its tooltip inside the player")
|
||||
pointer.cancel()
|
||||
var tracks: Button = button.get_parent().get_child(5)
|
||||
var actions: Array[String] = []
|
||||
overlay.action.connect(func(value: String): actions.append(value))
|
||||
pointer.sample_trigger(0.0)
|
||||
pointer.aim(tracks.get_global_rect().get_center(), true)
|
||||
await process_frame
|
||||
check(overlay.tooltip.text == "Audio and subtitles (coming soon)", "The disabled track placeholder explains its unavailable state on hover")
|
||||
pointer.sample_trigger(1.0)
|
||||
pointer.aim(tracks.get_global_rect().get_center(), true)
|
||||
pointer.sample_trigger(0.0)
|
||||
pointer.aim(tracks.get_global_rect().get_center(), true)
|
||||
await process_frame
|
||||
check(actions.is_empty(), "The track placeholder cannot trigger playback actions")
|
||||
pointer.cancel()
|
||||
button.grab_focus()
|
||||
await process_frame
|
||||
check(overlay.tooltip.text == "Play", "Keyboard focus also exposes the button's label")
|
||||
@@ -44,6 +58,7 @@ func run_checks() -> void:
|
||||
check(overlay.tooltip.text.is_empty(), "Hiding controls clears the tooltip")
|
||||
var row := button.get_parent()
|
||||
for control in row.get_children():
|
||||
if not control is Button: continue
|
||||
check(control.icon != null and "assets/icons/material/" in control.icon.resource_path, "Every player button loads a Material Symbols SVG")
|
||||
check(row.get_child(2).icon.resource_path.ends_with("replay_10.svg") and row.get_child(3).icon.resource_path.ends_with("forward_30.svg"), "Skip controls use symbols containing their durations")
|
||||
overlay.queue_free()
|
||||
|
||||
+91
-1
@@ -1,5 +1,14 @@
|
||||
extends SceneTree
|
||||
|
||||
class FixtureVideo extends VideoBackend:
|
||||
func available() -> bool: return true
|
||||
func open(_url: String, _authorization: String, _start_ms: int) -> void: pass
|
||||
func stop() -> void: pass
|
||||
|
||||
class FixtureSession extends PlaybackSession:
|
||||
func open(selected: Dictionary, _transcode: bool = false) -> void: item = selected
|
||||
func report(_event: String, _paused: bool = false) -> void: pass
|
||||
|
||||
var calls := 0
|
||||
|
||||
func _initialize() -> void:
|
||||
@@ -48,10 +57,12 @@ func run_checks() -> void:
|
||||
assert(app.player_overlay.get_parent() == app.screen, "The overlay follows the movie screen")
|
||||
app._toggle_menu()
|
||||
assert(not app.player_overlay.visible and app.screen.global_transform == screen_transform, "Hiding the overlay does not move the movie")
|
||||
app.api.token = "fixture"
|
||||
app.shell.set_signed_in(true)
|
||||
app._screen_settings()
|
||||
assert(app.panel.visible and not app.player_overlay.visible, "Settings open in the main window")
|
||||
var menu_material: StandardMaterial3D = app.panel.material_override
|
||||
assert(menu_material.no_depth_test and menu_material.render_priority > 0, "Main-window pages draw over a nearer movie screen")
|
||||
assert(not menu_material.no_depth_test, "Main-window pages respect controller depth")
|
||||
app._show_playback()
|
||||
assert(not app.panel.visible and app.player_overlay.visible, "Returning from settings restores the overlay")
|
||||
var before_reset: Transform3D = app.screen.global_transform
|
||||
@@ -64,8 +75,87 @@ func run_checks() -> void:
|
||||
assert(app.screen.global_basis.is_equal_approx(app.camera.global_basis), "Headset recenter uses the current viewing direction")
|
||||
assert(app.panel.global_position.is_equal_approx(app.camera.global_position - app.camera.global_basis.z * 2.7), "Headset recenter also moves the library menu")
|
||||
assert(not app.panel.visible and app.player_overlay.visible, "Headset recenter preserves menu visibility")
|
||||
check_menu_placement(app)
|
||||
await check_playback_placement(app)
|
||||
check_screen_follow(app)
|
||||
app.playing = false
|
||||
root.use_xr = false
|
||||
if added_interface: XRServer.remove_interface(xr)
|
||||
print("Startup recenter checks: PASS")
|
||||
quit()
|
||||
|
||||
func check_screen_follow(app: Node) -> void:
|
||||
app.settings.follow_screen = true
|
||||
app.settings.distance = 4.0
|
||||
app.settings.height = 0.4
|
||||
app.projection_mode = VideoProjection.Mode.SCREEN
|
||||
var menu_transform: Transform3D = app.panel.global_transform
|
||||
for environment in ["Void", "Passthrough"]:
|
||||
app.settings.environment = environment
|
||||
app.camera.position += Vector3(0.5, 0.2, -0.3)
|
||||
app.camera.rotation = Vector3(0.3, 0.7, 0.2)
|
||||
app._update_screen_follow()
|
||||
assert(app.screen.global_basis.is_equal_approx(app.camera.global_basis), "Following tracks head rotation in adjustable environments")
|
||||
var expected: Vector3 = app.camera.global_position - app.camera.global_basis.z * 4.0 + app.camera.global_basis.y * 0.4
|
||||
assert(app.screen.global_position.is_equal_approx(expected), "Following tracks translation and preserves distance and height")
|
||||
assert(app.panel.global_transform.is_equal_approx(menu_transform), "Following the movie does not move the settings window")
|
||||
app.settings.follow_screen = false
|
||||
var stationary: Transform3D = app.screen.global_transform
|
||||
app.camera.position += Vector3.ONE
|
||||
app.camera.rotate_y(0.4)
|
||||
app._update_screen_follow()
|
||||
assert(app.screen.global_transform.is_equal_approx(stationary), "Disabling following leaves the screen at its last position")
|
||||
app.settings.follow_screen = true
|
||||
for environment in ["Cinema", "Theatre"]:
|
||||
app.settings.environment = environment
|
||||
app._change_environment()
|
||||
var fixed_screen: Transform3D = app.screen.global_transform
|
||||
app.camera.position += Vector3.ONE
|
||||
app.camera.rotate_y(0.4)
|
||||
app._update_screen_follow()
|
||||
assert(app.screen.global_transform.is_equal_approx(fixed_screen), "Cinema and Theatre screens never follow head movement")
|
||||
app.settings.environment = "Void"
|
||||
for projection in [VideoProjection.Mode.EQUIRECTANGULAR_180, VideoProjection.Mode.EQUIRECTANGULAR]:
|
||||
app.projection_mode = projection
|
||||
var before: Transform3D = app.screen.global_transform
|
||||
var panorama: Basis = app.world.panorama.global_basis
|
||||
app.camera.rotate_y(0.4)
|
||||
app._update_screen_follow()
|
||||
assert(app.screen.global_transform.is_equal_approx(before) and app.world.panorama.global_basis.is_equal_approx(panorama), "Following does not rotate immersive video with the head")
|
||||
app.settings.follow_screen = false
|
||||
app.projection_mode = VideoProjection.Mode.SCREEN
|
||||
|
||||
func check_playback_placement(app: Node) -> void:
|
||||
app.video.queue_free()
|
||||
app.session.queue_free()
|
||||
app.video = FixtureVideo.new()
|
||||
app.session = FixtureSession.new()
|
||||
app.add_child(app.video)
|
||||
app.add_child(app.session)
|
||||
for environment in ["Void", "Cinema", "Theatre"]:
|
||||
app.settings.environment = environment
|
||||
app._change_environment()
|
||||
app._recenter()
|
||||
var screen_transform: Transform3D = app.screen.global_transform
|
||||
var theatre_transform: Transform3D = app.theatre.global_transform
|
||||
var panorama_basis: Basis = app.world.panorama.global_basis
|
||||
app.camera.position += Vector3(0.4, 0.2, 0.3)
|
||||
app.camera.rotate_y(0.5)
|
||||
await app._start({"Id": environment})
|
||||
assert(app.screen.global_transform.is_equal_approx(screen_transform), "Starting an item preserves screen placement")
|
||||
for path in ["Normal.mp4", "Beach.VR180.mp4", "Beach.360.mp4", "Normal.mp4"]:
|
||||
await app._play_stream("", {"Path": path}, 0)
|
||||
assert(app.screen.global_transform.is_equal_approx(screen_transform), "Starting a stream preserves screen placement")
|
||||
assert(app.theatre.global_transform.is_equal_approx(theatre_transform), "Starting a stream preserves theatre placement")
|
||||
assert(app.world.panorama.global_basis.is_equal_approx(panorama_basis), "Starting a stream preserves panorama direction")
|
||||
app.camera.rotate_y(0.2)
|
||||
|
||||
func check_menu_placement(app: Node) -> void:
|
||||
for environment in ["Theatre", "Cinema", "Void"]:
|
||||
app.settings.environment = environment
|
||||
app._change_environment()
|
||||
for pitch in [-70.0, -30.0, 0.0]:
|
||||
app.camera.rotation = Vector3(deg_to_rad(pitch), 0.4, 0.3)
|
||||
app._recenter()
|
||||
assert(app.panel.global_basis.is_equal_approx(app.camera.global_basis), "Menu recenter preserves the viewing direction")
|
||||
assert(app.panel.global_position.is_equal_approx(app.camera.global_position - app.camera.global_basis.z * 2.7), "Room geometry does not shift the menu position")
|
||||
@@ -56,5 +56,54 @@ func run_checks() -> void:
|
||||
await process_frame
|
||||
assert(cast.scroll.scroll_horizontal == 0, "Left joystick input must move the cast carousel back.")
|
||||
pointer.cancel()
|
||||
cast.queue_free()
|
||||
shell.queue_free()
|
||||
await process_frame
|
||||
await check_screen_sliders(viewport, pointer)
|
||||
feedback.player.stop()
|
||||
quit(0 if selections == 1 else 1)
|
||||
|
||||
func check_screen_sliders(viewport: SubViewport, pointer: PointerInput) -> void:
|
||||
var scroller := ScrollContainer.new()
|
||||
scroller.size = Vector2(800, 400)
|
||||
scroller.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
||||
viewport.add_child(scroller)
|
||||
var body := VBoxContainer.new()
|
||||
body.custom_minimum_size = Vector2(700, 1200)
|
||||
scroller.add_child(body)
|
||||
var slider := preload("res://scripts/ui/screen_position_slider.gd").new()
|
||||
slider.custom_minimum_size = Vector2(600, 48)
|
||||
slider.min_value = 1.5
|
||||
slider.max_value = 7.0
|
||||
slider.step = 0.05
|
||||
slider.value = 3.5
|
||||
body.add_child(slider)
|
||||
await process_frame
|
||||
await process_frame
|
||||
var point := slider.get_global_rect().get_center()
|
||||
pointer.aim(point, true)
|
||||
for index in range(5): pointer.scroll(point, 0.2, true)
|
||||
assert(is_equal_approx(slider.value, 3.55), "Right adjusts the hovered screen slider by one step.")
|
||||
for index in range(5): pointer.scroll(point, -0.2, true)
|
||||
assert(is_equal_approx(slider.value, 3.5), "Left reduces the hovered screen slider.")
|
||||
assert(scroller.scroll_vertical == 0, "Horizontal slider input must not scroll vertically.")
|
||||
slider.grab_focus()
|
||||
pointer.scroll(point, -0.2)
|
||||
assert(is_equal_approx(slider.value, 3.5), "Down must not change even a focused slider.")
|
||||
assert(scroller.scroll_vertical > 0, "Down over a slider must scroll the page.")
|
||||
pointer.scroll(point, 0.2)
|
||||
assert(is_equal_approx(slider.value, 3.5), "Up must not change a slider.")
|
||||
assert(scroller.scroll_vertical == 0, "Up over a slider must scroll the page back.")
|
||||
slider.editable = false
|
||||
for index in range(10): pointer.scroll(point, 0.2, true)
|
||||
assert(is_equal_approx(slider.value, 3.5), "Disabled sliders must ignore horizontal input.")
|
||||
point = Vector2(300, 200)
|
||||
pointer.aim(point, true)
|
||||
for index in range(10): pointer.scroll(point, 0.2, true)
|
||||
assert(scroller.scroll_vertical == 0, "Horizontal input over a vertical page must not scroll down.")
|
||||
pointer.scroll(point, -0.2)
|
||||
var vertical_position := scroller.scroll_vertical
|
||||
assert(vertical_position > 0, "Vertical input still scrolls the page.")
|
||||
for index in range(10): pointer.scroll(point, -0.2, true)
|
||||
assert(scroller.scroll_vertical == vertical_position, "Left must not scroll a vertical page up.")
|
||||
pointer.cancel()
|
||||
+40
-8
@@ -38,6 +38,8 @@ func run_checks() -> void:
|
||||
settings.library_sort_by = "PremiereDate"
|
||||
settings.library_sort_order = "Descending"
|
||||
settings.environment = "Passthrough"
|
||||
check(not settings.follow_screen, "Screen following is off by default.")
|
||||
settings.follow_screen = true
|
||||
settings.playback_mode = "original"
|
||||
settings.transcode_resolution = 2160
|
||||
var path := "res://dist/settings-test.cfg"
|
||||
@@ -46,6 +48,7 @@ func run_checks() -> void:
|
||||
restored.load_settings(path)
|
||||
check(restored.library_sort_by == "PremiereDate" and restored.library_sort_order == "Descending", "Sorting survives a restart.")
|
||||
check(restored.environment == "Passthrough", "Passthrough preference survives a restart.")
|
||||
check(restored.follow_screen, "Screen following survives a restart.")
|
||||
check(restored.playback_mode == "original", "Playback preference survives a restart.")
|
||||
check(restored.transcode_resolution == 2160, "Transcoded 4K preference survives a restart.")
|
||||
var config := ConfigFile.new()
|
||||
@@ -62,9 +65,11 @@ func run_checks() -> void:
|
||||
check(restored.playback_mode == "auto", "Invalid playback preferences fall back to Automatic.")
|
||||
check(restored.transcode_resolution == 1080, "Invalid conversion resolutions fall back to 1080p.")
|
||||
config.erase_section_key("playback", "transcode_resolution")
|
||||
config.erase_section_key("screen", "follow_screen")
|
||||
config.save(path)
|
||||
restored.load_settings(path)
|
||||
check(restored.transcode_resolution == 1080, "Existing settings without a resolution keep the previous 1080p quality.")
|
||||
check(not restored.follow_screen, "Existing settings without screen following keep a stationary screen.")
|
||||
for names in [["Theatre", "Cinema"], ["Theatre LowRes", "Theatre"]]:
|
||||
if config.has_section_key("screen", "environment_version"):
|
||||
config.erase_section_key("screen", "environment_version")
|
||||
@@ -101,23 +106,27 @@ func run_checks() -> void:
|
||||
shell.set_signed_in(true)
|
||||
shell.heading.text = "Settings"
|
||||
var view := SettingsView.new()
|
||||
shell.set_settings_mode(true)
|
||||
view.navigation_host = shell.settings_navigation
|
||||
var fixture := FixtureSettings.new()
|
||||
view.settings = fixture
|
||||
view.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
shell.page.add_child(view)
|
||||
await process_frame
|
||||
for button in view.find_children("*", "Button", true, false):
|
||||
check(button.text != "Back", "Settings pages do not show a Back button.")
|
||||
var release_date: CheckBox = view.sort_choices.get_child(8)
|
||||
release_date.button_pressed = true
|
||||
release_date.pressed.emit()
|
||||
check(fixture.library_sort_by == "PremiereDate" and fixture.saves == 1, "Selecting a sort field updates and saves the preference.")
|
||||
var descending: CheckBox = view.sections[0].get_child(2).get_child(1)
|
||||
var descending: CheckBox = view.sort_order_choices.get_child(1)
|
||||
descending.button_pressed = true
|
||||
descending.pressed.emit()
|
||||
check(fixture.library_sort_order == "Descending" and fixture.saves == 2, "Selecting sort order updates and saves the preference.")
|
||||
check(view.sections[0].visible and not view.sections[1].visible, "Library sorting is the default section.")
|
||||
if "--capture" in OS.get_cmdline_user_args():
|
||||
await capture("settings-library")
|
||||
var screen_tab: Button = view.get_child(0).get_child(1)
|
||||
var screen_tab: Button = view.navigation_buttons[1]
|
||||
screen_tab.button_pressed = true
|
||||
screen_tab.pressed.emit()
|
||||
check(not view.sections[0].visible and view.sections[1].visible, "Screen settings are reachable through the section selector.")
|
||||
@@ -144,16 +153,16 @@ func run_checks() -> void:
|
||||
check(is_equal_approx(restored_slider.value, 3.6) and restored_format.button_pressed, "Reset updates displayed values and preserves the current video format.")
|
||||
if "--capture" in OS.get_cmdline_user_args():
|
||||
await capture("settings-screen")
|
||||
var environment_tab: Button = view.get_child(0).get_child(2)
|
||||
var environment_tab: Button = view.navigation_buttons[2]
|
||||
environment_tab.button_pressed = true
|
||||
environment_tab.pressed.emit()
|
||||
check(view.sections[2].visible and not view.sections[1].visible, "Environment has its own settings page.")
|
||||
var theatre_choice: CheckBox = view.sections[2].get_child(2).get_child(1)
|
||||
var theatre_choice: CheckBox = view.environment_buttons["Cinema"]
|
||||
theatre_choice.button_pressed = true
|
||||
theatre_choice.pressed.emit()
|
||||
var theatre_slider: HSlider = view.screen_dimensions.get_child(0).get_child(1)
|
||||
check(not theatre_slider.editable, "Fixed Theatre projection disables Void screen positioning.")
|
||||
var low_res_choice: CheckBox = view.sections[2].get_child(2).get_child(2)
|
||||
var low_res_choice: CheckBox = view.environment_buttons["Theatre"]
|
||||
low_res_choice.button_pressed = true
|
||||
low_res_choice.pressed.emit()
|
||||
check(fixture.environment == "Theatre" and fixture.is_theatre(), "LowRes is a separate theatre option.")
|
||||
@@ -166,7 +175,7 @@ func run_checks() -> void:
|
||||
var restored_low_res := JellyframeSettings.new()
|
||||
restored_low_res.load_settings(low_res_path)
|
||||
check(restored_low_res.environment == "Theatre", "LowRes selection survives restarting the app.")
|
||||
var passthrough_choice: CheckBox = view.sections[2].get_child(2).get_child(3)
|
||||
var passthrough_choice: CheckBox = view.environment_buttons["Passthrough"]
|
||||
check(passthrough_choice.disabled, "Unsupported devices cannot select Passthrough.")
|
||||
passthrough_choice.disabled = false
|
||||
passthrough_choice.button_pressed = true
|
||||
@@ -174,6 +183,12 @@ func run_checks() -> void:
|
||||
check(fixture.environment == "Passthrough", "Passthrough selection updates the saved environment.")
|
||||
var passthrough_slider: HSlider = view.screen_dimensions.get_child(0).get_child(1)
|
||||
check(passthrough_slider.editable, "Passthrough uses adjustable screen controls.")
|
||||
if "--capture" in OS.get_cmdline_user_args():
|
||||
view._show_section(1)
|
||||
await process_frame
|
||||
view.scroll.scroll_vertical = 10000
|
||||
await capture("settings-screen-position")
|
||||
view._show_section(2)
|
||||
theatre_choice.button_pressed = true
|
||||
theatre_choice.pressed.emit()
|
||||
passthrough_choice.disabled = true
|
||||
@@ -181,15 +196,24 @@ func run_checks() -> void:
|
||||
await capture("settings-environment")
|
||||
shell.custom_minimum_size = Vector2(760, 850)
|
||||
root.size = Vector2i(800, 900)
|
||||
var library_tab: Button = view.get_child(0).get_child(0)
|
||||
var library_tab: Button = view.navigation_buttons[0]
|
||||
library_tab.button_pressed = true
|
||||
library_tab.pressed.emit()
|
||||
await capture("settings-narrow")
|
||||
check(view.sort_choices.columns == 1, "Narrow settings use a single column.")
|
||||
check(shell.get_global_rect().end.x <= root.size.x, "Narrow settings must fit within the viewport.")
|
||||
check(view.scroll.size.y >= 300, "Narrow settings leave usable space for the scrolling content.")
|
||||
check(view.navigation.get_parent() == shell.settings_navigation and not shell.libraries.visible, "Settings categories stay in the main sidebar at narrow widths.")
|
||||
view.scroll.scroll_vertical = 10000
|
||||
await capture("settings-narrow-bottom")
|
||||
var playback_tab: Button = view.get_child(0).get_child(3)
|
||||
for index in [1, 2, 3]:
|
||||
view._show_section(index)
|
||||
await capture("settings-narrow-" + str(index))
|
||||
check(view.scroll.size.y >= 300 and shell.get_global_rect().end.x <= root.size.x, "Each narrow settings section remains visible and fits the window.")
|
||||
root.size = Vector2i(1440, 900)
|
||||
await process_frame
|
||||
await process_frame
|
||||
var playback_tab: Button = view.navigation_buttons[3]
|
||||
playback_tab.button_pressed = true
|
||||
playback_tab.pressed.emit()
|
||||
check(view.sections[3].visible and not view.sections[2].visible, "Playback has its own settings page.")
|
||||
@@ -222,6 +246,14 @@ func run_checks() -> void:
|
||||
await capture("settings-playback")
|
||||
view.scroll.scroll_vertical = 10000
|
||||
await capture("settings-playback-resolution")
|
||||
var follow_updates := []
|
||||
view.screen_follow_changed.connect(func(): follow_updates.append(true))
|
||||
var follow_saves := fixture.saves
|
||||
view.follow_switch.button_pressed = true
|
||||
check(fixture.follow_screen and fixture.saves == follow_saves + 1 and follow_updates.size() == 1, "Enabling screen following applies and saves once.")
|
||||
check(not view.follow_switch.disabled, "The shared follow switch remains available for Void when passthrough is unsupported.")
|
||||
view.follow_switch.button_pressed = false
|
||||
check(not fixture.follow_screen, "Disabling the shared switch stops screen following.")
|
||||
print("Settings failures: ", failures)
|
||||
quit(1 if failures else 0)
|
||||
|
||||
|
||||
@@ -13,8 +13,8 @@ func run_checks() -> void:
|
||||
items.append({"Id": "missing", "Name": "Mixed library"})
|
||||
items.append({"Id": "null", "Name": "Mixed with null type", "CollectionType": null})
|
||||
shell.set_libraries(items)
|
||||
assert(shell.buttons.size() == 6, "Only supported video libraries and untyped mixed libraries are shown")
|
||||
for id in ["movies", "tvshows", "homevideos", "mixed", "missing", "null"]:
|
||||
assert(shell.buttons.size() == 7, "Supported video, photo and untyped mixed libraries are shown")
|
||||
for id in ["movies", "tvshows", "homevideos", "photos", "mixed", "missing", "null"]:
|
||||
assert(shell.buttons.has(id))
|
||||
assert(shell.buttons[id].icon != null, "Each library has a leading icon")
|
||||
assert("assets/icons/material/" in shell.buttons[id].icon.resource_path, "Each library uses a Material Symbols icon")
|
||||
|
||||
@@ -24,6 +24,5 @@ func run_check() -> void:
|
||||
quit()
|
||||
|
||||
func play_clip() -> void:
|
||||
app.calibration = true
|
||||
app.session.item = {"Name": "Stereo check: LEFT in left eye, RIGHT in right eye"}
|
||||
app._play_stream(ProjectSettings.globalize_path("res://native/android/src/main/res/raw/stereo_test.mp4"), {"Video3DFormat": "HalfSideBySide"}, 0)
|
||||
app._play_stream(ProjectSettings.globalize_path("res://tests/fixtures/stereo_test.mp4"), {"Video3DFormat": "HalfSideBySide"}, 0)
|
||||
@@ -85,6 +85,8 @@ func check_environment_switching() -> void:
|
||||
app.settings = FixtureSettings.new()
|
||||
root.add_child(app)
|
||||
check(not app.theatre.visible, "Void starts without theatre geometry.")
|
||||
app.api.token = "fixture"
|
||||
app.shell.set_signed_in(true)
|
||||
app._screen_settings()
|
||||
var view: SettingsView = app.page.get_child(0)
|
||||
var theatre_choice: CheckBox = view.sections[2].get_child(2).get_child(1)
|
||||
|
||||
Reference in new issue
Block a user