43 Commits
Author SHA1 Message Date
Lizzy 504284af5b Bump version to 0.1.3 2026-10-27 23:04:12 +01:00
Lizzy 4d43b8c446 Show buffered range on playback timeline 2026-10-27 23:01:46 +01:00
Lizzy addd5215e5 Add statistics panel to player 2026-10-27 22:47:48 +01:00
Lizzy 9dd4283af5 Preserve browsing page when opening tracks view 2026-10-27 21:25:59 +01:00
Lizzy 906276ca4d Remove testing status sections from README 2026-10-27 21:06:15 +01:00
Lizzy e0306de3d0 Update USAGE.md 2026-10-27 21:03:26 +01:00
Lizzy e9883e8f5a Add audio track and subtitle selection 2026-10-27 21:02:59 +01:00
Lizzy ec9799e7e4 Create test_stop_progress.gd 2026-10-27 20:24:43 +01:00
Lizzy 9c6668d0ea Pace PC video frames against audio clock 2026-10-27 20:22:22 +01:00
Lizzy 5c7aa5673f Add Play series button and Next Up section 2026-10-27 20:04:37 +01:00
Lizzy 6833640385 Add manual watched/unwatched toggles 2026-10-27 19:41:18 +01:00
Lizzy ce95b7eb6b Add media version selection for playback 2026-10-27 19:19:06 +01:00
Lizzy 70871b3e4c Replace banner JPG with a new PNG 2026-10-27 15:33:16 +01:00
Lizzy fbbd9d7c4e Rename project from Jellyframe to jellyXR
Rebrand the app to jellyXR so the name is not tied to a single headset
2026-10-27 13:36:22 +01:00
Lizzy caad1138c5 Document hardware compatibility status 2026-10-27 11:19:28 +01:00
Lizzy 99a452b3cf Bump version to 0.1.2 2026-10-27 00:22:54 +01:00
Lizzy 7be9e371ec Adjust screen sliders with horizontal joystick 2026-10-27 00:22:13 +01:00
Lizzy f0dfaa5e95 Increase video read-ahead buffering to 30s/256MiB 2026-10-27 00:15:34 +01:00
Lizzy 2431892b28 Set explicit Jellyframe user agent for streams
Add stream_identity.h defining JELLYFRAME_STREAM_USER_AGENT ("Jellyframe") and apply it to both the mpv (user-agent) and FFmpeg (user_agent) HTTP options so streaming requests identify themselves consistently.

Update the recovery test to identify the video request by its connection instead of the User-Agent header, since the user agent is no longer a reliable differentiator.
2026-10-26 23:56:07 +01:00
Lizzy f579cf3f3b Redesign settings UI 2026-10-26 23:30:48 +01:00
Lizzy c9144dc1d9 Show watched badge on episode cards 2026-10-26 22:55:46 +01:00
Lizzy 2d2a5ab3f4 Add Godot UID files for shader and test scripts 2026-10-26 22:55:27 +01:00
Lizzy 207ee7bad9 Sample YUV planes directly in PC video shader 2026-10-26 22:42:38 +01:00
Lizzy f9ec1c7df9 Document Quest 3 validation 2026-10-26 21:19:18 +01:00
Lizzy 35025443ad Show loading indicator while photos load 2026-10-26 21:13:52 +01:00
Lizzy eddb901c03 Jellyframe naming and platform goals 2026-10-26 16:54:23 +01:00
Lizzy 779de3d555 Document plans for store availability 2026-10-26 16:35:26 +01:00
Lizzy a559c47885 Clarify Jellyframe's support goals 2026-10-26 09:44:13 +01:00
Lizzy 623387b802 Include video shader in Android export 2026-10-26 00:41:12 +01:00
Lizzy dc725f2a10 Add photo viewing 2026-10-25 15:45:10 +01:00
Lizzy ee9f33c801 Add Jellyfin feature checklist
Document implemented and missing Jellyfin features,
2026-10-24 21:33:47 +02:00
Alexa 55aaf5ed42 Remove the Android calibration playback 2026-10-24 20:46:14 +02:00
Alexa 5089f653be Style skip popup 2026-10-24 20:21:04 +02:00
Alexa 39909adfb4 Preserve viewing placement when playback starts 2026-10-24 20:07:41 +02:00
Alexa 6bb29755c0 Map VR settings to the Y button 2026-10-24 19:56:19 +02:00
Alexa 82aef92a45 Add Jellyfin media segment skip controls 2026-10-24 19:21:17 +02:00
Lizzy ff66861e2c Open settings with left controller menu button 2026-10-24 16:22:09 +02:00
Lizzy feef698940 Reorganize development and installation docs 2026-10-24 05:03:16 +02:00
Lizzy 85a0913747 Set default clear color to black 2026-10-24 04:14:06 +02:00
Lizzy c1a20857e7 Add Play from start button to details page 2026-10-24 04:12:07 +02:00
Lizzy eb3fac60df Make B open library during playback 2026-10-24 03:59:41 +02:00
Lizzy 2faa4cc47f Show real controller models alongside the laser pointers 2026-10-24 03:10:47 +02:00
Lizzy 5c87459407 Create CODEOWNERS 2026-10-24 01:55:17 +02:00
171 changed files with 4323 additions and 979 deletions

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
* @Cuteminded @LexiMallow
.py @Cuteminded
+3 -3
View File
@@ -95,7 +95,7 @@ jobs:
- name: Verify APK signature
run: |
"$ANDROID_HOME/build-tools/36.1.0/apksigner" verify --verbose dist/jellyframe-android.apk
"$ANDROID_HOME/build-tools/36.1.0/apksigner" verify --verbose dist/jellyxr-android.apk
- name: Set artifact date
id: date
@@ -104,8 +104,8 @@ jobs:
- name: Upload APK
uses: actions/upload-artifact@v7
with:
name: ${{ steps.date.outputs.date }}-jellyframe-android
path: dist/jellyframe-android.apk
name: ${{ steps.date.outputs.date }}-jellyxr-android
path: dist/jellyxr-android.apk
if-no-files-found: error
retention-days: 14
archive: false
+12 -12
View File
@@ -75,29 +75,29 @@ jobs:
run: |
output="dist/linux-${ARCH}"
mkdir -p "$output"
godot --headless --xr-mode off --path . --export-release "$EXPORT_PRESET" "$output/jellyframe.${ARCH}"
godot --headless --xr-mode off --path . --export-release "$EXPORT_PRESET" "$output/jellyxr.${ARCH}"
- name: Verify Linux export files
run: |
output="dist/linux-${ARCH}"
test -s "$output/jellyframe.${ARCH}"
test -s "$output/jellyframe_mpv.linux.${ARCH}.so"
chmod +x "$output/jellyframe.${ARCH}"
test -s "$output/jellyxr.${ARCH}"
test -s "$output/jellyxr_mpv.linux.${ARCH}.so"
chmod +x "$output/jellyxr.${ARCH}"
- name: Verify exported renderer settings
run: timeout 30 godot --headless --xr-mode off --main-pack "dist/linux-${ARCH}/jellyframe.${ARCH}" --script tests/test_export_renderer.gd
run: timeout 30 godot --headless --xr-mode off --main-pack "dist/linux-${ARCH}/jellyxr.${ARCH}" --script tests/test_export_renderer.gd
- name: Copy Linux licenses and installation guide
run: |
output="dist/linux-${ARCH}"
cp -r "build/linux/${ARCH}/licenses" "$output/"
cp LICENSE "$output/licenses/jellyframe.txt"
cp LICENSE "$output/licenses/jellyxr.txt"
cp LICENSING.md "$output/licenses/"
- name: Check Linux decoder dependencies
run: |
output="dist/linux-${ARCH}"
ldd "$output/jellyframe_mpv.linux.${ARCH}.so" | tee "$RUNNER_TEMP/dependencies.log"
ldd "$output/jellyxr_mpv.linux.${ARCH}.so" | tee "$RUNNER_TEMP/dependencies.log"
if grep -q 'not found' "$RUNNER_TEMP/dependencies.log"; then
echo 'The exported decoder has missing shared libraries.' >&2
exit 1
@@ -108,7 +108,7 @@ jobs:
- name: Package Linux build with system libraries
run: |
tar -czf "dist/jellyframe-linux-${ARCH}.tar.gz" -C "dist/linux-${ARCH}" .
tar -czf "dist/jellyxr-linux-${ARCH}.tar.gz" -C "dist/linux-${ARCH}" .
- name: Set artifact date
id: date
@@ -117,8 +117,8 @@ jobs:
- name: Upload Linux build with system libraries
uses: actions/upload-artifact@v7
with:
name: ${{ steps.date.outputs.date }}-jellyframe-linux-${{ matrix.arch }}-system-libs
path: dist/jellyframe-linux-${{ matrix.arch }}.tar.gz
name: ${{ steps.date.outputs.date }}-jellyxr-linux-${{ matrix.arch }}-system-libs
path: dist/jellyxr-linux-${{ matrix.arch }}.tar.gz
if-no-files-found: error
retention-days: 14
archive: false
@@ -126,8 +126,8 @@ jobs:
- name: Upload Linux AppImage
uses: actions/upload-artifact@v7
with:
name: ${{ steps.date.outputs.date }}-jellyframe-linux-${{ matrix.arch }}-appimage
path: dist/jellyframe-linux-${{ matrix.arch }}.AppImage
name: ${{ steps.date.outputs.date }}-jellyxr-linux-${{ matrix.arch }}-appimage
path: dist/jellyxr-linux-${{ matrix.arch }}.AppImage
if-no-files-found: error
retention-days: 14
archive: false
+4 -4
View File
@@ -57,18 +57,18 @@ jobs:
- name: Export Windows build
run: |
New-Item -ItemType Directory -Force dist/windows | Out-Null
& $env:GODOT --headless --xr-mode off --path . --export-release 'Windows' dist/windows/jellyframe.exe
& $env:GODOT --headless --xr-mode off --path . --export-release 'Windows' dist/windows/jellyxr.exe
if ($LASTEXITCODE -ne 0) { throw 'Windows export failed.' }
- name: Verify Windows export files
run: |
foreach ($file in 'jellyframe.exe', 'jellyframe.pck', 'jellyframe_mpv.dll', 'libmpv-2.dll', 'avcodec-62.dll', 'avformat-62.dll', 'avutil-60.dll', 'swscale-9.dll', 'swresample-6.dll') {
foreach ($file in 'jellyxr.exe', 'jellyxr.pck', 'jellyxr_mpv.dll', 'libmpv-2.dll', 'avcodec-62.dll', 'avformat-62.dll', 'avutil-60.dll', 'swscale-9.dll', 'swresample-6.dll') {
if (-not (Test-Path "dist/windows/$file")) { throw "Export is missing $file." }
}
- name: Verify exported renderer settings
run: |
& $env:GODOT --headless --xr-mode off --main-pack dist/windows/jellyframe.pck --script tests/test_export_renderer.gd
& $env:GODOT --headless --xr-mode off --main-pack dist/windows/jellyxr.pck --script tests/test_export_renderer.gd
if ($LASTEXITCODE -ne 0) { throw 'Windows renderer settings check failed.' }
- name: Copy Windows licenses
@@ -81,7 +81,7 @@ jobs:
- name: Upload Windows build
uses: actions/upload-artifact@v7
with:
name: jellyframe-windows
name: jellyxr-windows
path: dist/windows/
if-no-files-found: error
retention-days: 14
+2 -2
View File
@@ -14,8 +14,8 @@ android/build/
android/.build_version
native/local.properties
native/**/build/
addons/jellyframe_mpv/
addons/jellyframe_video/bin/
addons/jellyxr_mpv/
addons/jellyxr_video/bin/
addons/godotopenxrvendors/
/tests/__pycache__
/tools/__pycache__
+3 -3
View File
@@ -1,6 +1,6 @@
# Contributing to Jellyframe
# Contributing to jellyXR
Contributions can include bug fixes, playback improvements, VR controls, tests, and documentation. Reports of problems you find while using Jellyframe are also welcome.
Contributions can include bug fixes, playback improvements, VR controls, tests, and documentation. Reports of problems you find while using jellyXR are also welcome.
Read the [README](README.md) for the project's scope and current limitations, and [Build from source](DEVELOPMENT.md) for setup, build commands, tests, and the code layout.
@@ -8,7 +8,7 @@ Read the [README](README.md) for the project's scope and current limitations, an
Search the repository's issues before opening a new one. Include:
- The Jellyframe build or commit you used.
- The jellyXR build or commit you used.
- Your operating system, headset, and OpenXR runtime, where applicable.
- Your GPU and the OpenGL or Vulkan renderer used for PC playback.
- Your Jellyfin server version.
+133 -143
View File
@@ -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.
jellyXR uses Godot 4.7.2 and OpenXR. Install Godot and its matching export templates before building. Run the commands below from the project root, with `godot` on your `PATH`.
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 jellyXR 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,120 +32,28 @@ 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/jellyxr-android.apk`. They download and verify Godot OpenXR Vendors for Quest passthrough support.
## Build and test on Windows
To build through GitHub Actions, select **Actions > Build Android APK > Run workflow**. Each run creates a new debug signing key. If it differs from the installed APK's key, uninstall the old build first. Uninstalling removes saved settings and sign-in.
To build the Windows app locally, install Godot 4.7.2 with matching export templates and Python 3.12. Build the PC plugin before importing or exporting the project:
## Build on Windows
Install Python 3.12, then build the PC plugin and export the app:
```powershell
./tools/build-pc-video.ps1 -Python 'C:/path/to/python.exe'
New-Item -ItemType Directory -Force dist/windows
godot --headless --xr-mode off --path . --editor --import --quit
godot --headless --xr-mode off --path . --export-release 'Windows' dist/windows/jellyframe.exe
godot --headless --xr-mode off --path . --export-release 'Windows' dist/windows/jellyxr.exe
```
The plugin build script downloads pinned CMake, LLVM-MinGW, godot-cpp, 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/jellyxr_mpv`. You do not need a system-wide compiler installation.
The Windows workflow builds the plugin, exports the app, verifies the output files, and uploads the build. 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/jellyxr_mpv`. It downloads and verifies godot-cpp, and uses the system libmpv and FFmpeg development packages. FFmpeg 6 or newer is required.
For x64:
Export for x64:
```sh
mkdir -p dist/linux-x86_64
godot --headless --xr-mode off --path . --export-release 'Linux x64' dist/linux-x86_64/jellyframe.x86_64
godot --headless --xr-mode off --path . --export-release 'Linux x64' dist/linux-x86_64/jellyxr.x86_64
```
For ARM64:
Export for ARM64:
```sh
mkdir -p dist/linux-arm64
godot --headless --xr-mode off --path . --export-release 'Linux ARM64' dist/linux-arm64/jellyframe.arm64
godot --headless --xr-mode off --path . --export-release 'Linux ARM64' dist/linux-arm64/jellyxr.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/jellyxr.txt
cp LICENSING.md dist/linux-x86_64/licenses/
bash tools/package-linux-appimages.sh x86_64
# On an ARM64 build machine:
bash tools/package-linux-appimages.sh arm64
```
The packager reuses the decoder library bundler 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/jellyxr-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
@@ -211,18 +122,97 @@ godot --headless --xr-mode off --path . --script tests/test_http.gd
godot --headless --xr-mode off --path . --script tests/test_autoplay.gd
godot --headless --xr-mode off --path . --script tests/test_player_return.gd
godot --headless --xr-mode off --path . --script tests/test_player_tooltips.gd
godot --headless --xr-mode off --path . --script tests/test_tracks.gd
godot --headless --xr-mode off --path . --script tests/test_player_loading.gd
godot --headless --xr-mode off --path . --script tests/test_photos.gd
```
## Test PC video
These tests work on Windows and Linux. The examples use Windows paths. On Linux, use `python3` and replace the executable paths. The Python video tests need FFmpeg. Keep `ffprobe` beside the FFmpeg executable for the buffering tests.
### Decoder and streaming
```powershell
godot --headless --xr-mode off --path . --script tests/test_pc_video.gd
python tests/test_pc_streaming.py --godot 'C:/path/to/godot_console.exe'
```
### GPU playback and transitions
Run these with a graphics context:
```powershell
godot --xr-mode off --rendering-method gl_compatibility --path . --script tests/test_pc_gpu_video.gd
godot --xr-mode off --rendering-method mobile --rendering-driver vulkan --path . --script tests/test_pc_gpu_video.gd
godot --xr-mode off --rendering-method gl_compatibility --path . --script tests/test_pc_video_transitions.gd
godot --xr-mode off --rendering-method mobile --rendering-driver vulkan --path . --script tests/test_pc_video_transitions.gd
godot --xr-mode off --rendering-method mobile --rendering-driver vulkan --path . --script tests/test_pc_yuv_material.gd
python tests/test_pc_vulkan.py --godot 'C:/path/to/godot_console.exe' --ffmpeg 'C:/path/to/ffmpeg.exe'
python tests/test_pc_hdr.py --godot 'C:/path/to/godot_console.exe' --ffmpeg 'C:/path/to/ffmpeg.exe'
python tests/test_pc_streaming.py --godot 'C:/path/to/godot_console.exe' --vulkan
```
The transition test checks the black screen before playback and after stopping, then checks that a reopened video becomes visible. The Vulkan and HDR tests generate video fixtures and check color conversion, including HDR-to-SDR tone mapping.
PC Vulkan playback uploads the decoded YUV planes and samples them directly in the video material. Color conversion and HDR tone mapping run while drawing the screen or panorama, without a full-resolution RGBA intermediate texture. Hardware frames still pass through system memory before upload. OpenGL and software playback use the RGB material path. The YUV material test checks decoder padding, rotation, and stereo eye boundaries.
### Performance
Use a video long enough for the sample plus one second of warm-up:
```powershell
godot --xr-mode off --rendering-method mobile --rendering-driver vulkan --path . --script tests/test_pc_video_performance.gd -- --video='C:/videos/8k.mp4' --seconds=20 --minimum-fps=57
```
The test reports presentation rate, dropped frames, decoder and transfer timings, and main-loop frame times. It does not measure SteamVR performance.
### Buffering and recovery
Supply a video with audio and at least 20 seconds of content:
```powershell
python tests/test_pc_video_recovery.py --godot 'C:/path/to/godot_console.exe' --ffmpeg 'C:/path/to/ffmpeg.exe' --video 'C:/videos/8k-with-audio.mp4'
python tests/test_pc_video_recovery.py --godot 'C:/path/to/godot_console.exe' --ffmpeg 'C:/path/to/ffmpeg.exe' --video 'C:/videos/8k-with-audio.mp4' --expect-buffer-free
```
The first command checks recovery, audio synchronization, and a manual pause during interrupted delivery. The second checks that read-ahead absorbs a short interruption without buffering. Omitting `--video` generates a 4K HEVC fixture and requires FFmpeg's NVIDIA NVENC encoder.
### Headset playback
Start SteamVR and connect your headset, then run:
```sh
godot --rendering-method mobile --rendering-driver vulkan --path . --script tests/test_steamvr_video.gd
```
The test loops a labelled stereo clip for two minutes. Close one eye at a time to check the LEFT and RIGHT labels. Press A to open playback controls. When signed in, press B to browse the library during playback. Replace the Vulkan arguments with `--rendering-method gl_compatibility` to test OpenGL.
Desktop and headless tests do not verify headset tracking, controller behavior, stereo output, or playback performance in VR.
### Playback logs
To record a Vulkan playback log, run the exported app with SteamVR active:
```powershell
New-Item -ItemType Directory -Force logs | Out-Null
$env:JELLYXR_VIDEO_STATS = '1'
.\dist\windows\jellyxr.exe --rendering-method mobile --rendering-driver vulkan --log-file (Join-Path $PWD 'logs/vulkan-playback.log')
```
Set `JELLYXR_PC_RENDERER=software` to force the software fallback for troubleshooting. Remove access tokens and private server details before sharing logs.
## Check 180° and 360° rendering
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 +220,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.
+29 -43
View File
@@ -1,81 +1,67 @@
# Install and start Jellyframe
# Install and start jellyXR
## What you need
Download the package for your platform from **Assets** on the [latest release page](https://github.com/Cuteminded/jellyframe/releases/latest).
- Access to a Jellyfin server and an account on that server.
- For Quest, a Quest 3 with developer mode and USB debugging enabled, plus a computer to install the app.
- For Windows or Linux, a computer and a headset with a compatible OpenXR runtime.
You need a Jellyfin server and an account to use the app. To build jellyXR yourself, follow the [build instructions](DEVELOPMENT.md).
The initial Jellyfin compatibility target is version 10.11.6. Use HTTPS when connecting to a server outside your home network.
## Quest 3
## Try it on Quest 3
Enable developer mode and USB debugging on your headset. Install Android's `adb` tool on your computer and download `jellyxr-android.apk`.
Download 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
adb install -r jellyxr-android.apk
```
If you built the APK yourself, use `dist/jellyframe-android.apk` as the install path instead.
Open jellyXR 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 `jellyxr.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 jellyXR cannot start with Vulkan, try OpenGL from PowerShell in the extracted folder:
```powershell
.\jellyframe.exe --rendering-method gl_compatibility
.\jellyxr.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
chmod +x jellyxr-linux-x86_64.AppImage
./jellyxr-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 `jellyxr-linux-arm64.AppImage`. On SteamOS, add the AppImage to Steam as a non-Steam game.
To select OpenGL manually:
If jellyXR cannot start with Vulkan, try OpenGL:
```sh
./jellyframe-linux-x86_64.AppImage --rendering-method gl_compatibility
./jellyxr-linux-x86_64.AppImage --rendering-method gl_compatibility
```
If mounting an AppImage fails because FUSE is unavailable, run it with `--appimage-extract-and-run`.
The builds target Ubuntu 24.04. AppImages 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 jellyXR:
```sh
sudo apt-get install libmpv2
tar -xzf jellyframe-linux-x86_64-system-libs.tar.gz
./jellyframe.x86_64
tar -xzf jellyxr-linux-x86_64-system-libs.tar.gz
./jellyxr.x86_64
```
Keep the executable and the `.so` file together. For ARM64, use `jellyframe-linux-arm64-system-libs.tar.gz` and launch `./jellyframe.arm64`.
Keep the executable and the `.so` file together. For ARM64, use `jellyxr-linux-arm64-system-libs.tar.gz` and launch `./jellyxr.arm64`.
Sign in and play a title using the same VR controls as Windows; desktop preview with `--xr-mode off` does not play video.
## After installation
Follow the [usage guide](USAGE.md) to sign in and play a video. See the [current limitations](README.md#current-limitations) for platform and headset compatibility.
+3 -3
View File
@@ -2,11 +2,11 @@
Copyright © 2026 Cuteminded.
Jellyframe's source code is licensed under the [GNU General Public License v3.0 only](LICENSE) (`GPL-3.0-only`).
jellyXR's source code is licensed under the [GNU General Public License v3.0 only](LICENSE) (`GPL-3.0-only`).
## Third-party components
Jellyframe uses third-party software and assets under their own licenses:
jellyXR uses third-party software and assets under their own licenses:
- Godot and Android components retain their original licenses and copyright notices.
- Icons in the player and library come from [Google's Material Symbols Rounded](https://github.com/google/material-design-icons).
@@ -14,7 +14,7 @@ Jellyframe uses third-party software and assets under their own licenses:
## Media artwork
Jellyframe retrieves movie posters, backdrops, and other media artwork from the user's Jellyfin server. It does not include these assets in its source code or distribute them with the app.
jellyXR retrieves movie posters, backdrops, and other media artwork from the user's Jellyfin server. It does not include these assets in its source code or distribute them with the app.
# PC playback dependencies
+22 -26
View File
@@ -1,47 +1,43 @@
<img src="assets/logo/jellyframe-wordmark.png" alt="Jellyframe" style="width:400px;"/>
<img src="assets/logo/jellyxr-wordmark.png" alt="jellyXR" style="width:400px;"/>
Watch your Jellyfin movies and shows on a virtual screen in VR.
Jellyframe is a free, open source app 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.
jellyXR is a free, open source app for browsing your Jellyfin library and watching videos in VR. Choose a title and position the screen where you want it. Your Jellyfin server supplies the movies and shows.
## From JellyQuest to Jellyframe
## Hardware status
This project started as JellyQuest, a Jellyfin VR app for the Quest 3. I renamed it Jellyframe because I plan to switch to Steam Frame.
| Device | Status | Build | Notes |
|---|---|---|---|
| [Quest 1](https://www.meta.com/quest/) | Pending | Android | Installed but not fully tested |
| [Quest 3](https://www.meta.com/quest/) | Working | Android | |
| [Xreal aura](https://www.xreal.com/aura) | Waiting for device | Android | Not yet released in Europe |
| [Steam Frame](https://store.steampowered.com/hardware/steamframe) | Waiting for device | Linux ARM64 | On reservation queue |
| [SteamVR](https://store.steampowered.com/steamvr) | Partially Working | Linux X64 | Needs further testing with Vulkan |
| [SteamVR](https://store.steampowered.com/steamvr) | Working | Windows | |
Steam Frame is now the main target for future development.
> **Note:** If you have a device that isn't listed here, or can test jellyXR on any of the listed devices, we'd love to hear from you!
>
> Please [open an issue](https://github.com/Cuteminded/jellyframe/issues) to share your results and help us keep the hardware compatibility list accurate and up to date.
> 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.
## What has been tested
## From JellyQuest to jellyXR
- 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.
- Automatic episode playback in series order.
- HDR, 1080p, 4K, and 8K video playback.
This project started as **JellyQuest**, a Jellyfin VR app for the Quest. I renamed it **jellyXR** because I want the app to work across multiple VR and AR devices, without tying its name to a single headset or platform.
## Current limitations
The goal is to support standalone headsets and devices connected to a PC. Support depends on each device's capabilities and testing. The hardware status table above shows what works today and what still needs testing.
- 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 jellyXR is ready for a wider release, then yes. I'd be happy to put in the work to make it available on Steam or the Meta Store.
## Documentation
- [Install and start](INSTALLATION.md)
- [Using Jellyframe](USAGE.md)
- [Using jellyXR](USAGE.md)
- [Build from source, tests, and code layout](DEVELOPMENT.md)
- [Contributing](CONTRIBUTING.md)
- [Licence and attribution](LICENSING.md)
## Licence
Jellyframe uses GPL-3.0-only; see [Licence and attribution](LICENSING.md) for licence terms and third-party credits.
jellyXR uses GPL-3.0-only; see [Licence and attribution](LICENSING.md) for licence terms and third-party credits.
+113
View File
@@ -0,0 +1,113 @@
# Jellyfin feature checklist
This checklist compares jellyXR with Jellyfin's user-facing features.
Implementation does not mean a feature has been tested on every headset.
- Checked items are implemented in the current source.
- Unchecked items are missing or incomplete.
- Hardware validation is tracked separately below.
## Sign-in and accounts
- [x] Connect to a custom Jellyfin server address.
- [x] Sign in with a username and password.
- [x] Sign in with Quick Connect after approval on another device.
- [x] Restore a saved session without storing the password.
- [x] Sign out and clear the saved session.
- [ ] Save multiple servers and switch between them.
- [ ] Switch between saved user accounts without signing out.
- [ ] Discover Jellyfin servers on the local network.
- [ ] Manage account settings and audio/subtitle language preferences.
## Libraries and discovery
- [x] Browse movies, series, and video items with posters and backdrops.
- [x] Select individual server libraries from the sidebar.
- [x] Show recently added titles on Home.
- [x] Show a Continue watching row on Home.
- [x] Search titles globally or within the selected library.
- [x] Load more titles while scrolling.
- [x] Sort libraries by name, rating, date, play count, or runtime, with ascending and descending order.
- [x] Browse titles in random order.
- [x] Browse series by season and episode.
- [x] Display watched indicators and saved playback progress supplied by Jellyfin.
- [X] Show a Next up row for series. Automatic playback of the next episode already exists.
- [ ] Filter by watched status, genre, year, or other metadata.
- [ ] Add, remove, and browse favourites.
- [ ] Browse collections.
- [ ] Browse, create, and play playlists or a user-managed playback queue.
- [ ] Show recommendations and similar titles.
- [ ] Open actor or crew pages and browse their other titles.
## Title details and watch history
- [x] Show descriptions, release years, runtimes, ratings, and genres.
- [x] Show cast and crew portraits, names, and roles.
- [x] Show studio and video/audio stream information when available.
- [x] Resume a movie or episode from its saved position.
- [x] Start a partially watched title from the beginning.
- [x] Report playback start, progress, pause state, and stop events to Jellyfin.
- [x] Manually mark a movie or episode as watched or unwatched.
- [x] Manually mark a season as watched or unwatched.
- [x] Manually mark a series as watched or unwatched.
- [x] Select between multiple media versions
- [ ] Browse and play trailers and extras.
## Video playback
- [x] Play supported original video streams through Direct Play.
- [x] Request server transcoding and play HLS streams.
- [x] Retry with transcoding after a playback failure in Automatic mode.
- [x] Choose Automatic, Original full resolution, or Transcoded playback.
- [x] Choose 720p, 1080p, or 4K transcoding limits before starting playback.
- [x] Pause, resume, stop, seek on the timeline, and skip backward or forward.
- [x] Show loading and buffering feedback.
- [x] Browse the library while the current video continues playing.
- [x] Play the next available episode automatically after a cancellable countdown.
- [x] Manually skip intros, credits, recaps, and previews when the server supplies media segments. Requires the Jellyfin 10.10+ API and a segment provider such as Intro Skipper.
- [x] Enable Direct Stream/remuxing in Automatic playback.
- [x] Select audio tracks and languages through server streaming.
- [x] Select embedded or external text subtitles, or use server burn-in for picture subtitles.
- [x] Adjust text subtitle size, color, vertical position, and timing.
- [ ] Browse chapters and jump to a selected chapter.
- [ ] Change playback speed.
- [ ] Change playback quality during an active session.
- [ ] Automatically hide playback controls after inactivity.
- [ ] Download media for offline playback.
## Other Jellyfin features
These extend beyond jellyXR's current movie and series focus.
- [x] View photos.
- [x] Show loading feedback when opening photos from the library and switching photos.
- [ ] View slideshows.
- [ ] Read books and comics.
- [ ] Watch live TV and browse the programme guide.
- [ ] Join or host SyncPlay sessions.
- [ ] Receive remote playback commands from other Jellyfin clients.
## jellyXR VR features
These are jellyXR features rather than Jellyfin feature gaps.
- [x] Browse and control playback with VR controllers and an on-screen keyboard.
- [x] Adjust screen width, distance, height, and curvature in supported environments.
- [x] Choose Void, Cinema, Theatre, or supported-device Passthrough environments.
- [x] Select 2D, half-SBS, full-SBS, and top-bottom layouts, with an eye-swap option.
- [x] Select flat-screen, 180-degree, or 360-degree projection, with automatic detection for supported metadata and filenames.
- [x] Save screen, environment, library sorting, and playback quality settings.
- [ ] Implement native HDR output and display calibration.
## Hardware validation
- [ ] Verify installation and playback on Steam Frame.
- [ ] Verify installation and playback on Android XR.
- [x] Confirm Quest 3 HLS seeking and resume after the timeline fix.
- [x] Verify top-bottom stereo and eye swapping on Quest 3.
- [x] Verify stereoscopic 180-degree projection and recentering on Quest 3.
- [x] Verify Quest 3 HDR content playback.
- [x] Verify PC OpenGL HDR playback in a headset.
- [x] Verify photo loading feedback on Quest 3, both from the library and between photos.
- [ ] Verify ARM64 and other Linux configurations on headsets.
- [ ] Verify the new native Vulkan decoder, HDR conversion, and 4K/8K playback on Linux with a headset.
+77 -24
View File
@@ -1,28 +1,28 @@
# Using Jellyframe
# Using jellyXR
1. Enter the address you normally use to open Jellyfin, such as `https://jellyfin.example.com`. If someone else runs the server, ask them for the address.
2. Enter your Jellyfin username and password.
3. Select **Sign in**.
To use **Quick Connect**, select it and note the code Jellyframe displays. Open Jellyfin on your phone or computer, sign in, and approve that code using Quick Connect.
To use **Quick Connect**, select it and note the code jellyXR displays. Open Jellyfin on your phone or computer, sign in, and approve that code using Quick Connect.
Jellyframe remembers your sign-in without saving your password. Select **Sign out** before switching accounts or servers. You can use one server and account at a time.
jellyXR remembers your sign-in without saving your password. Select **Sign out** before switching accounts or servers. You can use one server and account at a time.
## Reset your saved sign-in on PC
If you cannot sign out through the app, remove the saved sign-in file:
1. Close Jellyframe completely.
2. Find the file for your computer in the table below.
1. Close jellyXR completely.
2. Open the folder listed below. On Windows, paste `%APPDATA%\jellyXR` into the address bar in File Explorer and press Enter.
3. Delete `session.cfg`.
4. Open Jellyframe again and sign in.
4. Open jellyXR again and sign in.
| Computer | Saved sign-in file |
| Computer | Folder |
| --- | --- |
| Windows | `%APPDATA%\Jellyframe\session.cfg` |
| Linux | `~/.local/share/Jellyframe/session.cfg` |
| Windows | `%APPDATA%\jellyXR` |
| Linux | `~/.local/share/jellyXR` |
To reset the saved app settings as well, delete `settings.cfg` from the same folder while Jellyframe is closed. You will need to enter your server address and choose your settings again. This does not delete movies, shows, or your Jellyfin account.
To reset the saved app settings as well, delete `settings.cfg` from the same folder while jellyXR is closed. You will need to enter your server address and choose your settings again. This does not delete movies, shows, or your Jellyfin account.
## Choose something to watch
@@ -31,11 +31,21 @@ 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.
Play and Resume use the first available version. If a movie or episode has several versions, select **Choose version** to pick the one you want to watch.
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 +55,55 @@ 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 | Choose a language, turn subtitles on or off, and adjust how they look. |
| Hide controls | Remove the controls from your view. Press A to show them again. |
| Statistics for nerds | Use the chart button at the far right to inspect playback details and live statistics. Select it again to dismiss the panel. |
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
If Jellyfin knows where an intro or the credits begin, a **Skip intro** or **Skip credits** button appears near the bottom-right of the video. Point at it and press the trigger to skip. You can use it without opening the playback controls. Some videos also have buttons to skip a recap or preview.
This needs setup in Jellyfin, for example with [Intro Skipper](https://github.com/intro-skipper/intro-skipper). If someone else manages your Jellyfin server, ask them to enable it. Skip buttons appear only after Jellyfin has checked the video and found the parts it can skip.
## Audio and subtitles
Press **A** to open the playback controls, then select **Audio and subtitles**.
### Choose a language
Under **Audio**, select the language or version you want to hear. The choices depend on the video. Some videos also include commentary.
The video briefly reloads when you change the audio, then continues from the same point. If you paused it first, it stays paused.
If the choices are unavailable, select **Use Automatic for this video**. This lets Jellyfin prepare the video for your chosen audio or subtitles. It does not change your saved playback preference.
Select **Back to video** to continue watching, or press **B** to open your library.
### Turn subtitles on or off
Under **Subtitles**, choose one of the available options. Choose **Off** to hide them.
Subtitles may take a little while to load. If loading fails, select the same option again to retry.
### Make subtitles easier to read
Under **Text subtitle appearance**, you can:
- Change **Text size** to make the letters larger or smaller.
- Change **Vertical position** to move the subtitles up or down.
- Choose white, yellow, or green text.
- Change **Delay** if the words appear at the wrong time.
If the words appear too early, increase Delay with **+**. If they appear too late, decrease it with **-**. Select **Reset timing** to undo your timing changes.
The app remembers your size, position, and color choices. Timing changes apply only to the current video. Subtitles move above the playback controls while those controls are visible, then return to your chosen position when you hide them.
Some subtitles are saved as pictures. Choosing these briefly reloads the video, and you cannot change their size, color, position, or timing. The app tells you when those adjustments do not apply. Other subtitles may look simpler than they do in another player, without their original fonts or placement.
## Make the screen comfortable
Open **Settings**, then choose **Screen**. If your chosen background allows screen adjustments, you can change:
@@ -82,7 +134,7 @@ If your device does not support Passthrough, you cannot select it. In Cinema and
## Watch a 3D video
Jellyframe usually chooses the right picture layout automatically. If you see two pictures side by side or one above the other, open **Settings > Screen** and look for **Video format**.
jellyXR usually chooses the right picture layout automatically. If you see two pictures side by side or one above the other, open **Settings > Screen** and look for **Video format**.
| Option | When to use it |
| --- | --- |
@@ -104,23 +156,23 @@ 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.
Jellyframe can recognize some 180° and 360° videos automatically, but you can change the setting if it chooses incorrectly. A new title uses automatic detection again. Some panoramic video formats are not supported, so changing this setting cannot fix every distorted video.
jellyXR can recognize some 180° and 360° videos automatically, but you can change the setting if it chooses incorrectly. A new title uses automatic detection again. Some videos use a picture layout the app cannot show correctly. Changing this setting will not fix those videos.
## Change playback quality
Open **Settings > Playback**. The choice applies to the next video you start.
- **Automatic** plays the original video when possible and asks Jellyfin to convert it when needed. Start with this option if you are unsure.
- **Automatic** lets Jellyfin choose how to play the video on your device. Start with this option if you are unsure.
- **Original full resolution** keeps the original video quality. Your headset or PC must be able to play the file.
- **Transcoded** asks your Jellyfin server to convert the video. Choose **720p**, **1080p**, or **4K** under **Transcoded resolution**. This can help with files that will not play smoothly at their original quality.
- **Transcoded** asks Jellyfin to prepare a version that is easier for your device to play. Choose **720p**, **1080p**, or **4K** under **Transcoded resolution**. This can help with files that will not play smoothly at their original quality.
1080p is the default for converted videos. Choose 720p to use less data, or 4K for more detail when the source video supports it. These are maximum sizes; smaller videos are not enlarged. Higher resolutions need a faster connection and more processing power from your Jellyfin server. Your choice is saved and applies to the next video you start.
Start with **1080p**. Try **720p** if playback keeps stopping to load, or **4K** for a sharper picture if the video was recorded at that quality. Choosing 4K cannot add detail to a lower-quality video. Higher quality needs a faster connection and a computer running Jellyfin that can keep up. The app remembers your choice for the next video you start.
Picture quality depends on the video, your device, your connection, and what your Jellyfin server can handle. To use the Windows setup tested with HDR, 4K, and 8K videos, follow the [Windows launch instructions](INSTALLATION.md#try-it-on-windows-with-steamvr).
Picture quality also depends on the video itself and your headset. For help getting started on a PC, see the [Windows launch instructions](INSTALLATION.md#try-it-on-windows-with-steamvr).
## Change the order of your library
@@ -130,11 +182,12 @@ 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.
- If the video pauses with a loading message, it is waiting for more video data. Give it a moment. If this keeps happening, try Automatic or Transcoded under Settings > Playback, then start the video again.
- Audio-language and subtitle selection are not available yet.
- If subtitles appear too early or too late, adjust Delay under Audio and subtitles.
- If audio or subtitle choices are unavailable, select Use Automatic for this video on that page.
For setup problems, see [Install and start Jellyframe](INSTALLATION.md). The [README](README.md#current-limitations) lists the remaining limitations.
For setup problems, see [Install and start jellyXR](INSTALLATION.md). The [README](README.md#current-limitations) lists the remaining limitations.
@@ -2,13 +2,13 @@
extends EditorExportPlugin
func _get_name() -> String:
return "JellyframeVideo"
return "JellyXRVideo"
func _supports_platform(platform: EditorExportPlatform) -> bool:
return platform is EditorExportPlatformAndroid
func _get_android_libraries(_platform: EditorExportPlatform, _debug: bool) -> PackedStringArray:
return PackedStringArray(["res://addons/jellyframe_video/bin/jellyframe-video-release.aar"])
return PackedStringArray(["res://addons/jellyxr_video/bin/jellyxr-video-release.aar"])
func _get_android_manifest_activity_element_contents(_platform: EditorExportPlatform, _debug: bool) -> String:
return '<intent-filter><action android:name="android.intent.action.MAIN"/><category android:name="com.oculus.intent.category.VR"/></intent-filter>'
File renamed without changes.
@@ -1,5 +1,5 @@
[plugin]
name="Jellyframe Android video"
name="jellyXR Android video"
description="Android video decoding and secure session storage."
author="Cuteminded"
version="0.1.0"
@@ -4,7 +4,7 @@ extends EditorPlugin
var exporter: EditorExportPlugin
func _enter_tree() -> void:
exporter = preload("res://addons/jellyframe_video/export.gd").new()
exporter = preload("res://addons/jellyxr_video/export.gd").new()
add_export_plugin(exporter)
func _exit_tree() -> void:
File renamed without changes.
+1
View File
@@ -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
+1
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24"><path fill="#ffffff" d="M4 19h4V9H4v10zm6 0h4V5h-4v14zm6 0h4v-7h-4v7z"/></svg>

After

Width:  |  Height:  |  Size: 162 B

@@ -0,0 +1,43 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cte3e27ekhfhy"
path="res://.godot/imported/statistics.svg-cb4795c754d515713a37969b809a039d.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/icons/material/statistics.svg"
dest_files=["res://.godot/imported/statistics.svg-cb4795c754d515713a37969b809a039d.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false
+1
View File
@@ -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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 360 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 351 KiB

@@ -2,16 +2,16 @@
importer="texture"
type="CompressedTexture2D"
uid="uid://dmce1ivmbk2fp"
path="res://.godot/imported/banner.jpg-6d02bc55ca4bbeee115ce9d14947211e.ctex"
uid="uid://dxevvv5ord8pe"
path="res://.godot/imported/banner.png-bd23af29f76d71fc9bfdb42c682b72e4.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/logo/banner.jpg"
dest_files=["res://.godot/imported/banner.jpg-6d02bc55ca4bbeee115ce9d14947211e.ctex"]
source_file="res://assets/logo/banner.png"
dest_files=["res://.godot/imported/banner.png-bd23af29f76d71fc9bfdb42c682b72e4.ctex"]
[params]
Binary file not shown.

Before

Width:  |  Height:  |  Size: 775 KiB

@@ -3,15 +3,15 @@
importer="texture"
type="CompressedTexture2D"
uid="uid://ck4x8pltfn58j"
path="res://.godot/imported/jellyframe-logo-transparent.png-0ef10f99e02b93d9ea3384102e1c4911.ctex"
path="res://.godot/imported/jellyxr-logo-transparent.png-2dfee7dc41b0d39b9daa681e6e7e02f7.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/logo/jellyframe-logo-transparent.png"
dest_files=["res://.godot/imported/jellyframe-logo-transparent.png-0ef10f99e02b93d9ea3384102e1c4911.ctex"]
source_file="res://assets/logo/jellyxr-logo-transparent.png"
dest_files=["res://.godot/imported/jellyxr-logo-transparent.png-2dfee7dc41b0d39b9daa681e6e7e02f7.ctex"]
[params]
File renamed without changes.
@@ -3,15 +3,15 @@
importer="texture"
type="CompressedTexture2D"
uid="uid://hm54bsfvy7o4"
path="res://.godot/imported/jellyframe-logo.png-c3465f8a3b44d0216b2085b1f0539f96.ctex"
path="res://.godot/imported/jellyxr-logo.png-80e79f5d43d1747dd0f89e3817de72a9.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/logo/jellyframe-logo.png"
dest_files=["res://.godot/imported/jellyframe-logo.png-c3465f8a3b44d0216b2085b1f0539f96.ctex"]
source_file="res://assets/logo/jellyxr-logo.png"
dest_files=["res://.godot/imported/jellyxr-logo.png-80e79f5d43d1747dd0f89e3817de72a9.ctex"]
[params]
Binary file not shown.

After

Width:  |  Height:  |  Size: 937 KiB

@@ -3,15 +3,15 @@
importer="texture"
type="CompressedTexture2D"
uid="uid://b6yvltew548ey"
path="res://.godot/imported/jellyframe-wordmark.png-8b4f6ce94c0d01410ac5982a545ac1fd.ctex"
path="res://.godot/imported/jellyxr-wordmark.png-d0e48d005fe6a57844383d26386949aa.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/logo/jellyframe-wordmark.png"
dest_files=["res://.godot/imported/jellyframe-wordmark.png-8b4f6ce94c0d01410ac5982a545ac1fd.ctex"]
source_file="res://assets/logo/jellyxr-wordmark.png"
dest_files=["res://.godot/imported/jellyxr-wordmark.png-d0e48d005fe6a57844383d26386949aa.ctex"]
[params]
Binary file not shown.

Before

Width:  |  Height:  |  Size: 355 KiB

@@ -1,40 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://crg1x2ohwbgbs"
path="res://.godot/imported/repository-open-graph-template.jpg-073b42b68ea80d7e5f867a4b731e7acc.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/logo/repository-open-graph-template.jpg"
dest_files=["res://.godot/imported/repository-open-graph-template.jpg-073b42b68ea80d7e5f867a4b731e7acc.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
+12 -12
View File
@@ -7,8 +7,8 @@ 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"
export_path="dist/jellyframe-android.apk"
exclude_filter="assets/cinema.glb,assets/theatre/materials.tres,native/*,android/*,docs/*,tests/*,tools/*,dist/*,build/*,addons/jellyxr_mpv/*"
export_path="dist/jellyxr-android.apk"
script_export_mode=2
[preset.0.options]
@@ -24,10 +24,10 @@ architectures/armeabi-v7a=false
architectures/arm64-v8a=true
architectures/x86=false
architectures/x86_64=false
version/code=2
version/name="0.1.1"
package/unique_name="nu.lizzy.jellyframe"
package/name="Jellyframe"
version/code=4
version/name="0.1.3"
package/unique_name="nu.lizzy.jellyxr"
package/name="jellyXR"
package/signed=true
package/retain_data_on_uninstall=false
package/exclude_from_recents=false
@@ -54,8 +54,8 @@ dedicated_server=false
custom_features=""
export_filter="all_resources"
include_filter=""
exclude_filter="assets/cinema.glb,assets/theatre/materials.tres,native/*,android/*,docs/*,tests/*,tools/*,dist/*,build/*,addons/jellyframe_video/bin/*"
export_path="dist/windows/jellyframe.exe"
exclude_filter="assets/cinema.glb,assets/theatre/materials.tres,native/*,android/*,docs/*,tests/*,tools/*,dist/*,build/*,addons/jellyxr_video/bin/*"
export_path="dist/windows/jellyxr.exe"
script_export_mode=2
[preset.1.options]
@@ -77,8 +77,8 @@ dedicated_server=false
custom_features=""
export_filter="all_resources"
include_filter=""
exclude_filter="assets/cinema.glb,assets/theatre/materials.tres,native/*,android/*,docs/*,tests/*,tools/*,dist/*,build/*,addons/jellyframe_video/bin/*"
export_path="dist/linux-x86_64/jellyframe.x86_64"
exclude_filter="assets/cinema.glb,assets/theatre/materials.tres,native/*,android/*,docs/*,tests/*,tools/*,dist/*,build/*,addons/jellyxr_video/bin/*"
export_path="dist/linux-x86_64/jellyxr.x86_64"
script_export_mode=2
[preset.2.options]
@@ -98,8 +98,8 @@ dedicated_server=false
custom_features=""
export_filter="all_resources"
include_filter=""
exclude_filter="assets/cinema.glb,assets/theatre/materials.tres,native/*,android/*,docs/*,tests/*,tools/*,dist/*,build/*,addons/jellyframe_video/bin/*"
export_path="dist/linux-arm64/jellyframe.arm64"
exclude_filter="assets/cinema.glb,assets/theatre/materials.tres,native/*,android/*,docs/*,tests/*,tools/*,dist/*,build/*,addons/jellyxr_video/bin/*"
export_path="dist/linux-arm64/jellyxr.arm64"
script_export_mode=2
[preset.3.options]
+3 -3
View File
@@ -1,7 +1,7 @@
plugins { id 'com.android.library' }
android {
namespace 'nu.lizzy.jellyframe.video'
namespace 'nu.lizzy.jellyxr.video'
compileSdk 35
defaultConfig { minSdk 29 }
compileOptions {
@@ -17,6 +17,6 @@ dependencies {
tasks.register('stagePlugin', Copy) {
dependsOn 'assembleRelease'
from layout.buildDirectory.file('outputs/aar/android-release.aar')
into '../../addons/jellyframe_video/bin'
rename { 'jellyframe-video-release.aar' }
into '../../addons/jellyxr_video/bin'
rename { 'jellyxr-video-release.aar' }
}
+2 -2
View File
@@ -2,8 +2,8 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-feature android:name="android.hardware.vr.headtracking" android:version="1" android:required="true" />
<application android:usesCleartextTraffic="true" android:allowBackup="false">
<meta-data android:name="org.godotengine.plugin.v2.JellyframeVideo"
android:value="nu.lizzy.jellyframe.video.JellyframeVideo" />
<meta-data android:name="org.godotengine.plugin.v2.JellyXRVideo"
android:value="nu.lizzy.jellyxr.video.JellyXRVideo" />
<meta-data android:name="com.oculus.supportedDevices" android:value="quest3|questpro|quest2" />
</application>
</manifest>
@@ -1,4 +1,4 @@
package nu.lizzy.jellyframe.video;
package nu.lizzy.jellyxr.video;
import android.graphics.SurfaceTexture;
import android.media.MediaPlayer;
@@ -20,7 +20,7 @@ import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import javax.microedition.khronos.opengles.GL10;
public final class JellyframeVideo extends GodotPlugin {
public final class JellyXRVideo extends GodotPlugin {
private final Handler main = new Handler(Looper.getMainLooper());
private final AtomicBoolean frameAvailable = new AtomicBoolean();
private final float[] transform = new float[16];
@@ -32,6 +32,7 @@ public final class JellyframeVideo extends GodotPlugin {
private MediaPlayer player;
private volatile int position;
private volatile int duration;
private volatile int bufferedPercent = -1;
private volatile boolean prepared;
private volatile boolean buffering;
private volatile boolean seeking;
@@ -39,12 +40,12 @@ public final class JellyframeVideo extends GodotPlugin {
private volatile int readyGeneration = -1;
private int textureGeneration = -1;
public JellyframeVideo(Godot godot) {
public JellyXRVideo(Godot godot) {
super(godot);
Matrix.setIdentityM(transform, 0);
}
@Override public String getPluginName() { return "JellyframeVideo"; }
@Override public String getPluginName() { return "JellyXRVideo"; }
@Override public Set<SignalInfo> getPluginSignals() {
Set<SignalInfo> result = new HashSet<>();
@@ -104,6 +105,9 @@ public final class JellyframeVideo extends GodotPlugin {
player.setOnVideoSizeChangedListener((current, width, height) -> {
if (isCurrent(current, opening)) emitSignal("video_size", width, height);
});
player.setOnBufferingUpdateListener((current, percent) -> {
if (isCurrent(current, opening)) bufferedPercent = Math.max(0, Math.min(100, percent));
});
player.setOnInfoListener((current, what, extra) -> {
if (!isCurrent(current, opening)) return true;
if (what == MediaPlayer.MEDIA_INFO_BUFFERING_START) {
@@ -112,7 +116,7 @@ public final class JellyframeVideo extends GodotPlugin {
if (what == MediaPlayer.MEDIA_INFO_BUFFERING_END) {
buffering = false;
}
if (what == MediaPlayer.MEDIA_INFO_VIDEO_TRACK_LAGGING) Log.w("JellyframeVideo", "Video decoder is falling behind");
if (what == MediaPlayer.MEDIA_INFO_VIDEO_TRACK_LAGGING) Log.w("JellyXRVideo", "Video decoder is falling behind");
return false;
});
player.setOnSeekCompleteListener(current -> {
@@ -153,7 +157,7 @@ public final class JellyframeVideo extends GodotPlugin {
position = player.getCurrentPosition();
if (watchdog.stalled(SystemClock.elapsedRealtime(), lastFrame, buffering || seeking)) {
prepared = false;
Log.w("JellyframeVideo", "No video frames at " + position + " ms, buffering=" + buffering + ", seeking=" + seeking);
Log.w("JellyXRVideo", "No video frames at " + position + " ms, buffering=" + buffering + ", seeking=" + seeking);
emitSignal("failed", "Video playback stalled.");
return;
}
@@ -171,18 +175,13 @@ public final class JellyframeVideo extends GodotPlugin {
@UsedByGodot public int position_ms() { return position; }
@UsedByGodot public int duration_ms() { return duration; }
@UsedByGodot public int buffered_position_ms() {
return prepared && !seeking && bufferedPercent >= 0 && duration > 0
? (int) ((long) duration * bufferedPercent / 100) : -1;
}
@UsedByGodot public boolean has_video_frame() { return readyGeneration >= 0 && readyGeneration == generation.get(); }
@UsedByGodot public boolean is_buffering() { return prepared && (buffering || seeking); }
@UsedByGodot public String calibration_url() {
int resource = getContext().getResources().getIdentifier("stereo_test", "raw", getContext().getPackageName());
return "android.resource://" + getContext().getPackageName() + "/" + resource;
}
@UsedByGodot public boolean calibration_requested() {
return getActivity() != null && getActivity().getIntent().getBooleanExtra("jellyframe_calibration", false);
}
@UsedByGodot public void set_paused(boolean paused) {
main.post(() -> {
if (player == null || !prepared) return;
@@ -195,6 +194,7 @@ public final class JellyframeVideo extends GodotPlugin {
main.post(() -> {
if (player != null && prepared) {
seeking = true;
bufferedPercent = -1;
watchdog.setPaused(!player.isPlaying(), SystemClock.elapsedRealtime());
player.seekTo(Math.max(0, Math.min(milliseconds, player.getDuration())), MediaPlayer.SEEK_CLOSEST);
}
@@ -213,6 +213,7 @@ public final class JellyframeVideo extends GodotPlugin {
seeking = false;
position = 0;
duration = 0;
bufferedPercent = -1;
if (player != null) { player.release(); player = null; }
}
@@ -1,4 +1,4 @@
package nu.lizzy.jellyframe.video;
package nu.lizzy.jellyxr.video;
final class PlaybackWatchdog {
private static final long FRAME_TIMEOUT_MS = 20000;
@@ -1,4 +1,4 @@
package nu.lizzy.jellyframe.video;
package nu.lizzy.jellyxr.video;
import android.content.Context;
import android.security.keystore.KeyGenParameterSpec;
@@ -12,7 +12,7 @@ import javax.crypto.spec.GCMParameterSpec;
import java.nio.charset.StandardCharsets;
final class SessionStore {
private static final String ALIAS = "jellyframe-session";
private static final String ALIAS = "jellyxr-session";
private final Context context;
SessionStore(Context context) { this.context = context; }
+17 -17
View File
@@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.20)
project(jellyframe_mpv LANGUAGES CXX)
project(jellyxr_mpv LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
@@ -21,11 +21,11 @@ if(WIN32)
endif()
find_path(MPV_INCLUDE_DIR mpv/client.h PATHS "${MPV_ROOT}/include" REQUIRED NO_DEFAULT_PATH)
find_library(MPV_LIBRARY NAMES mpv mpv.dll libmpv.dll PATHS "${MPV_ROOT}" "${MPV_ROOT}/lib" REQUIRED NO_DEFAULT_PATH)
add_library(jellyframe_mpv_dependency UNKNOWN IMPORTED)
set_target_properties(jellyframe_mpv_dependency PROPERTIES
add_library(jellyxr_mpv_dependency UNKNOWN IMPORTED)
set_target_properties(jellyxr_mpv_dependency PROPERTIES
IMPORTED_LOCATION "${MPV_LIBRARY}"
INTERFACE_INCLUDE_DIRECTORIES "${MPV_INCLUDE_DIR}")
set(MPV_TARGET jellyframe_mpv_dependency)
set(MPV_TARGET jellyxr_mpv_dependency)
find_path(FFMPEG_INCLUDE_DIR libavcodec/avcodec.h PATHS "${FFMPEG_ROOT}/include" REQUIRED NO_DEFAULT_PATH)
foreach(component avcodec avformat avutil swscale)
find_library(FFMPEG_${component}_LIBRARY NAMES ${component} PATHS "${FFMPEG_ROOT}/lib" REQUIRED NO_DEFAULT_PATH)
@@ -42,29 +42,29 @@ else()
endif()
find_package(Threads REQUIRED)
add_library(jellyframe_mpv SHARED
add_library(jellyxr_mpv SHARED
mpv_video.cpp render_worker.cpp render_gl.cpp render_gl_worker.cpp
gl_functions.cpp register_types.cpp render_vulkan.cpp
video_decoder.cpp video_decoder_frames.cpp video_decoder_packets.cpp video_decoder_planes.cpp video_plane.cpp video_sync.cpp)
target_link_libraries(jellyframe_mpv PRIVATE godot-cpp ${MPV_TARGET} ${FFMPEG_LIBRARIES} Threads::Threads)
target_link_libraries(jellyxr_mpv PRIVATE godot-cpp ${MPV_TARGET} ${FFMPEG_LIBRARIES} Threads::Threads)
if(WIN32)
target_include_directories(jellyframe_mpv PRIVATE "${FFMPEG_INCLUDE_DIR}")
target_include_directories(jellyxr_mpv PRIVATE "${FFMPEG_INCLUDE_DIR}")
endif()
if(WIN32)
target_sources(jellyframe_mpv PRIVATE shared_gl_context_windows.cpp)
target_link_libraries(jellyframe_mpv PRIVATE opengl32)
target_sources(jellyxr_mpv PRIVATE shared_gl_context_windows.cpp)
target_link_libraries(jellyxr_mpv PRIVATE opengl32)
else()
target_sources(jellyframe_mpv PRIVATE shared_gl_context_linux.cpp)
target_link_libraries(jellyframe_mpv PRIVATE ${CMAKE_DL_LIBS})
target_sources(jellyxr_mpv PRIVATE shared_gl_context_linux.cpp)
target_link_libraries(jellyxr_mpv PRIVATE ${CMAKE_DL_LIBS})
endif()
if(MINGW)
target_link_options(jellyframe_mpv PRIVATE -static -static-libgcc -static-libstdc++)
target_link_options(jellyxr_mpv PRIVATE -static -static-libgcc -static-libstdc++)
endif()
set_target_properties(jellyframe_mpv PROPERTIES PREFIX "" OUTPUT_NAME "jellyframe_mpv")
set_target_properties(jellyxr_mpv PROPERTIES PREFIX "" OUTPUT_NAME "jellyxr_mpv")
if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
get_target_property(JELLYFRAME_ARCH godot-cpp GODOTCPP_ARCH)
if(NOT JELLYFRAME_ARCH MATCHES "^(x86_64|arm64)$")
message(FATAL_ERROR "Unsupported Linux architecture: ${JELLYFRAME_ARCH}")
get_target_property(JELLYXR_ARCH godot-cpp GODOTCPP_ARCH)
if(NOT JELLYXR_ARCH MATCHES "^(x86_64|arm64)$")
message(FATAL_ERROR "Unsupported Linux architecture: ${JELLYXR_ARCH}")
endif()
set_target_properties(jellyframe_mpv PROPERTIES OUTPUT_NAME "jellyframe_mpv.linux.${JELLYFRAME_ARCH}")
set_target_properties(jellyxr_mpv PROPERTIES OUTPUT_NAME "jellyxr_mpv.linux.${JELLYXR_ARCH}")
endif()
+19 -19
View File
@@ -12,25 +12,25 @@ constexpr unsigned sync_complete = 0x9117, already_signaled = 0x911A, condition_
}
struct GlFunctions {
void (JELLYFRAME_GL_CALL *gen_textures)(int, unsigned *) = nullptr;
void (JELLYFRAME_GL_CALL *delete_textures)(int, const unsigned *) = nullptr;
void (JELLYFRAME_GL_CALL *bind_texture)(unsigned, unsigned) = nullptr;
void (JELLYFRAME_GL_CALL *texture_parameter)(unsigned, unsigned, int) = nullptr;
void (JELLYFRAME_GL_CALL *texture_image)(unsigned, int, int, int, int, int, unsigned, unsigned, const void *) = nullptr;
void (JELLYFRAME_GL_CALL *gen_framebuffers)(int, unsigned *) = nullptr;
void (JELLYFRAME_GL_CALL *delete_framebuffers)(int, const unsigned *) = nullptr;
void (JELLYFRAME_GL_CALL *bind_framebuffer)(unsigned, unsigned) = nullptr;
void (JELLYFRAME_GL_CALL *attach_texture)(unsigned, unsigned, unsigned, unsigned, int) = nullptr;
unsigned (JELLYFRAME_GL_CALL *check_framebuffer)(unsigned) = nullptr;
void (JELLYFRAME_GL_CALL *blit)(int, int, int, int, int, int, int, int, unsigned, unsigned) = nullptr;
void *(JELLYFRAME_GL_CALL *fence)(unsigned, unsigned) = nullptr;
unsigned (JELLYFRAME_GL_CALL *poll_fence)(void *, unsigned, unsigned long long) = nullptr;
void (JELLYFRAME_GL_CALL *delete_fence)(void *) = nullptr;
void (JELLYFRAME_GL_CALL *flush)() = nullptr;
void (JELLYFRAME_GL_CALL *get_integer)(unsigned, int *) = nullptr;
unsigned char (JELLYFRAME_GL_CALL *is_enabled)(unsigned) = nullptr;
void (JELLYFRAME_GL_CALL *enable)(unsigned) = nullptr;
void (JELLYFRAME_GL_CALL *disable)(unsigned) = nullptr;
void (JELLYXR_GL_CALL *gen_textures)(int, unsigned *) = nullptr;
void (JELLYXR_GL_CALL *delete_textures)(int, const unsigned *) = nullptr;
void (JELLYXR_GL_CALL *bind_texture)(unsigned, unsigned) = nullptr;
void (JELLYXR_GL_CALL *texture_parameter)(unsigned, unsigned, int) = nullptr;
void (JELLYXR_GL_CALL *texture_image)(unsigned, int, int, int, int, int, unsigned, unsigned, const void *) = nullptr;
void (JELLYXR_GL_CALL *gen_framebuffers)(int, unsigned *) = nullptr;
void (JELLYXR_GL_CALL *delete_framebuffers)(int, const unsigned *) = nullptr;
void (JELLYXR_GL_CALL *bind_framebuffer)(unsigned, unsigned) = nullptr;
void (JELLYXR_GL_CALL *attach_texture)(unsigned, unsigned, unsigned, unsigned, int) = nullptr;
unsigned (JELLYXR_GL_CALL *check_framebuffer)(unsigned) = nullptr;
void (JELLYXR_GL_CALL *blit)(int, int, int, int, int, int, int, int, unsigned, unsigned) = nullptr;
void *(JELLYXR_GL_CALL *fence)(unsigned, unsigned) = nullptr;
unsigned (JELLYXR_GL_CALL *poll_fence)(void *, unsigned, unsigned long long) = nullptr;
void (JELLYXR_GL_CALL *delete_fence)(void *) = nullptr;
void (JELLYXR_GL_CALL *flush)() = nullptr;
void (JELLYXR_GL_CALL *get_integer)(unsigned, int *) = nullptr;
unsigned char (JELLYXR_GL_CALL *is_enabled)(unsigned) = nullptr;
void (JELLYXR_GL_CALL *enable)(unsigned) = nullptr;
void (JELLYXR_GL_CALL *disable)(unsigned) = nullptr;
bool load(const SharedGlContext &context);
};
-12
View File
@@ -1,12 +0,0 @@
[configuration]
entry_symbol = "jellyframe_mpv_init"
compatibility_minimum = "4.5"
reloadable = false
[libraries]
windows.x86_64 = "res://addons/jellyframe_mpv/bin/jellyframe_mpv.dll"
linux.x86_64 = "res://addons/jellyframe_mpv/bin/jellyframe_mpv.linux.x86_64.so"
linux.arm64 = "res://addons/jellyframe_mpv/bin/jellyframe_mpv.linux.arm64.so"
[dependencies]
windows.x86_64 = { "res://addons/jellyframe_mpv/bin/libmpv-2.dll": "", "res://addons/jellyframe_mpv/bin/avcodec-62.dll": "", "res://addons/jellyframe_mpv/bin/avformat-62.dll": "", "res://addons/jellyframe_mpv/bin/avutil-60.dll": "", "res://addons/jellyframe_mpv/bin/swscale-9.dll": "", "res://addons/jellyframe_mpv/bin/swresample-6.dll": "" }
+12
View File
@@ -0,0 +1,12 @@
[configuration]
entry_symbol = "jellyxr_mpv_init"
compatibility_minimum = "4.5"
reloadable = false
[libraries]
windows.x86_64 = "res://addons/jellyxr_mpv/bin/jellyxr_mpv.dll"
linux.x86_64 = "res://addons/jellyxr_mpv/bin/jellyxr_mpv.linux.x86_64.so"
linux.arm64 = "res://addons/jellyxr_mpv/bin/jellyxr_mpv.linux.arm64.so"
[dependencies]
windows.x86_64 = { "res://addons/jellyxr_mpv/bin/libmpv-2.dll": "", "res://addons/jellyxr_mpv/bin/avcodec-62.dll": "", "res://addons/jellyxr_mpv/bin/avformat-62.dll": "", "res://addons/jellyxr_mpv/bin/avutil-60.dll": "", "res://addons/jellyxr_mpv/bin/swscale-9.dll": "", "res://addons/jellyxr_mpv/bin/swresample-6.dll": "" }
+60 -39
View File
@@ -1,4 +1,5 @@
#include "mpv_video.h"
#include "stream_identity.h"
#include <godot_cpp/classes/image.hpp>
#include <godot_cpp/classes/os.hpp>
#include <godot_cpp/classes/display_server.hpp>
@@ -24,16 +25,17 @@ static bool hardware_opengl_available() {
!adapter.contains("software rasterizer") && !adapter.contains("swiftshader");
}
void JellyframeMpv::_bind_methods() {
ClassDB::bind_method(D_METHOD("open_video", "url", "authorization", "start_ms"), &JellyframeMpv::open_video);
ClassDB::bind_method(D_METHOD("set_paused", "paused"), &JellyframeMpv::set_paused);
ClassDB::bind_method(D_METHOD("seek_to", "milliseconds"), &JellyframeMpv::seek_to);
ClassDB::bind_method(D_METHOD("position_ms"), &JellyframeMpv::position_ms);
ClassDB::bind_method(D_METHOD("duration_ms"), &JellyframeMpv::duration_ms);
ClassDB::bind_method(D_METHOD("is_buffering"), &JellyframeMpv::is_buffering);
ClassDB::bind_method(D_METHOD("rendering_backend"), &JellyframeMpv::rendering_backend);
ClassDB::bind_method(D_METHOD("playback_diagnostics"), &JellyframeMpv::playback_diagnostics);
ClassDB::bind_method(D_METHOD("stop_video"), &JellyframeMpv::stop_video);
void JellyXRMpv::_bind_methods() {
ClassDB::bind_method(D_METHOD("open_video", "url", "authorization", "start_ms"), &JellyXRMpv::open_video);
ClassDB::bind_method(D_METHOD("set_paused", "paused"), &JellyXRMpv::set_paused);
ClassDB::bind_method(D_METHOD("seek_to", "milliseconds"), &JellyXRMpv::seek_to);
ClassDB::bind_method(D_METHOD("position_ms"), &JellyXRMpv::position_ms);
ClassDB::bind_method(D_METHOD("duration_ms"), &JellyXRMpv::duration_ms);
ClassDB::bind_method(D_METHOD("is_buffering"), &JellyXRMpv::is_buffering);
ClassDB::bind_method(D_METHOD("rendering_backend"), &JellyXRMpv::rendering_backend);
ClassDB::bind_method(D_METHOD("video_shader_parameters"), &JellyXRMpv::video_shader_parameters);
ClassDB::bind_method(D_METHOD("playback_diagnostics"), &JellyXRMpv::playback_diagnostics);
ClassDB::bind_method(D_METHOD("stop_video"), &JellyXRMpv::stop_video);
ADD_SIGNAL(MethodInfo("prepared"));
ADD_SIGNAL(MethodInfo("ended"));
ADD_SIGNAL(MethodInfo("failed", PropertyInfo(Variant::STRING, "message")));
@@ -41,18 +43,18 @@ void JellyframeMpv::_bind_methods() {
ADD_SIGNAL(MethodInfo("texture_changed", PropertyInfo(Variant::OBJECT, "texture", PROPERTY_HINT_RESOURCE_TYPE, "Texture2D")));
}
JellyframeMpv::~JellyframeMpv() {
JellyXRMpv::~JellyXRMpv() {
shutdown();
}
String JellyframeMpv::initialize(int64_t start_ms) {
log_video_stats = OS::get_singleton()->get_environment("JELLYFRAME_VIDEO_STATS") == "1";
String JellyXRMpv::initialize(int64_t start_ms) {
log_video_stats = OS::get_singleton()->get_environment("JELLYXR_VIDEO_STATS") == "1";
last_diagnostics_time = playback_time_ms();
player = mpv_create();
if (!player) return "Could not create the PC video decoder.";
const bool use_opengl = !software_only &&
OS::get_singleton()->get_environment("JELLYFRAME_PC_RENDERER") != "software" && hardware_opengl_available();
if (!software_only && OS::get_singleton()->get_environment("JELLYFRAME_PC_RENDERER") != "software" &&
OS::get_singleton()->get_environment("JELLYXR_PC_RENDERER") != "software" && hardware_opengl_available();
if (!software_only && OS::get_singleton()->get_environment("JELLYXR_PC_RENDERER") != "software" &&
DisplayServer::get_singleton()->get_name() != "headless" &&
RenderingServer::get_singleton()->get_current_rendering_driver_name() == "vulkan") {
vulkan_renderer.instantiate();
@@ -66,8 +68,10 @@ String JellyframeMpv::initialize(int64_t start_ms) {
{"ytdl", "no"}, {"input-default-bindings", "no"}, {"input-vo-keyboard", "no"},
{"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"}
{"sid", "no"}, {"sub-auto", "no"}, {"audio-file-auto", "no"}, {"keep-open", "no"},
{"idle", "yes"}, {"tls-verify", "yes"},
{"cache", "yes"}, {"cache-secs", "30"}, {"demuxer-max-bytes", "256MiB"},
{"user-agent", JELLYXR_STREAM_USER_AGENT}
};
for (const auto &option : options) {
int error = mpv_set_option_string(player, option[0], option[1]);
@@ -98,7 +102,7 @@ String JellyframeMpv::initialize(int64_t start_ms) {
return "";
}
String JellyframeMpv::open_video(String url, String authorization, int64_t start_ms) {
String JellyXRMpv::open_video(String url, String authorization, int64_t start_ms) {
shutdown();
current_url = url;
current_authorization = authorization;
@@ -136,13 +140,14 @@ String JellyframeMpv::open_video(String url, String authorization, int64_t start
return String("Could not open the video: ") + mpv_error_string(result);
}
position = std::max<int64_t>(0, start_ms);
audio_clock.reset(position, playback_time_ms());
duration = 0;
active = true;
set_process(true);
return "";
}
void JellyframeMpv::set_paused(bool value) {
void JellyXRMpv::set_paused(bool value) {
if (!player || !active) return;
if (uses_video_clock()) {
if (!paused && video_clock_started && !seeking) position = int64_t(playback_time_ms() - video_clock_origin);
@@ -152,13 +157,15 @@ void JellyframeMpv::set_paused(bool value) {
return;
}
paused = value;
audio_clock.reset(position, playback_time_ms());
watchdog.reset(playback_time_ms());
apply_audio_pause();
}
void JellyframeMpv::seek_to(int64_t milliseconds) {
void JellyXRMpv::seek_to(int64_t milliseconds) {
if (!player || !active) return;
seeking = true;
audio_clock.reset(std::max<int64_t>(0, milliseconds), playback_time_ms());
if (vulkan_renderer.is_valid()) vulkan_renderer->seek(milliseconds);
watchdog.reset(playback_time_ms());
if (uses_video_clock()) {
@@ -171,27 +178,27 @@ void JellyframeMpv::seek_to(int64_t milliseconds) {
mpv_command_async(player, 0, command);
}
int64_t JellyframeMpv::position_ms() const { return position; }
int64_t JellyframeMpv::duration_ms() const { return duration; }
int64_t JellyXRMpv::position_ms() const { return position; }
int64_t JellyXRMpv::duration_ms() const { return duration; }
bool JellyframeMpv::is_buffering() const {
bool JellyXRMpv::is_buffering() const {
return active && (buffering || video_buffering || seeking);
}
bool JellyframeMpv::uses_video_clock() const {
bool JellyXRMpv::uses_video_clock() const {
return vulkan_renderer.is_valid() && !vulkan_renderer->has_audio();
}
String JellyframeMpv::rendering_backend() const {
String JellyXRMpv::rendering_backend() const {
if (!player) return "none";
if (vulkan_renderer.is_valid()) return "vulkan";
return gl_renderer.is_valid() ? "opengl" : "software";
}
Dictionary JellyframeMpv::playback_diagnostics() const {
Dictionary JellyXRMpv::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-paced-yuv-2";
values["audio_position_ms"] = position;
if (vulkan_renderer.is_valid()) {
values["video_position_ms"] = vulkan_renderer->timestamp_ms();
@@ -201,7 +208,7 @@ Dictionary JellyframeMpv::playback_diagnostics() const {
return values;
}
void JellyframeMpv::fallback_to_software() {
void JellyXRMpv::fallback_to_software() {
UtilityFunctions::print("PC video: falling back to software. ", playback_diagnostics());
String url = current_url;
String authorization = current_authorization;
@@ -213,11 +220,11 @@ void JellyframeMpv::fallback_to_software() {
if (was_paused) set_paused(true);
}
void JellyframeMpv::stop_video() {
void JellyXRMpv::stop_video() {
shutdown();
}
void JellyframeMpv::shutdown() {
void JellyXRMpv::shutdown() {
active = false;
prepared = false;
paused = false;
@@ -244,7 +251,7 @@ void JellyframeMpv::shutdown() {
current_authorization = "";
}
bool JellyframeMpv::update_size() {
bool JellyXRMpv::update_size() {
if (vulkan_renderer.is_valid()) {
emit_signal("size_changed", vulkan_renderer->width(), vulkan_renderer->height());
return vulkan_renderer->width() > 0 && vulkan_renderer->height() > 0;
@@ -265,22 +272,27 @@ bool JellyframeMpv::update_size() {
return true;
}
bool JellyframeMpv::upload_frame() {
bool JellyXRMpv::upload_frame() {
if (vulkan_renderer.is_valid()) {
vulkan_renderer->set_clock(position);
uint64_t now = playback_time_ms();
if (paused || buffering || video_buffering || seeking || !prepared) audio_clock.reset(position, now);
vulkan_renderer->set_clock(uses_video_clock() ? position : audio_clock.position(now));
Dictionary previous_parameters = vulkan_renderer->shader_parameters();
if (!vulkan_renderer->take_frame()) return false;
if (uses_video_clock()) {
if (!video_clock_started || seeking) video_clock_origin = playback_time_ms() - position;
video_clock_started = true;
seeking = false;
}
Ref<Texture2D> next = vulkan_renderer->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()) {
@@ -305,12 +317,17 @@ bool JellyframeMpv::upload_frame() {
return true;
}
void JellyframeMpv::update_property(const mpv_event &event) {
void JellyXRMpv::update_property(const mpv_event &event) {
auto *property = static_cast<mpv_event_property *>(event.data);
if (!property->data) return;
if (property->format == MPV_FORMAT_DOUBLE) {
int64_t milliseconds = int64_t(*static_cast<double *>(property->data) * 1000.0);
if (event.reply_userdata == 1) position = std::max<int64_t>(0, milliseconds);
if (event.reply_userdata == 1) {
position = std::max<int64_t>(0, milliseconds);
uint64_t now = playback_time_ms();
if (paused || buffering || video_buffering || seeking || !prepared) audio_clock.reset(position, now);
else audio_clock.synchronize(position, now);
}
if (event.reply_userdata == 2) duration = std::max<int64_t>(0, milliseconds);
} else if (property->format == MPV_FORMAT_FLAG) {
bool value = *static_cast<int *>(property->data) != 0;
@@ -329,7 +346,7 @@ void JellyframeMpv::update_property(const mpv_event &event) {
}
}
void JellyframeMpv::_process(double) {
void JellyXRMpv::_process(double) {
if (!player || !active) return;
if (vulkan_renderer.is_valid() && vulkan_renderer->error() < 0) { fallback_to_software(); return; }
if (gl_renderer.is_valid() && gl_renderer->error() < 0) { fallback_to_software(); return; }
@@ -402,3 +419,7 @@ void JellyframeMpv::_process(double) {
emit_signal("failed", "Video playback stalled.");
}
}
Dictionary JellyXRMpv::video_shader_parameters() const {
return vulkan_renderer.is_valid() ? vulkan_renderer->shader_parameters() : Dictionary();
}
+7 -3
View File
@@ -4,14 +4,15 @@
#include "render_gl.h"
#include "render_vulkan.h"
#include "playback_watchdog.h"
#include "playback_clock.h"
#include <godot_cpp/classes/image_texture.hpp>
#include <godot_cpp/classes/node.hpp>
class JellyframeMpv : public godot::Node {
GDCLASS(JellyframeMpv, godot::Node)
class JellyXRMpv : public godot::Node {
GDCLASS(JellyXRMpv, godot::Node)
public:
~JellyframeMpv();
~JellyXRMpv();
godot::String open_video(godot::String url, godot::String authorization, int64_t start_ms);
void set_paused(bool paused);
void seek_to(int64_t milliseconds);
@@ -20,6 +21,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;
@@ -59,4 +62,5 @@ private:
godot::String current_url;
godot::String current_authorization;
PlaybackWatchdog watchdog;
PlaybackClock audio_clock;
};
+28
View File
@@ -0,0 +1,28 @@
#pragma once
#include <algorithm>
#include <cstdint>
#include <cstdlib>
class PlaybackClock {
public:
void reset(int64_t position, uint64_t now) {
anchor = position;
sampled_at = now;
}
int64_t position(uint64_t now) const {
return anchor + int64_t(std::min<uint64_t>(now - sampled_at, 250));
}
void synchronize(int64_t audio_position, uint64_t now) {
int64_t predicted = position(now);
int64_t error = audio_position - predicted;
anchor = std::abs(error) > 250 ? audio_position : predicted + std::clamp<int64_t>(error, -2, 2);
sampled_at = now;
}
private:
int64_t anchor = 0;
uint64_t sampled_at = 0;
};
+2 -2
View File
@@ -7,13 +7,13 @@ static void initialize(ModuleInitializationLevel level) {
if (level == MODULE_INITIALIZATION_LEVEL_SCENE) {
ClassDB::register_internal_class<RenderGl>();
ClassDB::register_internal_class<RenderVulkan>();
ClassDB::register_class<JellyframeMpv>();
ClassDB::register_class<JellyXRMpv>();
}
}
static void terminate(ModuleInitializationLevel) {}
extern "C" GDExtensionBool GDE_EXPORT jellyframe_mpv_init(
extern "C" GDExtensionBool GDE_EXPORT jellyxr_mpv_init(
GDExtensionInterfaceGetProcAddress get_proc_address,
GDExtensionClassLibraryPtr library,
GDExtensionInitialization *initialization) {
+41 -90
View File
@@ -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(), &parameters, 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;
}
+9 -11
View File
@@ -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;
+2 -2
View File
@@ -3,9 +3,9 @@
#include <memory>
#ifdef _WIN32
#define JELLYFRAME_GL_CALL __stdcall
#define JELLYXR_GL_CALL __stdcall
#else
#define JELLYFRAME_GL_CALL
#define JELLYXR_GL_CALL
#endif
class SharedGlContext {
+3
View File
@@ -0,0 +1,3 @@
#pragma once
inline constexpr const char *JELLYXR_STREAM_USER_AGENT = "jellyXR";
-72
View File
@@ -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";
+7 -2
View File
@@ -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", JELLYXR_STREAM_USER_AGENT, 0);
av_dict_set(&options, "rw_timeout", "15000000", 0);
io_deadline = av_gettime_relative() + 15000000;
int result = avformat_open_input(&input, url.c_str(), nullptr, &options);
@@ -148,6 +150,7 @@ int VideoDecoder::start(const std::string &url, const std::string &authorization
last_publish_us = 0;
decoded_timestamp = 0;
generation = 0;
awaiting_first_frame = true;
rotation = 0;
std::promise<int> initialized;
auto result = initialized.get_future();
@@ -202,13 +205,15 @@ void VideoDecoder::seek(int64_t milliseconds) {
reader_ended = false;
end_of_stream = false;
frame_ready = false;
awaiting_first_frame = true;
}
wake.notify_all();
}
bool VideoDecoder::take_frame(VideoPlanes &frame) {
std::lock_guard<std::mutex> lock(mutex);
if (!frame_ready) return false;
if (!frame_ready || (!awaiting_first_frame && pending.timestamp > clock)) return false;
awaiting_first_frame = false;
std::swap(frame, pending);
frame_ready = false;
wake.notify_all();
@@ -217,7 +222,7 @@ bool VideoDecoder::take_frame(VideoPlanes &frame) {
bool VideoDecoder::has_frame() {
std::lock_guard<std::mutex> lock(mutex);
return frame_ready;
return frame_ready && (awaiting_first_frame || pending.timestamp <= clock);
}
void VideoDecoder::decode_loop() {
+5 -1
View File
@@ -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);
@@ -121,6 +124,7 @@ private:
VideoPlanes pending;
VideoPlanes staging;
bool frame_ready = false;
bool awaiting_first_frame = true;
int64_t clock = 0;
int64_t seek_target = -1;
int64_t discard_before = -1;
+1 -1
View File
@@ -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;
+2 -2
View File
@@ -1,11 +1,11 @@
#include "mpv_video.h"
void JellyframeMpv::apply_audio_pause() {
void JellyXRMpv::apply_audio_pause() {
int flag = paused || video_buffering;
mpv_set_property_async(player, 0, "pause", MPV_FORMAT_FLAG, &flag);
}
void JellyframeMpv::update_video_buffering() {
void JellyXRMpv::update_video_buffering() {
if (vulkan_renderer.is_null() || uses_video_clock() || !prepared || seeking) return;
int64_t timestamp = vulkan_renderer->timestamp_ms();
bool waiting = video_buffering;
+1 -1
View File
@@ -5,6 +5,6 @@ dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories { google(); mavenCentral() }
}
rootProject.name = 'JellyframeVideo'
rootProject.name = 'JellyXRVideo'
include ':android'
+6 -7
View File
@@ -14,13 +14,13 @@ compatibility/default_parent_skeleton_in_mesh_instance_3d=true
[application]
config/name="Jellyframe"
config/version="0.1.1"
config/name="jellyXR"
config/description="A Jellyfin cinema for standalone VR."
config/version="0.1.3"
run/main_scene="res://scenes/main.tscn"
config/use_custom_user_dir=true
config/features=PackedStringArray("4.7", "GL Compatibility")
config/icon="res://assets/logo/jellyframe-logo.png"
config/icon="res://assets/logo/jellyxr-logo.png"
[display]
@@ -29,18 +29,16 @@ window/size/viewport_height=900
[editor_plugins]
enabled=PackedStringArray("res://addons/jellyframe_video/plugin.cfg")
enabled=PackedStringArray("res://addons/jellyxr_video/plugin.cfg")
[rendering]
renderer/rendering_method="gl_compatibility"
renderer/rendering_method.mobile="gl_compatibility"
textures/vram_compression/import_etc2_astc=true
environment/defaults/default_clear_color=Color(0.018, 0.029, 0.046, 1)
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
+1 -1
View File
@@ -2,6 +2,6 @@
[ext_resource type="Script" path="res://scripts/main.gd" id="1"]
[node name="Jellyframe" type="Node3D"]
[node name="jellyXR" type="Node3D"]
script = ExtResource("1")
+45
View File
@@ -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]}
+1
View File
@@ -0,0 +1 @@
uid://gscxynvjkrml
+15 -2
View File
@@ -1,4 +1,4 @@
class_name JellyframeSettings
class_name JellyXRSettings
extends RefCounted
const PATH := "user://settings.cfg"
@@ -21,12 +21,16 @@ var distance := 3.0
var height := 0.0
var curvature := 0.0
var environment := "Void"
var follow_screen := false
var server := ""
var device_id := ""
var library_sort_by := "SortName"
var library_sort_order := "Ascending"
var playback_mode := "auto"
var transcode_resolution := 1080
var subtitle_size := 48
var subtitle_color := "White"
var subtitle_height := 0.18
func load_settings(path: String = PATH) -> void:
var config := ConfigFile.new()
@@ -35,6 +39,7 @@ func load_settings(path: String = PATH) -> void:
distance = clampf(config.get_value("screen", "distance", distance), 1.5, 8.0)
height = clampf(config.get_value("screen", "height", height), -2.0, 2.0)
curvature = clampf(config.get_value("screen", "curvature", curvature), 0.0, 1.2)
follow_screen = config.get_value("screen", "follow_screen", false) == true
environment = config.get_value("screen", "environment", "Void")
if int(config.get_value("screen", "environment_version", 1)) < 2:
if environment == "Theatre": environment = "Cinema"
@@ -52,6 +57,10 @@ func load_settings(path: String = PATH) -> void:
if playback_mode not in PLAYBACK_MODES: playback_mode = "auto"
transcode_resolution = int(config.get_value("playback", "transcode_resolution", 1080))
if not TRANSCODE_PRESETS.has(transcode_resolution): transcode_resolution = 1080
subtitle_size = clampi(int(config.get_value("subtitles", "size", 48)), 28, 80)
subtitle_color = str(config.get_value("subtitles", "color", "White"))
if subtitle_color not in ["White", "Yellow", "Green"]: subtitle_color = "White"
subtitle_height = clampf(float(config.get_value("subtitles", "height", 0.18)), 0.1, 0.8)
if device_id.is_empty():
device_id = Crypto.new().generate_random_bytes(16).hex_encode()
save(path)
@@ -60,7 +69,7 @@ func is_theatre() -> bool:
return environment in ["Cinema", "Theatre"]
func reset_screen() -> void:
var defaults := JellyframeSettings.new()
var defaults := JellyXRSettings.new()
for key in SCREEN_KEYS:
set(key, defaults.get(key))
@@ -69,6 +78,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)
@@ -76,5 +86,8 @@ func save(path: String = PATH) -> Error:
config.set_value("library", "sort_order", library_sort_order)
config.set_value("playback", "mode", playback_mode)
config.set_value("playback", "transcode_resolution", transcode_resolution)
config.set_value("subtitles", "size", subtitle_size)
config.set_value("subtitles", "color", subtitle_color)
config.set_value("subtitles", "height", subtitle_height)
return config.save(path)
+25 -3
View File
@@ -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="jellyXR", Device="%s", DeviceId="%s", Version="0.1.3"' % [name, device_id]
if not token.is_empty():
value += ', Token="%s"' % token
return value
@@ -84,7 +84,7 @@ func query(values: Dictionary) -> String:
return "?" + "&".join(pairs)
func library(search: String = "", offset: int = 0, parent_id: String = "") -> Dictionary:
var values := {"UserId": user_id, "Recursive": "true", "IncludeItemTypes": "Movie,Series,Video", "Fields": "Overview,UserData", "Limit": 25, "StartIndex": offset, "SortBy": library_sort_by, "SortOrder": library_sort_order}
var values := {"UserId": user_id, "Recursive": "true", "IncludeItemTypes": "Movie,Series,Video,Photo", "Fields": "Overview,UserData", "Limit": 25, "StartIndex": offset, "SortBy": library_sort_by, "SortOrder": library_sort_order}
if search.is_empty() and parent_id.is_empty():
values["SortBy"] = "DateCreated"
values["SortOrder"] = "Descending"
@@ -133,11 +133,33 @@ func resume_items() -> Dictionary:
func details(id: String) -> Dictionary:
return await request("/Users/%s/Items/%s" % [user_id, id.uri_encode()])
func set_watched(id: String, watched: bool) -> Dictionary:
return await request("/Users/%s/PlayedItems/%s" % [user_id.uri_encode(), id.uri_encode()], HTTPClient.METHOD_POST if watched else HTTPClient.METHOD_DELETE)
func seasons(series_id: String) -> Dictionary:
return await request("/Shows/%s/Seasons" % series_id.uri_encode() + query({"UserId": user_id, "Fields": "Overview,UserData", "EnableUserData": "true"}))
func series_playback(series_id: String) -> Dictionary:
var result := await request("/Shows/NextUp" + query(_next_up_parameters(series_id)))
if not result.ok or not result.data.get("Items", []).is_empty(): return result
return await request("/Shows/%s/Episodes" % series_id.uri_encode() + query({"UserId": user_id, "StartIndex": 0, "Limit": 1, "SortBy": "ParentIndexNumber,IndexNumber", "SortOrder": "Ascending", "Fields": "Overview,UserData,MediaSources", "EnableUserData": "true", "IsMissing": "false"}))
func _next_up_parameters(series_id: String) -> Dictionary:
return {"UserId": user_id, "SeriesId": series_id, "Limit": 1, "Fields": "Overview,UserData,MediaSources", "EnableUserData": "true", "EnableResumable": "true", "EnableRewatching": "false"}
func next_up(series_id: String) -> Dictionary:
var parameters := _next_up_parameters(series_id)
var result := await request("/Shows/NextUp" + query(parameters))
if not result.ok: return result
var items: Array = result.data.get("Items", [])
if items.is_empty(): return result
var user: Dictionary = items[0].get("UserData", {}) if items[0].get("UserData") else {}
if int(user.get("PlaybackPositionTicks", 0)) > 0: return result
parameters["DisableFirstEpisode"] = "true"
return await request("/Shows/NextUp" + query(parameters))
func episodes(series_id: String, season_id: String, offset: int = 0) -> Dictionary:
return await request("/Shows/%s/Episodes" % series_id.uri_encode() + query({"UserId": user_id, "SeasonId": season_id, "StartIndex": offset, "Limit": 100, "Fields": "Overview,UserData", "EnableUserData": "true", "IsMissing": "false"}))
return await request("/Shows/%s/Episodes" % series_id.uri_encode() + query({"UserId": user_id, "SeasonId": season_id, "StartIndex": offset, "Limit": 100, "Fields": "Overview,UserData,MediaSources", "EnableUserData": "true", "IsMissing": "false"}))
func next_episode(item: Dictionary) -> Dictionary:
var series_id := str(item.get("SeriesId", ""))
+43
View File
@@ -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 {}
+1
View File
@@ -0,0 +1 @@
uid://cwoeoeho3i2tr
+67 -15
View File
@@ -3,9 +3,10 @@ extends Node
signal failed(message: String)
signal stream_ready(url: String, source: Dictionary, start_ms: int)
signal reporting_failed(message: String)
var api: JellyfinClient
var settings: JellyframeSettings
var settings: JellyXRSettings
var active_playback_mode := "auto"
var active_transcode_resolution := 1080
var item: Dictionary = {}
@@ -15,38 +16,69 @@ var method := "DirectPlay"
var position_ticks := 0
var started := false
var fallback_attempted := false
var media_source_id := ""
var audio_index := -1
var subtitle_index := -1
var burn_subtitles := false
var generation := 0
func open(selected: Dictionary, transcode: bool = false) -> void:
func open(selected: Dictionary, transcode: bool = false, continuing: bool = false) -> void:
generation += 1
var current := generation
item = selected
if not transcode:
if not transcode and not continuing:
audio_index = -1
subtitle_index = -1
burn_subtitles = false
media_source_id = str(item.get("SelectedMediaSourceId", ""))
if media_source_id.is_empty() and item.get("MediaSources") is Array and not item.MediaSources.is_empty():
media_source_id = str(item.MediaSources[0].get("Id", ""))
position_ticks = int(item.get("UserData", {}).get("PlaybackPositionTicks", 0))
fallback_attempted = false
active_playback_mode = settings.playback_mode if settings != null else "auto"
active_transcode_resolution = settings.transcode_resolution if settings != null and active_playback_mode == "transcoded" else 1080
started = false
var use_transcode := transcode or active_playback_mode == "transcoded"
var use_transcode := transcode or burn_subtitles or active_playback_mode == "transcoded"
var original := active_playback_mode == "original"
var profile := _device_profile(use_transcode, original)
var result := await api.request("/Items/%s/PlaybackInfo" % str(item.Id).uri_encode(), HTTPClient.METHOD_POST, {
var body := {
"UserId": api.user_id, "StartTimeTicks": position_ticks, "IsPlayback": true,
"AutoOpenLiveStream": true, "EnableDirectPlay": not use_transcode, "EnableDirectStream": false,
"AutoOpenLiveStream": true, "EnableDirectPlay": not use_transcode and audio_index < 0, "EnableDirectStream": not use_transcode and not original,
"EnableTranscoding": not original, "AllowVideoStreamCopy": not use_transcode,
"DeviceProfile": profile, "MaxStreamingBitrate": profile.MaxStreamingBitrate
})
}
body["SubtitleStreamIndex"] = subtitle_index if burn_subtitles else -1
if burn_subtitles: body["AlwaysBurnInSubtitleWhenTranscoding"] = true
if audio_index >= 0: body["AudioStreamIndex"] = audio_index
if not media_source_id.is_empty(): body["MediaSourceId"] = media_source_id
var needs_source_selection := media_source_id.is_empty()
var result := await api.request("/Items/%s/PlaybackInfo" % str(item.Id).uri_encode(), HTTPClient.METHOD_POST, body)
if current != generation: return
if not result.ok:
failed.emit(result.error)
return
if result.data.get("MediaSources", []).is_empty():
failed.emit("Jellyfin could not provide a video stream. Check your account's playback permissions.")
return
source = result.data.MediaSources[0]
source = {}
for candidate in result.data.MediaSources:
if media_source_id.is_empty() or str(candidate.get("Id", "")) == media_source_id:
source = candidate
break
if source.is_empty():
failed.emit("The selected version is no longer available. Reopen the item and choose another version.")
return
media_source_id = str(source.Id)
if needs_source_selection and source.get("DefaultSubtitleStreamIndex") != null and int(source.DefaultSubtitleStreamIndex) >= 0:
await open(item, transcode, true)
return
session_id = result.data.get("PlaySessionId", "")
var url: String
var start_ms := int(position_ticks / 10000)
if original and not source.get("SupportsDirectPlay", false):
failed.emit("Jellyfin cannot provide the original video. Choose Transcoded in Settings > Playback.")
return
if source.get("SupportsDirectPlay", false) and not use_transcode:
if source.get("SupportsDirectPlay", false) and not use_transcode and audio_index < 0:
method = "DirectPlay"
url = api.server + "/Videos/%s/stream" % str(item.Id).uri_encode() + api.query({"Static": "true", "MediaSourceId": source.Id})
else:
@@ -55,6 +87,8 @@ func open(selected: Dictionary, transcode: bool = false) -> void:
if path.is_empty():
failed.emit("Jellyfin needs to convert this video but did not provide a stream.")
return
if not use_transcode and (source.get("SupportsDirectStream", false) or _copies_video(path)):
method = "DirectStream"
url = api.stream_url(path)
if url.is_empty():
failed.emit("The server returned an unsupported stream address.")
@@ -63,7 +97,7 @@ func open(selected: Dictionary, transcode: bool = false) -> void:
stream_ready.emit(url, source, start_ms)
func is_hls() -> bool:
if method != "Transcode": return false
if method == "DirectPlay": return false
var protocol := str(source.get("TranscodingSubProtocol", "")).to_lower()
var path := str(source.get("TranscodingUrl", "")).get_slice("?", 0).to_lower()
return protocol == "hls" or path.ends_with(".m3u8")
@@ -75,18 +109,18 @@ func _device_profile(transcode: bool, original: bool = false) -> Dictionary:
direct_profile["AudioCodec"] = "aac,mp3,opus,vorbis"
var transcoding_profile := {"Container": "ts", "Type": "Video", "VideoCodec": "h264", "AudioCodec": "aac", "Protocol": "hls", "Context": "Streaming", "MaxAudioChannels": "2", "MinSegments": 2, "BreakOnNonKeyFrames": true}
var codec_conditions := [{"Condition": "Equals", "Property": "VideoRangeType", "Value": "SDR", "IsRequired": true}]
var preset: Dictionary = JellyframeSettings.TRANSCODE_PRESETS.get(active_transcode_resolution, JellyframeSettings.TRANSCODE_PRESETS[1080])
var preset: Dictionary = JellyXRSettings.TRANSCODE_PRESETS.get(active_transcode_resolution, JellyXRSettings.TRANSCODE_PRESETS[1080])
transcoding_profile["Conditions"] = []
for limit in [["Width", str(preset.width)], ["Height", str(preset.height)], ["VideoFramerate", "30"], ["VideoBitrate", str(preset.bitrate)]]:
transcoding_profile.Conditions.append({"Condition": "LessThanEqual", "Property": limit[0], "Value": limit[1], "IsRequired": true})
if transcode: codec_conditions.append_array(transcoding_profile.Conditions)
return {
"Name": ("Jellyframe original " if original else "Jellyframe SDR ") + OS.get_name(),
"Name": ("jellyXR original " if original else "jellyXR SDR ") + OS.get_name(),
"MaxStreamingBitrate": 2147483647 if original else 80000000,
"DirectPlayProfiles": [] if transcode else [direct_profile],
"TranscodingProfiles": [] if original else [transcoding_profile],
"CodecProfiles": [] if original else [{"Type": "Video", "Conditions": codec_conditions}],
"SubtitleProfiles": []
"SubtitleProfiles": [{"Format": "vtt", "Method": "External"}, {"Format": "pgssub", "Method": "Encode"}, {"Format": "dvdsub", "Method": "Encode"}, {"Format": "dvbsub", "Method": "Encode"}]
}
func retry_transcode() -> void:
@@ -105,8 +139,26 @@ func report(event: String, paused: bool = false) -> void:
if event == "Playing": started = true
if not started: return
var path := "/Sessions/Playing" + ("/" + event if event != "Playing" else "")
await api.request(path, HTTPClient.METHOD_POST, {"ItemId": item.Id, "MediaSourceId": source.Id, "PlaySessionId": session_id, "PositionTicks": position_ticks, "IsPaused": paused, "CanSeek": true, "PlayMethod": method})
var body := {"ItemId": item.Id, "MediaSourceId": source.Id, "PlaySessionId": session_id, "PositionTicks": position_ticks, "IsPaused": paused, "CanSeek": true, "PlayMethod": method, "SubtitleStreamIndex": subtitle_index}
if audio_index >= 0:
body["AudioStreamIndex"] = audio_index
elif source.get("DefaultAudioStreamIndex") != null:
body["AudioStreamIndex"] = int(source.DefaultAudioStreamIndex)
var result := await api.request(path, HTTPClient.METHOD_POST, body)
if not result.ok:
var message := "Jellyfin playback report (%s) failed: %s" % [event, result.get("error", "Unknown error.")]
push_warning(message)
reporting_failed.emit(message)
if event == "Stopped":
started = false
if method == "Transcode":
if method != "DirectPlay":
await api.request("/Videos/ActiveEncodings" + api.query({"DeviceId": api.device_id, "PlaySessionId": session_id}), HTTPClient.METHOD_DELETE)
func cancel_pending() -> void:
generation += 1
func _copies_video(path: String) -> bool:
for pair in path.get_slice("?", 1).split("&"):
if pair.get_slice("=", 0).to_lower() == "videocodec":
return pair.get_slice("=", 1).uri_decode().to_lower() == "copy"
return false
+330 -60
View File
@@ -1,6 +1,6 @@
extends Node3D
var settings := JellyframeSettings.new()
var settings := JellyXRSettings.new()
var session_store := SessionStore.new()
var api := JellyfinClient.new()
var session := PlaybackSession.new()
@@ -14,6 +14,9 @@ var libraries_loaded := false
var library_generation := 0
var page_generation := 0
var page: VBoxContainer
var browsing_return_page: Control
var browsing_return_heading := ""
var browsing_return_status := ""
var status: Label
var keyboard: VRKeyboard
var material: ShaderMaterial
@@ -25,17 +28,26 @@ var stereo_overridden := false
var projection_mode := VideoProjection.Mode.SCREEN
var projection_overridden := false
var video_size := Vector2i(1920, 1080)
var decoder_size := Vector2i.ZERO
var playing := false
var elapsed := 0.0
var transcode_offset := 0
var quick_generation := 0
var countdown_generation := 0
var autoplay_pending := false
var calibration := false
var seeking := false
var seek_paused := false
var timeline_elapsed := 0.0
var decoder_ready := false
var media_segments := MediaSegments.new()
var photo_active := false
var photo_loading := false
var photo_item: Dictionary = {}
var video_material: ShaderMaterial
var photo_loader: PhotoView
var photo_sequence := PhotoSequence.new()
var subtitles := SubtitleTrack.new()
var subtitle_overlay := SubtitleOverlay.new()
func _ready() -> void:
settings.load_settings()
@@ -45,20 +57,19 @@ func _ready() -> void:
session.settings = settings
add_child(session)
add_child(video)
add_child(subtitles)
_setup_world()
add_child(MenuFeedback.new())
_setup_ui()
session.stream_ready.connect(_play_stream)
session.failed.connect(_error)
session.reporting_failed.connect(func(message: String): status.text = message)
video.failed.connect(_video_failed)
video.prepared.connect(_video_prepared)
video.ended.connect(_ended)
video.size_changed.connect(func(w: int, h: int): video_size = Vector2i(w, h); _resize_screen())
video.size_changed.connect(func(w: int, h: int): video_size = Vector2i(w, h); decoder_size = video_size; _resize_screen())
_show_login()
await _restore_session()
if video.has_android_services() and OS.is_debug_build() and video.bridge.calibration_requested():
await get_tree().create_timer(2).timeout
_calibrate()
func _setup_world() -> void:
world = ViewingWorld.new()
@@ -69,14 +80,20 @@ func _setup_world() -> void:
viewport = world.viewport
panel = world.panel
screen = world.screen
screen.add_child(subtitle_overlay)
player_overlay = world.player_overlay
subtitle_overlay.controls = player_overlay
player_overlay.action.connect(_player_action)
player_overlay.statistics_provider = func(): return preload("res://scripts/ui/playback_statistics.gd").snapshot(session, video, decoder_size if decoder_ready else Vector2i.ZERO)
player_overlay.seek_requested.connect(_seek_to)
world.player_loading.cancelled.connect(_show_library)
world.player_skip_popup.skip_requested.connect(_skip_segment)
material = world.material
theatre = world.theatre
video.material = material
world.menu_requested.connect(_toggle_menu)
world.settings_requested.connect(_toggle_settings)
world.library_requested.connect(_toggle_controller_library)
var xr := XRServer.find_interface("OpenXR") as OpenXRInterface
if xr:
xr.pose_recentered.connect(_recenter, CONNECT_DEFERRED)
@@ -93,12 +110,17 @@ func _setup_ui() -> void:
status = shell.status
shell.navigate.connect(_open_library)
shell.screen_settings.connect(_screen_settings)
shell.return_from_settings.connect(_restore_browsing_page)
shell.sign_out.connect(_sign_out)
keyboard = VRKeyboard.new()
viewport.add_child(keyboard)
keyboard.hide()
func _clear_page() -> void:
func _clear_page(keep_browsing_return: bool = false) -> void:
if not keep_browsing_return and is_instance_valid(browsing_return_page):
browsing_return_page.queue_free()
browsing_return_page = null
shell.set_settings_mode(false)
page_generation += 1
countdown_generation += 1
_cancel_countdown()
@@ -141,8 +163,6 @@ func _show_login() -> void:
else: status.text = result.error)
Widgets.button("Quick Connect", func():
if _configure(address.text): _quick_connect(), row)
if video.has_android_services() and OS.is_debug_build():
Widgets.button("Test 3D playback", _calibrate, row)
Widgets.label("HTTP sends credentials without encryption. Use HTTPS outside your home network.", page, 18)
status.text = "Sign in to start watching."
@@ -236,6 +256,22 @@ func _show_library() -> void:
if shell.buttons.has(shell.selected_id): title = shell.buttons[shell.selected_id].text
_open_library(shell.selected_id, title)
func _toggle_controller_library() -> void:
if api.token.is_empty(): return
if autoplay_pending:
_show_library()
return
for child in page.get_children():
if child is TracksView or child is SettingsView:
_restore_browsing_page()
return
_toggle_panel()
func _toggle_panel() -> void:
panel.visible = not panel.visible
player_overlay.hide()
keyboard.hide()
func _open_library(id: String, title: String) -> void:
_clear_page()
shell.set_signed_in(true)
@@ -247,7 +283,11 @@ func _open_library(id: String, title: String) -> void:
api.library_sort_by = settings.library_sort_by
api.library_sort_order = settings.library_sort_order
library.size_flags_vertical = Control.SIZE_EXPAND_FILL
library.status.connect(func(message: String): status.text = message)
library.status.connect(func(message: String):
if library == browsing_return_page:
browsing_return_status = message
else:
status.text = message)
library.selected.connect(_details)
page.add_child(library)
library.field.focus_entered.connect(func(): keyboard.edit.call_deferred(library.field))
@@ -269,6 +309,12 @@ func _load_libraries() -> void:
Widgets.button("Retry libraries", _load_libraries, shell.libraries).add_theme_font_size_override("font_size", 17)
func _details(item: Dictionary, season: Dictionary = {}) -> void:
if item.get("Type", "") == "Photo":
photo_sequence = PhotoSequence.new()
for child in page.get_children():
if child is LibraryView: photo_sequence.capture(child, item)
_show_photo(item)
return
page_generation += 1
var current := page_generation
status.text = "Loading details..."
@@ -285,12 +331,96 @@ func _details(item: Dictionary, season: Dictionary = {}) -> void:
view.initial_season = season
view.size_flags_vertical = Control.SIZE_EXPAND_FILL
view.play_requested.connect(_start)
view.play_from_start_requested.connect(_start.bind(true))
view.version_requested.connect(_choose_media_version)
view.back_requested.connect(_show_library)
page.add_child(view)
status.text = "Ready."
func _start(item: Dictionary) -> void:
if playing and str(session.item.get("Id", "")) == str(item.get("Id", "")):
func _show_photo(item: Dictionary) -> void:
page_generation += 1
var current := page_generation
await _stop(false)
if current != page_generation: return
photo_active = true
photo_item = item
video_material = material
material = ShaderMaterial.new()
material.shader = preload("res://shaders/pc_video.gdshader")
screen.material_override = material
world.panorama.material_override = material
mode = PhotoView.stereo_mode(item)
projection_mode = VideoProjection.detect({"Path": item.get("Path", item.get("Name", ""))})
stereo_overridden = false
projection_overridden = false
photo_loader = PhotoView.new()
photo_loader.api = api
photo_loader.item = item
photo_loader.photo_ready.connect(_photo_ready)
photo_loader.failed.connect(_photo_failed)
status.text = "Loading photo..."
photo_loading = true
keyboard.hide()
panel.hide()
screen.show()
_resize_screen()
_update_loading()
add_child(photo_loader)
photo_loader._load()
func _photo_ready(texture: Texture2D) -> void:
if not photo_active: return
photo_loading = false
_update_loading()
material.set_shader_parameter("video_texture", texture)
video_size = Vector2i(texture.get_size())
screen.show()
world.set_panoramic(projection_mode != VideoProjection.Mode.SCREEN, VideoProjection.horizontal_angle(projection_mode))
_resize_screen()
status.text = ""
_show_playback()
func _photo_failed(message: String) -> void:
_close_photo()
status.text = message + " Select the photo to try again."
panel.show()
func _step_photo(direction: int) -> void:
if not photo_active or photo_sequence.busy: return
var current := page_generation
player_overlay.set_photo_navigation(false, false)
photo_loading = true
_update_loading()
var result := await photo_sequence.step(direction, api)
if current != page_generation or not photo_active: return
if result.has("item"):
await _show_photo(result.item)
else:
photo_loading = false
_update_loading()
player_overlay.set_photo_navigation(photo_sequence.index > 0, photo_sequence.can_next())
if result.has("error"): player_overlay.tooltip.text = result.error
func _close_photo() -> void:
if not photo_active: return
photo_active = false
photo_loading = false
world.player_loading.hide()
photo_item = {}
if is_instance_valid(photo_loader):
remove_child(photo_loader)
photo_loader.queue_free()
photo_loader = null
material = video_material
screen.material_override = material
world.panorama.material_override = material
video_material = null
world.set_panoramic(false)
player_overlay.set_photo_mode(false)
func _start(item: Dictionary, from_start: bool = false) -> void:
var requested_source := str(item.get("SelectedMediaSourceId", ""))
if not from_start and playing and str(session.item.get("Id", "")) == str(item.get("Id", "")) and (requested_source.is_empty() or requested_source == session.media_source_id):
if video.paused:
video.toggle_pause()
session.report("Progress", false)
@@ -300,21 +430,60 @@ func _start(item: Dictionary) -> void:
status.text = video.unavailable_message()
return
await _stop(false)
calibration = false
projection_overridden = false
stereo_overridden = false
projection_mode = VideoProjection.Mode.SCREEN
world.set_panoramic(false)
_recenter()
_resize_screen()
status.text = "Preparing playback..."
playing = true
screen.show()
_show_playback()
player_overlay.title.text = DetailsMetadata.title(item, "Now playing")
await session.open(item)
media_segments.load_item(api, str(item.Id))
var selected := item
if from_start:
selected = item.duplicate(true)
if not selected.get("UserData") is Dictionary: selected.UserData = {}
selected.UserData.PlaybackPositionTicks = 0
await session.open(selected)
func _choose_media_version(item: Dictionary) -> void:
var sources: Array = item.get("MediaSources", []) if item.get("MediaSources") else []
_clear_page()
shell.heading.text = "Playback"
panel.show()
player_overlay.hide()
Widgets.button("Back to details", _details.bind(item), page)
var title := Widgets.label("Choose a version", page, 36)
title.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
var name_label := Widgets.label(DetailsMetadata.title(item), page, 26)
name_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
var scroll := ScrollContainer.new()
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
scroll.follow_focus = true
page.add_child(scroll)
var choices := VBoxContainer.new()
choices.size_flags_horizontal = Control.SIZE_EXPAND_FILL
choices.add_theme_constant_override("separation", 12)
scroll.add_child(choices)
for index in range(sources.size()):
var source: Dictionary = sources[index]
var selected := item.duplicate(true)
selected["SelectedMediaSourceId"] = str(source.get("Id", ""))
var label := str(source.get("Name", "")) if source.get("Name") else "Version %d" % (index + 1)
var button := Widgets.button(label, _start.bind(selected), choices)
button.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
button.alignment = HORIZONTAL_ALIGNMENT_LEFT
button.disabled = selected.SelectedMediaSourceId.is_empty()
if index == 0: button.grab_focus.call_deferred()
status.text = "Select the version to play."
func _play_stream(url: String, source: Dictionary, start_ms: int) -> void:
var keep_menu_open := playing and panel.visible
decoder_ready = false
decoder_size = Vector2i.ZERO
if not seeking and not stereo_overridden: mode = Stereo.detect(source)
if not seeking and not projection_overridden:
projection_mode = VideoProjection.detect(source)
@@ -322,11 +491,11 @@ func _play_stream(url: String, source: Dictionary, start_ms: int) -> void:
for stream in source.get("MediaStreams", []):
if stream.get("Type", "") == "Video":
video_size = Vector2i(int(stream.get("Width", 1920)), int(stream.get("Height", 1080)))
transcode_offset = session.position_ticks if session.method == "Transcode" and not session.is_hls() else 0
if not seeking: _recenter()
transcode_offset = session.position_ticks if session.method != "DirectPlay" and not session.is_hls() else 0
if not seeking: _resize_screen()
screen.show()
playing = true
if not seeking: _show_playback()
if not seeking and not keep_menu_open: _show_playback()
_update_timeline()
await video.open(url, api.authorization(), start_ms)
@@ -334,9 +503,8 @@ func _video_prepared() -> void:
decoder_ready = true
if seeking and seek_paused: video.toggle_pause()
session.report("Playing", video.paused)
status.text = "Playing. Press A or B to show the controls."
if get_viewport().use_xr and not seeking:
panel.hide()
status.text = "Playing. Press A for controls or B for the library."
if get_viewport().use_xr and not seeking and not panel.visible:
player_overlay.hide()
seeking = false
_update_timeline()
@@ -344,24 +512,35 @@ func _video_prepared() -> void:
func _show_playback() -> void:
keyboard.hide()
panel.hide()
player_overlay.title.text = DetailsMetadata.title(session.item, "Now playing")
player_overlay.set_photo_mode(photo_active)
if photo_active: player_overlay.set_photo_navigation(photo_sequence.index > 0, photo_sequence.can_next())
player_overlay.title.text = DetailsMetadata.title(photo_item if photo_active else session.item, "Now playing")
player_overlay.show()
_update_timeline()
func _player_action(action: String) -> void:
if photo_active and action in ["pause", "back", "forward", "skip_segment"]: return
match action:
"previous_photo": _step_photo(-1)
"next_photo": _step_photo(1)
"pause":
video.toggle_pause()
session.report("Progress", video.paused)
_update_timeline()
"back": _skip(-10000)
"forward": _skip(30000)
"skip_segment": _skip_segment()
"stop": _stop()
"tracks": _show_tracks()
"settings": _screen_settings()
"library": _return_to_title()
"hide": player_overlay.hide()
func _return_to_title() -> void:
if is_instance_valid(browsing_return_page): _restore_browsing_page()
if photo_active:
_show_library()
return
var item := session.item
if item.is_empty():
_show_library()
@@ -382,11 +561,22 @@ func _return_to_title() -> void:
return
await _details({"Id": target_id}, season)
func _toggle_settings() -> void:
if api.token.is_empty() or not shell.signed_in: return
for child in page.get_children():
if child is SettingsView:
_toggle_panel()
return
_screen_settings()
func _screen_settings() -> void:
_clear_page()
var previous_heading := shell.heading.text if shell.heading.text != "Settings" else "Home"
if api.token.is_empty() or not shell.signed_in: return
_preserve_browsing_page()
panel.show()
shell.heading.text = "Settings"
shell.set_settings_mode(true)
var view := SettingsView.new()
view.navigation_host = shell.settings_navigation
view.settings = settings
view.passthrough_supported = world.passthrough.is_supported()
view.stereo_mode = mode
@@ -394,19 +584,38 @@ func _screen_settings() -> void:
view.swap_eyes = material.get_shader_parameter("swap_eyes") == true
view.size_flags_vertical = Control.SIZE_EXPAND_FILL
view.screen_changed.connect(func(): _resize_screen(); _position_screen())
view.screen_follow_changed.connect(_update_screen_follow)
view.environment_changed.connect(_change_environment)
view.stereo_selected.connect(_select_stereo)
view.projection_selected.connect(_select_projection)
view.eyes_swapped.connect(func(): material.set_shader_parameter("swap_eyes", not material.get_shader_parameter("swap_eyes")))
view.status.connect(func(message: String): status.text = message)
view.back_requested.connect(func():
shell.heading.text = previous_heading
if playing: _show_playback()
elif not api.token.is_empty(): _show_library()
else: _show_login())
page.add_child(view)
status.text = "Changes take effect as you make them."
func _preserve_browsing_page() -> void:
for child in page.get_children():
if child is LibraryView or child is DetailsView:
browsing_return_page = child
browsing_return_heading = shell.heading.text
browsing_return_status = status.text
child.hide()
child.reparent(viewport)
_clear_page(true)
func _restore_browsing_page() -> void:
if not is_instance_valid(browsing_return_page):
_show_library()
return
var previous := browsing_return_page
browsing_return_page = null
_clear_page()
previous.reparent(page)
previous.show()
shell.heading.text = browsing_return_heading
status.text = browsing_return_status
panel.show()
func _resize_screen() -> void:
var panoramic := projection_mode != VideoProjection.Mode.SCREEN
var aspect_ratio := 16.0 / 9.0 if panoramic else Stereo.aspect(video_size.x, video_size.y, mode)
@@ -420,6 +629,8 @@ func _resize_screen() -> void:
material.set_shader_parameter("top_bottom", mode == Stereo.Mode.TOP_BOTTOM)
player_overlay.fit_screen(dimensions.x, aspect_ratio, curvature)
world.player_loading.fit_screen(dimensions.x, curvature)
world.skip_screen_size = Vector3(dimensions.x, aspect_ratio, curvature)
subtitle_overlay.fit_screen(world.skip_screen_size, settings)
func _select_projection(value: int) -> void:
projection_mode = value
@@ -446,9 +657,8 @@ func _recenter() -> void:
func _recenter_video() -> void:
screen.global_basis = camera.global_basis
theatre.recenter(camera.global_transform)
if world.panoramic:
world.panorama.global_position = camera.global_position
world.panorama.global_rotation = Vector3(0.0, camera.global_rotation.y, 0.0)
world.panorama.global_position = camera.global_position
world.panorama.global_rotation = Vector3(0.0, camera.global_rotation.y, 0.0)
_position_screen()
_resize_screen()
@@ -460,12 +670,18 @@ func _position_screen() -> void:
else:
screen.global_position = camera.global_position - screen.global_basis.z * settings.distance + screen.global_basis.y * settings.height
func _update_screen_follow() -> void:
if not settings.follow_screen or settings.environment not in ["Void", "Passthrough"]: return
if projection_mode != VideoProjection.Mode.SCREEN: return
screen.global_basis = camera.global_basis
_position_screen()
func _toggle_menu() -> void:
if not get_viewport().use_xr: return
if autoplay_pending:
_show_library()
return
if playing:
if playing or photo_active:
if player_overlay.visible: player_overlay.hide()
else: _show_playback()
else: panel.visible = not panel.visible
@@ -474,7 +690,6 @@ func _skip(amount: int) -> void:
_seek_to(int(transcode_offset / 10000) + video.position_ms() + amount)
func _duration_ms() -> int:
if calibration: return video.duration_ms()
var ticks := int(session.source.get("RunTimeTicks", 0))
if ticks <= 0: ticks = int(session.item.get("RunTimeTicks", 0))
if ticks > 0: return int(ticks / 10000)
@@ -482,13 +697,26 @@ func _duration_ms() -> int:
return duration + int(transcode_offset / 10000) if duration > 0 else 0
func _update_timeline() -> void:
if autoplay_pending: return
if autoplay_pending or photo_active: return
player_overlay.update_time(int(transcode_offset / 10000) + video.position_ms(), _duration_ms(), decoder_ready and not seeking, video.paused)
var buffered := video.buffered_position_ms() if decoder_ready and not seeking else -1
player_overlay.timeline.update_buffer(buffered + int(transcode_offset / 10000) if buffered >= 0 else -1, _duration_ms(), decoder_ready and not seeking)
world.player_skip_popup.update_segment(_active_segment())
_update_loading()
func _active_segment() -> Dictionary:
if not playing or not decoder_ready or seeking: return {}
return media_segments.active(int(transcode_offset / 10000) + video.position_ms(), _duration_ms())
func _skip_segment() -> void:
var segment := _active_segment()
if not segment.is_empty(): _seek_to(int(segment.target_ms))
func _update_loading() -> void:
if autoplay_pending: return
if playing and (not decoder_ready or seeking):
if photo_active and photo_loading:
world.player_loading.show_photo_loading()
elif playing and (not decoder_ready or seeking):
world.player_loading.show_loading()
elif playing and video.is_buffering():
world.player_loading.show_buffering()
@@ -498,7 +726,7 @@ func _update_loading() -> void:
func _seek_to(milliseconds: int) -> void:
if not playing or seeking or not decoder_ready: return
var target := clampi(milliseconds, 0, maxi(0, _duration_ms() - 1000))
if calibration or session.method != "Transcode" or session.is_hls():
if session.method == "DirectPlay" or session.is_hls():
video.seek(target)
return
seeking = true
@@ -512,9 +740,16 @@ func _seek_to(milliseconds: int) -> void:
await session.report("Stopped")
if generation != countdown_generation: return
session.position_ticks = target * 10000
await session.open(session.item, true)
await session.open(session.item, session.method == "Transcode", true)
func _stop(show_library: bool = true, completed: bool = false) -> void:
session.cancel_pending()
subtitles.clear()
subtitles.delay_ms = 0
subtitle_overlay.text = ""
_close_photo()
media_segments.clear()
world.player_skip_popup.update_segment({})
countdown_generation += 1
_cancel_countdown()
world.player_loading.hide()
@@ -532,12 +767,6 @@ func _stop(show_library: bool = true, completed: bool = false) -> void:
func _ended() -> void:
if not playing: return
if calibration:
await _stop(false)
calibration = false
_show_login()
if get_viewport().use_xr: panel.show()
return
var previous := session.item.duplicate(true)
var generation := countdown_generation + 1
await _stop(false, true)
@@ -580,9 +809,6 @@ func _cancel_countdown() -> void:
func _video_failed(message: String) -> void:
status.text = message
if calibration:
_error(message)
return
if video.available() and playing:
decoder_ready = false
_update_timeline()
@@ -591,6 +817,10 @@ func _video_failed(message: String) -> void:
await session.retry_transcode()
func _error(message: String) -> void:
subtitles.clear()
subtitle_overlay.text = ""
media_segments.clear()
world.player_skip_popup.update_segment({})
countdown_generation += 1
_cancel_countdown()
world.player_loading.hide()
@@ -618,8 +848,10 @@ func _sign_out() -> void:
_show_login()
func _process(delta: float) -> void:
_update_screen_follow()
if not playing: return
_update_loading()
subtitle_overlay.text = subtitles.text_at(int(transcode_offset / 10000) + video.position_ms()) if decoder_ready and not seeking else ""
timeline_elapsed += delta
if timeline_elapsed >= 0.25:
timeline_elapsed = 0.0
@@ -634,18 +866,56 @@ func _process(delta: float) -> void:
func _unhandled_input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE: _toggle_menu()
func _show_tracks() -> void:
if not playing or photo_active: return
_preserve_browsing_page()
panel.show()
shell.heading.text = "Audio and subtitles"
var view := TracksView.new()
view.session = session
view.subtitles = subtitles
view.settings = settings
view.size_flags_vertical = Control.SIZE_EXPAND_FILL
view.closed.connect(_show_playback)
view.appearance_changed.connect(_resize_screen)
view.audio_selected.connect(_select_audio)
view.subtitle_selected.connect(_select_subtitle)
page.add_child(view)
status.text = "Audio changes briefly restart playback at the current position."
func _calibrate() -> void:
transcode_offset = 0
func _select_subtitle(index: int) -> void:
if not playing or seeking: return
var burn := false
for stream in session.source.get("MediaStreams", []):
if stream.get("Type", "") == "Subtitle" and int(stream.Index) == index:
burn = not SubtitleTrack.supported(stream)
if burn and session.active_playback_mode == "original": return
var restart := burn or session.burn_subtitles
session.subtitle_index = index
session.burn_subtitles = burn
if burn:
subtitles.clear()
subtitles.index = index
subtitles.message = "Picture subtitles are burned into the video. Text adjustments do not apply."
subtitles.changed.emit()
else:
subtitles.select(api, str(session.item.Id), session.media_source_id, index)
if restart: await _restart_tracks()
else: session.report("Progress", video.paused)
func _select_audio(index: int) -> void:
if not playing or seeking or session.active_playback_mode == "original": return
session.audio_index = index
await _restart_tracks()
func _restart_tracks() -> void:
seeking = true
seek_paused = video.paused
decoder_ready = false
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()
var current := countdown_generation
session.position_ticks = transcode_offset + video.position_ms() * 10000
video.stop()
await session.report("Stopped")
if current != countdown_generation: return
await session.open(session.item, false, true)
if current == countdown_generation and playing: _show_tracks()
+3 -3
View File
@@ -7,8 +7,8 @@ var stream_open := false
var frame_visible := false
func _ready() -> void:
if not Engine.has_singleton("JellyframeVideo"): return
bridge = Engine.get_singleton("JellyframeVideo")
if not Engine.has_singleton("JellyXRVideo"): return
bridge = Engine.get_singleton("JellyXRVideo")
bridge.connect("prepared", func(): prepared.emit())
bridge.connect("completed", func(): ended.emit())
bridge.connect("failed", func(message: String): failed.emit(message))
@@ -22,7 +22,7 @@ func has_android_services() -> bool:
return available()
func unavailable_message() -> String:
return "Video playback requires the Jellyframe Android plugin."
return "Video playback requires the jellyXR Android plugin."
func open(url: String, authorization: String, start_ms: int) -> void:
playback_generation += 1
+13 -3
View File
@@ -2,7 +2,7 @@ class_name PcVideo
extends VideoBackend
func available() -> bool:
return get_viewport().use_xr and ClassDB.class_exists("JellyframeMpv")
return get_viewport().use_xr and ClassDB.class_exists("JellyXRMpv")
func unavailable_message() -> String:
if not get_viewport().use_xr:
@@ -17,7 +17,7 @@ func open(url: String, authorization: String, start_ms: int) -> void:
failed.emit(unavailable_message())
return
if bridge == null:
bridge = ClassDB.instantiate("JellyframeMpv")
bridge = ClassDB.instantiate("JellyXRMpv")
add_child(bridge)
bridge.prepared.connect(func(): prepared.emit())
bridge.ended.connect(func(): ended.emit())
@@ -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:
+102
View File
@@ -0,0 +1,102 @@
class_name SubtitleTrack
extends Node
signal changed
var cues: Array[Dictionary] = []
var index := -1
var delay_ms := 0
var message := "Subtitles off"
var request: HTTPRequest
var generation := 0
static func supported(stream: Dictionary) -> bool:
return stream.get("IsTextSubtitleStream", false) or str(stream.get("Codec", "")).to_lower() in ["srt", "subrip", "vtt", "webvtt", "ass", "ssa", "mov_text", "ttml"]
func clear() -> void:
generation += 1
if is_instance_valid(request):
request.cancel_request()
request.queue_free()
request = null
cues.clear()
index = -1
message = "Subtitles off"
changed.emit()
func select(api: JellyfinClient, item_id: String, source_id: String, selected: int) -> void:
clear()
index = selected
if selected < 0: return
var current := generation
message = "Loading subtitles..."
changed.emit()
request = HTTPRequest.new()
request.timeout = 120.0
request.body_size_limit = 16 * 1024 * 1024
add_child(request)
var path := "/Videos/%s/%s/Subtitles/%d/Stream.vtt?CopyTimestamps=true&AddVttTimeMap=false" % [item_id.uri_encode(), source_id.uri_encode(), selected]
var error := request.request(api.server + path, PackedStringArray(["Authorization: " + api.authorization()]))
if error != OK:
message = "Could not request subtitles. Select the track to retry."
request.queue_free()
request = null
changed.emit()
return
var response: Array = await request.request_completed
if current != generation: return
request.queue_free()
request = null
if response[0] != HTTPRequest.RESULT_SUCCESS or int(response[1]) != 200:
message = "Could not load subtitles. Select the track to retry."
else:
cues = parse(response[3].get_string_from_utf8())
message = "Subtitles ready" if not cues.is_empty() else "No readable subtitle cues were returned."
changed.emit()
static func timestamp(value: String) -> int:
var parts := value.replace(",", ".").split(":")
if parts.size() not in [2, 3]: return -1
var seconds := 0.0
for part in parts:
if not part.is_valid_float(): return -1
seconds = seconds * 60.0 + float(part)
return roundi(seconds * 1000.0)
static func parse(document: String) -> Array[Dictionary]:
var result: Array[Dictionary] = []
var tags := RegEx.new()
tags.compile("<[^>]*>")
for block in document.replace("\r", "").split("\n\n", false):
var lines := block.strip_edges().split("\n")
if lines[0].begins_with("NOTE") or lines[0] in ["STYLE", "REGION"]: continue
for line_index in range(lines.size()):
if not " --> " in lines[line_index]: continue
var times := lines[line_index].split(" --> ")
var start := timestamp(times[0].strip_edges())
var end := timestamp(times[1].strip_edges().get_slice(" ", 0))
if start < 0 or end <= start: break
var text := tags.sub("\n".join(lines.slice(line_index + 1)), "", true)
text = text.replace("&lt;", "<").replace("&gt;", ">").replace("&nbsp;", " ").replace("&quot;", '"').replace("&amp;", "&")
result.append({"start": start, "end": end, "text": text})
break
result.sort_custom(func(a: Dictionary, b: Dictionary): return a.start < b.start)
var latest_end := 0
for cue in result:
latest_end = maxi(latest_end, cue.end)
cue["latest_end"] = latest_end
return result
func text_at(position_ms: int) -> String:
var time := position_ms - delay_ms
var low := 0
var high := cues.size()
while low < high:
var middle := (low + high) / 2
if int(cues[middle].start) <= time: low = middle + 1
else: high = middle
var active := PackedStringArray()
var cursor := low - 1
while cursor >= 0 and int(cues[cursor].latest_end) > time:
if int(cues[cursor].end) > time: active.insert(0, cues[cursor].text)
cursor -= 1
return "\n".join(active)
+1
View File
@@ -0,0 +1 @@
uid://nv45euhbfw57
+8
View File
@@ -34,6 +34,14 @@ func position_ms() -> int:
func duration_ms() -> int:
return 0
func buffered_position_ms() -> int:
if bridge == null: return -1
if bridge.has_method("buffered_position_ms"): return bridge.buffered_position_ms()
if bridge.has_method("playback_diagnostics"):
var values: Dictionary = bridge.playback_diagnostics()
if values.has("read_ahead_ms"): return position_ms() + int(values.read_ahead_ms)
return -1
func is_buffering() -> bool:
return bridge != null and bridge.has_method("is_buffering") and bridge.is_buffering()
+78 -5
View File
@@ -2,6 +2,8 @@ class_name DetailsView
extends VBoxContainer
signal play_requested(item: Dictionary)
signal play_from_start_requested(item: Dictionary)
signal version_requested(item: Dictionary)
signal back_requested
var item: Dictionary
@@ -12,12 +14,18 @@ var scroll: ScrollContainer
var body: VBoxContainer
var series_browser: SeriesBrowser
var play_button: Button
var play_from_start_button: Button
var version_button: Button
var watched_control: VBoxContainer
var resume_label: Label
var back_button: Button
var title_label: Label
var overview_label: Label
var poster: TextureRect
var backdrop: TextureRect
var cast_carousel: CastCarousel
var series_playback_status: Label
var series_playback_loading := false
func _ready() -> void:
add_theme_constant_override("separation", 18)
@@ -26,8 +34,10 @@ func _ready() -> void:
artwork.api = api
add_child(artwork)
var navigation := HBoxContainer.new()
navigation.add_theme_constant_override("separation", 12)
add_child(navigation)
back_button = Widgets.button("Back to library", func(): back_requested.emit(), navigation)
back_button.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
scroll = ScrollContainer.new()
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
@@ -44,8 +54,11 @@ func _ready() -> void:
series_browser.api = api
series_browser.artwork = artwork
series_browser.series_id = str(item.Id)
series_browser.series_item = item
series_browser.navigation_host = navigation
series_browser.selected_season = initial_season
series_browser.play_requested.connect(func(episode: Dictionary): play_requested.emit(episode))
series_browser.version_requested.connect(func(episode: Dictionary): version_requested.emit(episode))
series_browser.section_changed.connect(_scroll_to_series)
body.add_child(series_browser)
_build_people()
@@ -107,13 +120,14 @@ func _build_header() -> void:
_build_playback(summary)
func _build_playback(parent: VBoxContainer) -> void:
if item.get("Type", "") == "Series":
return
var is_series: bool = item.get("Type", "") == "Series"
var user: Dictionary = item.get("UserData", {}) if item.get("UserData") else {}
var position := int(user.get("PlaybackPositionTicks", 0))
var actions := HBoxContainer.new()
var actions := HFlowContainer.new()
actions.add_theme_constant_override("h_separation", 12)
actions.add_theme_constant_override("v_separation", 12)
parent.add_child(actions)
play_button = Widgets.button("Resume" if position > 0 else "Play movie", func(): play_requested.emit(item), actions)
play_button = Widgets.button("Play series" if is_series else ("Resume" if position > 0 else "Play movie"), _play_item, actions)
play_button.custom_minimum_size = Vector2(200, 64)
play_button.icon = preload("res://assets/icons/material/play_arrow.svg")
play_button.add_theme_constant_override("icon_max_width", 28)
@@ -125,8 +139,67 @@ func _build_playback(parent: VBoxContainer) -> void:
focus.set_border_width_all(3)
focus.border_color = Color("071c20")
play_button.add_theme_stylebox_override("focus", focus)
if is_series:
play_button.tooltip_text = "Start the first episode or continue where you left off."
series_playback_status = _muted("", parent, 20)
series_playback_status.hide()
return
if position > 0:
_muted("Continue from " + DetailsMetadata.duration(position), parent, 20)
play_from_start_button = Widgets.button("Play from start", func(): play_from_start_requested.emit(item), actions)
play_from_start_button.custom_minimum_size = Vector2(200, 64)
resume_label = _muted("Continue from " + DetailsMetadata.duration(position), parent, 20)
var secondary_actions := HFlowContainer.new()
secondary_actions.add_theme_constant_override("h_separation", 12)
secondary_actions.add_theme_constant_override("v_separation", 12)
parent.add_child(secondary_actions)
if item.get("Type", "") in ["Movie", "Episode"]:
watched_control = preload("res://scripts/ui/watched_control.gd").new()
watched_control.api = api
watched_control.item = item
watched_control.changed.connect(_refresh_playback_state)
secondary_actions.add_child(watched_control)
watched_control.button.custom_minimum_size.y = 64
var sources: Array = item.get("MediaSources", []) if item.get("MediaSources") else []
if sources.size() > 1:
version_button = Widgets.button("Choose version", func(): version_requested.emit(item), secondary_actions)
version_button.custom_minimum_size = Vector2(200, 64)
version_button.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
func _play_item() -> void:
if item.get("Type", "") != "Series":
play_requested.emit(item)
return
if series_playback_loading: return
series_playback_loading = true
play_button.disabled = true
play_button.text = "Loading episode..."
series_playback_status.hide()
var result := await api.series_playback(str(item.Id))
if not is_inside_tree(): return
series_playback_loading = false
play_button.disabled = false
play_button.text = "Play series"
if not result.ok:
series_playback_status.text = "Could not load an episode. Try again."
series_playback_status.show()
return
var episodes: Array = result.data.get("Items", [])
if episodes.is_empty():
series_playback_status.text = "No episodes available for this series."
series_playback_status.show()
return
var episode: Dictionary = episodes[0].duplicate(true)
episode["Type"] = "Episode"
episode["SeriesId"] = str(item.Id)
play_requested.emit(episode)
func _refresh_playback_state() -> void:
var position := int(item.UserData.get("PlaybackPositionTicks", 0))
play_button.text = "Resume" if position > 0 else ("Play episode" if item.get("Type") == "Episode" else "Play movie")
if is_instance_valid(play_from_start_button): play_from_start_button.visible = position > 0
if is_instance_valid(resume_label):
resume_label.visible = position > 0
resume_label.text = "Continue from " + DetailsMetadata.duration(position)
func _build_information() -> void:
var section := VBoxContainer.new()
-2
View File
@@ -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:
+31 -2
View File
@@ -2,10 +2,16 @@ class_name EpisodeCard
extends PanelContainer
signal chosen(item: Dictionary)
signal version_requested(item: Dictionary)
var item: Dictionary
var artwork: ArtworkLoader
var api: JellyfinClient
var watched_control: VBoxContainer
var watched_badge: Control
var facts_label: Label
var play_button: Button
var version_button: Button
var thumbnail: TextureRect
var title_label: Label
@@ -42,14 +48,37 @@ func _ready() -> void:
title_label = _label(DetailsMetadata.title(item, "Episode"), text, 26)
var facts := DetailsMetadata.facts(item)
var user: Dictionary = item.get("UserData", {}) if item.get("UserData") else {}
watched_badge = Widgets.watched_badge(frame)
watched_badge.visible = user.get("Played", false)
var position := int(user.get("PlaybackPositionTicks", 0))
if position > 0: facts.append("Continue from " + DetailsMetadata.duration(position))
if not facts.is_empty():
_label(" / ".join(facts), text, 20).add_theme_color_override("font_color", Color("b0bbce"))
facts_label = _label(" / ".join(facts), text, 20)
facts_label.add_theme_color_override("font_color", Color("b0bbce"))
_label(str(item.get("Overview") if item.get("Overview") else "No description available."), text, 22)
play_button = Widgets.button("Resume episode" if position > 0 else "Play episode", func(): chosen.emit(item), image_column)
play_button.custom_minimum_size = Vector2(190, 56)
play_button.accessibility_name = play_button.text + " " + title_label.text
if user.get("Played", false):
play_button.accessibility_name += ", watched"
var sources: Array = item.get("MediaSources", []) if item.get("MediaSources") else []
if sources.size() > 1:
version_button = Widgets.button("Choose version", func(): version_requested.emit(item), image_column)
version_button.accessibility_name = "Choose version of " + title_label.text
watched_control = preload("res://scripts/ui/watched_control.gd").new()
watched_control.api = api
watched_control.item = item
watched_control.changed.connect(_refresh_user_data)
text.add_child(watched_control)
func _refresh_user_data() -> void:
var user: Dictionary = item.UserData
var position := int(user.get("PlaybackPositionTicks", 0))
var facts := DetailsMetadata.facts(item)
if position > 0: facts.append("Continue from " + DetailsMetadata.duration(position))
facts_label.text = " / ".join(facts)
watched_badge.visible = user.get("Played", false)
play_button.text = "Resume episode" if position > 0 else "Play episode"
play_button.accessibility_name = play_button.text + " " + title_label.text + (", watched" if user.get("Played", false) else "")
func _label(value: String, parent: Node, font_size: int) -> Label:
var label := Widgets.label(value, parent, font_size)
+3 -3
View File
@@ -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)
+4 -17
View File
@@ -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
+48
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
uid://wo7uui2jccph
+28
View File
@@ -0,0 +1,28 @@
extends RefCounted
static func snapshot(session: PlaybackSession, video: VideoBackend, playback_size := Vector2i.ZERO) -> Dictionary:
var stream: Dictionary = {}
for candidate in session.source.get("MediaStreams", []):
if candidate.get("Type") == "Video":
stream = candidate
break
var diagnostics: Dictionary = {}
if video.bridge != null and video.bridge.has_method("playback_diagnostics"):
diagnostics = video.bridge.playback_diagnostics()
var width := int(stream.get("Width", 0))
var height := int(stream.get("Height", 0))
var fps := float(stream.get("AverageFrameRate", stream.get("RealFrameRate", 0.0)))
var bitrate := int(session.source.get("Bitrate", 0))
return {
"Playback resolution": "%d x %d" % [playback_size.x, playback_size.y] if playback_size.x > 0 and playback_size.y > 0 else "Not reported",
"Playback": session.method,
"Source video": "%d x %d / %.3f fps" % [width, height, fps] if width > 0 else "Not reported",
"Source codec": str(stream.get("Codec", "Not reported")).to_upper(),
"Source bitrate": "%.2f Mbps" % (bitrate / 1000000.0) if bitrate > 0 else "Not reported",
"Renderer / app FPS": "%s / %d" % [RenderingServer.get_current_rendering_method(), Engine.get_frames_per_second()],
"Player state": "Buffering" if video.is_buffering() else ("Paused" if video.paused else "Playing"),
"Video decoder": str(diagnostics.get("codec", "Platform decoder")),
"Hardware decoding": str(diagnostics.get("hardware_device", "Not reported")),
"Dropped frames": str(diagnostics.get("dropped_frames", "Not reported")),
"Read ahead": "%.2f s" % (float(diagnostics.read_ahead_ms) / 1000.0) if diagnostics.has("read_ahead_ms") else "Not reported",
}
+1
View File
@@ -0,0 +1 @@
uid://d146kg25o802p
+20 -2
View File
@@ -6,6 +6,7 @@ var slider: HSlider
var elapsed_label: Label
var duration_label: Label
var dragging := false
var buffered_bar: ProgressBar
func _ready() -> void:
var times := HBoxContainer.new()
@@ -13,13 +14,26 @@ func _ready() -> void:
elapsed_label = Widgets.label("0:00", times, 20)
elapsed_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
duration_label = Widgets.label("--:--", times, 20)
var track_area := Control.new()
track_area.custom_minimum_size.y = 56
add_child(track_area)
buffered_bar = ProgressBar.new()
buffered_bar.show_percentage = false
buffered_bar.mouse_filter = Control.MOUSE_FILTER_IGNORE
buffered_bar.set_anchors_and_offsets_preset(Control.PRESET_TOP_WIDE)
buffered_bar.offset_top = 23
buffered_bar.offset_bottom = 33
buffered_bar.add_theme_stylebox_override("background", Widgets.surface(Color("445268"), 5))
buffered_bar.add_theme_stylebox_override("fill", Widgets.surface(Color("8998af"), 5))
track_area.add_child(buffered_bar)
slider = HSlider.new()
slider.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
slider.custom_minimum_size.y = 56
slider.step = 1000
slider.scrollable = false
slider.editable = false
slider.tooltip_text = "Seek to a time"
var track := Widgets.surface(Color("445268"), 5)
var track := Widgets.surface(Color.TRANSPARENT, 5)
track.content_margin_top = 5
track.content_margin_bottom = 5
slider.add_theme_stylebox_override("slider", track)
@@ -27,7 +41,7 @@ func _ready() -> void:
fill.bg_color = Color("6de3cf")
slider.add_theme_stylebox_override("grabber_area", fill)
slider.add_theme_stylebox_override("grabber_area_highlight", fill)
add_child(slider)
track_area.add_child(slider)
slider.drag_started.connect(func(): dragging = true)
slider.value_changed.connect(func(value: float):
elapsed_label.text = format_time(int(value))
@@ -48,6 +62,10 @@ func update_time(position_ms: int, duration_ms: int, ready: bool = true) -> void
elapsed_label.text = format_time(maxi(0, position_ms))
duration_label.text = format_time(duration_ms) if duration_ms > 0 else "--:--"
func update_buffer(buffered_ms: int, duration_ms: int, ready: bool) -> void:
buffered_bar.max_value = maxi(1, duration_ms)
buffered_bar.value = clampi(buffered_ms, 0, duration_ms) if ready and buffered_ms >= 0 and duration_ms > 0 else 0
static func format_time(milliseconds: int) -> String:
var seconds := maxi(0, milliseconds / 1000)
if seconds >= 3600: return "%d:%02d:%02d" % [seconds / 3600, (seconds / 60) % 60, seconds % 60]
+4 -1
View File
@@ -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...")
+120 -16
View File
@@ -12,6 +12,19 @@ var play_icon: Texture2D
var pause_icon: Texture2D
var tooltip: Label
var hovered_control: Control
var video_controls: Array[Button] = []
var stop_button: Button
var controls_spacer: Control
var controls_row: HBoxContainer
var photo_mode := false
var previous_photo: Button
var next_photo: Button
var statistics_button: Button
var statistics_panel: VBoxContainer
var statistics_values: Dictionary = {}
var statistics_provider: Callable
var statistics_elapsed := 0.0
var screen_dimensions := Vector3(3.6, 16.0 / 9.0, 0.0)
func _ready() -> void:
play_icon = load("res://assets/icons/material/play_arrow.svg")
@@ -25,6 +38,7 @@ func _ready() -> void:
material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
material.albedo_texture = viewport.get_texture()
VrOverlayMaterial.apply_depth(material)
material_override = material
var panel := PanelContainer.new()
panel.theme = Widgets.theme()
@@ -45,27 +59,110 @@ func _ready() -> void:
timeline.slider.mouse_exited.connect(_hide_tooltip.bind(timeline.slider))
timeline.slider.focus_entered.connect(_show_tooltip.bind(timeline.slider))
timeline.slider.focus_exited.connect(_hide_tooltip.bind(timeline.slider))
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 12)
body.add_child(row)
for entry in [["Pause", "pause", "pause"], ["Stop", "stop", "stop"], ["Back 10 seconds", "back", "replay_10"], ["Forward 30 seconds", "forward", "forward_30"], ["Settings", "settings", "settings"], ["Library", "library", "video_library"], ["Hide controls", "hide", "visibility_off"]]:
var button := Widgets.button("", func(): action.emit(entry[1]), row)
button.icon = load("res://assets/icons/material/%s.svg" % entry[2])
button.accessibility_name = entry[0]
button.custom_minimum_size = Vector2(140, 72)
button.icon_alignment = HORIZONTAL_ALIGNMENT_CENTER
button.add_theme_constant_override("icon_max_width", 44)
button.mouse_entered.connect(_show_tooltip.bind(button))
button.mouse_exited.connect(_hide_tooltip.bind(button))
button.focus_entered.connect(_show_tooltip.bind(button))
button.focus_exited.connect(_hide_tooltip.bind(button))
if entry[1] == "pause": pause_button = button
_build_controls(body)
tooltip = Widgets.label("", body, 20)
tooltip.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
tooltip.mouse_filter = Control.MOUSE_FILTER_IGNORE
tooltip.custom_minimum_size.y = 28
_build_statistics(body)
visibility_changed.connect(func():
if not is_visible_in_tree(): set_statistics_visible(false))
hide()
func _build_controls(parent: Control) -> void:
var row := HBoxContainer.new()
controls_row = row
row.add_theme_constant_override("separation", 12)
parent.add_child(row)
pause_button = _add_control("Pause", "pause", "pause", row)
stop_button = _add_control("Stop", "stop", "stop", row)
_add_control("Back 10 seconds", "back", "replay_10", row)
_add_control("Forward 30 seconds", "forward", "forward_30", row)
controls_spacer = row.add_spacer(false)
_add_control("Audio and subtitles", "tracks", "subtitles", row)
_add_control("Settings", "settings", "settings", row)
_add_control("Hide controls", "hide", "visibility_off", row)
previous_photo = _add_control("Previous photo", "previous_photo", "chevron_left", row)
next_photo = _add_control("Next photo", "next_photo", "chevron_right", row)
previous_photo.hide()
next_photo.hide()
statistics_button = _add_control("Statistics for nerds", "statistics", "statistics", row)
statistics_button.toggle_mode = true
statistics_button.pressed.connect(func(): set_statistics_visible(not statistics_panel.visible))
video_controls.append(statistics_button)
func _build_statistics(parent: Control) -> void:
statistics_panel = VBoxContainer.new()
statistics_panel.add_theme_constant_override("separation", 8)
parent.add_child(statistics_panel)
var heading := HBoxContainer.new()
statistics_panel.add_child(heading)
Widgets.label("Statistics for nerds", heading, 26).size_flags_horizontal = Control.SIZE_EXPAND_FILL
var grid := GridContainer.new()
grid.columns = 4
grid.add_theme_constant_override("h_separation", 24)
grid.add_theme_constant_override("v_separation", 12)
statistics_panel.add_child(grid)
for key in ["Source video", "Playback resolution", "Playback", "Source codec", "Source bitrate", "Renderer / app FPS", "Player state", "Video decoder", "Hardware decoding", "Dropped frames", "Read ahead"]:
Widgets.label(key, grid, 20)
var value := Widgets.label("Not reported", grid, 20)
value.size_flags_horizontal = Control.SIZE_EXPAND_FILL
value.custom_minimum_size.x = 210
value.clip_text = true
statistics_values[key] = value
statistics_panel.hide()
func set_statistics_visible(enabled: bool) -> void:
enabled = enabled and not photo_mode
statistics_panel.visible = enabled
statistics_button.set_pressed_no_signal(enabled)
viewport.size = Vector2i(820, 210) if photo_mode else Vector2i(1400, 620 if enabled else 300)
fit_screen(screen_dimensions.x, screen_dimensions.y, screen_dimensions.z)
statistics_elapsed = 0.0
if enabled: _refresh_statistics()
func _refresh_statistics() -> void:
if not statistics_provider.is_valid(): return
var values: Dictionary = statistics_provider.call()
for key in statistics_values:
statistics_values[key].text = str(values.get(key, "Not reported"))
func _add_control(label: String, action_name: String, icon_name: String, parent: Control) -> Button:
var button := Widgets.button("", func(): action.emit(action_name), parent)
if action_name in ["pause", "back", "forward", "tracks"]: video_controls.append(button)
button.icon = load("res://assets/icons/material/%s.svg" % icon_name)
button.accessibility_name = label
button.custom_minimum_size = Vector2(140, 72)
button.icon_alignment = HORIZONTAL_ALIGNMENT_CENTER
button.add_theme_constant_override("icon_max_width", 44)
button.mouse_entered.connect(_show_tooltip.bind(button))
button.mouse_exited.connect(_hide_tooltip.bind(button))
button.focus_entered.connect(_show_tooltip.bind(button))
button.focus_exited.connect(_hide_tooltip.bind(button))
return button
func set_photo_mode(enabled: bool) -> void:
photo_mode = enabled
set_statistics_visible(false)
previous_photo.visible = enabled
next_photo.visible = enabled
controls_row.move_child(previous_photo, 0 if enabled else controls_row.get_child_count() - 1)
controls_row.move_child(next_photo, 1 if enabled else controls_row.get_child_count() - 1)
timeline.visible = not enabled
for button in video_controls: button.visible = not enabled
controls_spacer.visible = not enabled
controls_row.alignment = BoxContainer.ALIGNMENT_CENTER if enabled else BoxContainer.ALIGNMENT_BEGIN
stop_button.icon = load("res://assets/icons/material/logout.svg" if enabled else "res://assets/icons/material/stop.svg")
stop_button.accessibility_name = "Exit" if enabled else "Stop"
viewport.size = Vector2i(820, 210) if enabled else Vector2i(1400, 300)
fit_screen(screen_dimensions.x, screen_dimensions.y, screen_dimensions.z)
hovered_control = null
tooltip.text = ""
func set_photo_navigation(has_previous: bool, has_next: bool) -> void:
previous_photo.disabled = not has_previous
next_photo.disabled = not has_next
func _show_tooltip(control: Control) -> void:
hovered_control = control
tooltip.text = control.accessibility_name
@@ -76,6 +173,11 @@ func _hide_tooltip(control: Control) -> void:
tooltip.text = ""
func _process(_delta: float) -> void:
if is_visible_in_tree() and statistics_panel.visible:
statistics_elapsed += _delta
if statistics_elapsed >= 1.0:
statistics_elapsed = 0.0
_refresh_statistics()
if not is_visible_in_tree():
hovered_control = null
tooltip.text = ""
@@ -83,7 +185,9 @@ func _process(_delta: float) -> void:
tooltip.text = hovered_control.accessibility_name
func fit_screen(width: float, aspect: float, curvature: float) -> void:
panel_size = Vector2(width * 0.94, width * 0.94 * 300.0 / 1400.0)
screen_dimensions = Vector3(width, aspect, curvature)
var panel_width := width * 0.94 * float(viewport.size.x) / 1400.0
panel_size = Vector2(panel_width, panel_width * float(viewport.size.y) / float(viewport.size.x))
var quad := QuadMesh.new()
quad.size = panel_size
mesh = quad
+59
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://xa6vnyg643ki
+42
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://rm4cllsb0evx
+107 -3
View File
@@ -2,11 +2,13 @@ class_name SeriesBrowser
extends VBoxContainer
signal play_requested(item: Dictionary)
signal version_requested(item: Dictionary)
signal section_changed
var api: JellyfinClient
var artwork: ArtworkLoader
var series_id := ""
var series_item: Dictionary = {}
var season_grid: HFlowContainer
var season_section: VBoxContainer
var episode_section: VBoxContainer
@@ -16,9 +18,22 @@ var season_description: Label
var all_seasons_button: Button
var generation := 0
var selected_season: Dictionary = {}
var navigation_host: HBoxContainer
var navigation: HBoxContainer
var watched_host: VBoxContainer
var watched_control: VBoxContainer
var next_up_section: VBoxContainer
var next_up_list: VBoxContainer
func _ready() -> void:
add_theme_constant_override("separation", 20)
next_up_section = VBoxContainer.new()
next_up_section.add_theme_constant_override("separation", 18)
add_child(next_up_section)
Widgets.label("Next Up", next_up_section, 28)
next_up_list = VBoxContainer.new()
next_up_section.add_child(next_up_list)
next_up_section.hide()
season_section = VBoxContainer.new()
season_section.add_theme_constant_override("separation", 18)
add_child(season_section)
@@ -30,9 +45,18 @@ func _ready() -> void:
episode_section = VBoxContainer.new()
episode_section.add_theme_constant_override("separation", 18)
add_child(episode_section)
var navigation := HBoxContainer.new()
episode_section.add_child(navigation)
navigation = HBoxContainer.new()
navigation.size_flags_horizontal = Control.SIZE_EXPAND_FILL
navigation.add_theme_constant_override("separation", 12)
(navigation_host if navigation_host != null else self).add_child(navigation)
all_seasons_button = Widgets.button("All seasons", _show_seasons, navigation)
all_seasons_button.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
var spacer := Control.new()
spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
navigation.add_child(spacer)
watched_host = VBoxContainer.new()
navigation.add_child(watched_host)
navigation.hide()
episode_heading = _label("", episode_section, 28)
season_description = _label("", episode_section, 22)
episode_list = VBoxContainer.new()
@@ -45,6 +69,10 @@ func _ready() -> void:
func load_seasons() -> void:
generation += 1
var current := generation
_load_next_up(current)
if series_item.is_empty(): series_item = {"Id": series_id, "Type": "Series"}
all_seasons_button.hide()
_set_watched_control(series_item, _series_watched_changed.bind(current), "Applies to every episode in every season of this series.")
_clear(season_grid)
_label("Loading seasons...", season_grid, 22)
var result := await api.seasons(series_id)
@@ -55,6 +83,15 @@ func load_seasons() -> void:
Widgets.button("Retry seasons", load_seasons, season_grid)
return
var seasons: Array = result.data.get("Items", [])
var all_watched := not seasons.is_empty()
for season in seasons:
var user: Dictionary = season.get("UserData", {}) if season.get("UserData") else {}
all_watched = all_watched and bool(user.get("Played", false))
var series_user: Dictionary = series_item.get("UserData", {}) if series_item.get("UserData") else {}
series_user["Played"] = all_watched
series_item["UserData"] = series_user
watched_control._refresh()
watched_control.button.disabled = seasons.is_empty()
if seasons.is_empty():
_label("No seasons available.", season_grid, 22)
for season in seasons:
@@ -64,12 +101,39 @@ func load_seasons() -> void:
card.chosen.connect(open_season)
season_grid.add_child(card)
func _load_next_up(current: int) -> void:
next_up_section.hide()
_clear(next_up_list)
var result := await api.next_up(series_id)
if not _current(current): return
if not result.ok:
next_up_section.show()
_label("Could not load the next episode.", next_up_list, 22)
Widgets.button("Retry Next Up", _load_next_up.bind(current), next_up_list)
return
var items: Array = result.data.get("Items", [])
if items.is_empty(): return
var card := EpisodeCard.new()
card.item = items[0].duplicate(true)
card.item["Type"] = "Episode"
card.item["SeriesId"] = series_id
card.artwork = artwork
card.api = api
card.chosen.connect(func(chosen: Dictionary): play_requested.emit(chosen))
card.version_requested.connect(func(chosen: Dictionary): version_requested.emit(chosen))
next_up_list.add_child(card)
card.watched_control.changed.connect(load_seasons)
next_up_section.show()
func open_season(season: Dictionary) -> void:
selected_season = season.duplicate(true)
generation += 1
var current := generation
next_up_section.hide()
season_section.hide()
episode_section.show()
all_seasons_button.show()
_set_watched_control(selected_season, _season_watched_changed.bind(current), "Applies to every episode in this season.")
all_seasons_button.grab_focus()
episode_heading.text = str(season.get("Name", "Season")) + " episodes"
season_description.text = str(season.get("Overview") if season.get("Overview") else "")
@@ -101,17 +165,57 @@ func open_season(season: Dictionary) -> void:
if card.item.get("ParentIndexNumber") == null:
card.item["ParentIndexNumber"] = season.get("IndexNumber")
card.artwork = artwork
card.api = api
card.chosen.connect(func(chosen: Dictionary): play_requested.emit(chosen))
card.version_requested.connect(func(chosen: Dictionary): version_requested.emit(chosen))
episode_list.add_child(card)
card.watched_control.changed.connect(_refresh_season_watched)
_refresh_season_watched()
section_changed.emit()
func _refresh_season_watched() -> void:
if watched_control.busy: return
var all_watched := true
var count := 0
for card in episode_list.get_children():
if not card is EpisodeCard: continue
count += 1
var user: Dictionary = card.item.get("UserData", {}) if card.item.get("UserData") else {}
all_watched = all_watched and bool(user.get("Played", false))
selected_season["UserData"] = {"Played": all_watched and count > 0}
watched_control._refresh()
watched_control.button.disabled = count == 0
func _set_watched_control(item: Dictionary, action: Callable, tooltip: String) -> void:
navigation.show()
_clear(watched_host)
watched_control = preload("res://scripts/ui/watched_control.gd").new()
watched_control.api = api
watched_control.item = item
watched_control.changed.connect(action)
watched_host.add_child(watched_control)
watched_control.button.disabled = true
watched_control.button.tooltip_text = tooltip
func _series_watched_changed(current: int) -> void:
if not _current(current): return
await load_seasons()
if _current(current + 1): watched_control.button.grab_focus()
func _season_watched_changed(current: int) -> void:
if not _current(current): return
await open_season(selected_season)
if _current(current + 1) and not selected_season.is_empty():
watched_control.button.grab_focus()
func _show_seasons() -> void:
selected_season = {}
generation += 1
episode_section.hide()
navigation.hide()
season_section.show()
section_changed.emit()
if season_grid.get_child_count() == 0: load_seasons()
load_seasons()
if season_grid.get_child_count() > 0:
var first := season_grid.get_child(0) as Control
if first and first.focus_mode != Control.FOCUS_NONE: first.grab_focus()
+145 -76
View File
@@ -1,15 +1,15 @@
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)
signal eyes_swapped
signal status(message: String)
var settings: JellyframeSettings
var settings: JellyXRSettings
var stereo_mode := Stereo.Mode.MONO
var projection_mode := VideoProjection.Mode.SCREEN
var swap_eyes := false
@@ -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)
sort_choices = GridContainer.new()
sort_choices.columns = 2
sort_choices.add_theme_constant_override("h_separation", 18)
sort_choices.add_theme_constant_override("v_separation", 8)
parent.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)
_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, order,
_choice(value, value == settings.library_sort_order, order_group, sort_order_choices,
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)
_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.add_theme_constant_override("h_separation", 8)
sort_choices.add_theme_constant_override("v_separation", 8)
sort_card.add_child(sort_choices)
var group := ButtonGroup.new()
for option in JellyXRSettings.LIBRARY_SORT_OPTIONS:
_choice(option[0], option[1] == settings.library_sort_by, group, sort_choices,
func(): settings.library_sort_by = option[1]; _save())
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)
@@ -133,8 +184,8 @@ func _build_transcode_options(parent: VBoxContainer) -> void:
transcode_choices.add_theme_constant_override("v_separation", 12)
transcode_options.add_child(transcode_choices)
var group := ButtonGroup.new()
for resolution in JellyframeSettings.TRANSCODE_PRESETS:
var preset: Dictionary = JellyframeSettings.TRANSCODE_PRESETS[resolution]
for resolution in JellyXRSettings.TRANSCODE_PRESETS:
var preset: Dictionary = JellyXRSettings.TRANSCODE_PRESETS[resolution]
_choice(preset.label, settings.transcode_resolution == resolution, group, transcode_choices,
func(): settings.transcode_resolution = resolution; _save())
_hint("Choose a maximum resolution. Smaller videos keep their size. Higher resolutions need a faster connection and more server processing power.", transcode_options)
@@ -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,26 +235,29 @@ 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)
_hint("Jellyframe detects the 3D format. Choose a different format if the picture looks wrong.", parent)
parent = _card(parent, "Picture format")
_hint("jellyXR 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)
parent.add_child(stereo_choices)
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
+42 -6
View File
@@ -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
@@ -34,24 +39,32 @@ func _ready() -> void:
navigation.add_theme_constant_override("separation", 12)
rail.add_child(navigation)
var logo := TextureRect.new()
logo.texture = preload("res://assets/logo/jellyframe-wordmark.png")
logo.texture = preload("res://assets/logo/jellyxr-wordmark.png")
logo.custom_minimum_size = Vector2(180, 72)
logo.accessibility_name = "Jellyframe"
logo.accessibility_name = "jellyXR"
logo.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
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)
+34
View File
@@ -0,0 +1,34 @@
class_name SubtitleOverlay
extends Label3D
var controls: PlayerOverlay
var preferred_y := 0.0
var controls_gap := 0.0
func _ready() -> void:
no_depth_test = true
outline_size = 10
outline_modulate = Color.BLACK
modulate = Color.WHITE
shaded = false
autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
width = 1250
render_priority = 5
func fit_screen(dimensions: Vector3, settings: JellyXRSettings) -> void:
font_size = settings.subtitle_size
modulate = {"White": Color.WHITE, "Yellow": Color.YELLOW, "Green": Color.GREEN}.get(settings.subtitle_color, Color.WHITE)
pixel_size = dimensions.x * 0.85 / 1250.0
preferred_y = -dimensions.x / dimensions.y * (0.5 - settings.subtitle_height)
controls_gap = dimensions.x * 0.025
position.z = (1.0 - cos(dimensions.z / 2.0)) * dimensions.x / dimensions.z + 0.04 if dimensions.z > 0.001 else 0.04
_update_position()
func _process(_delta: float) -> void:
_update_position()
func _update_position() -> void:
position.y = preferred_y
if text.is_empty() or not is_instance_valid(controls) or not controls.is_visible_in_tree(): return
var controls_top := controls.position.y + controls.panel_size.y / 2.0
position.y = maxf(preferred_y, controls_top + controls_gap - get_aabb().position.y)
+1
View File
@@ -0,0 +1 @@
uid://dtigioofv3r0x
+105
View File
@@ -0,0 +1,105 @@
class_name TracksView
extends VBoxContainer
signal audio_selected(index: int)
signal subtitle_selected(index: int)
signal appearance_changed
signal closed
var session: PlaybackSession
var subtitles: SubtitleTrack
var settings: JellyXRSettings
var message: Label
var back_button: Button
func _ready() -> void:
back_button = Widgets.button("Back to video", func(): closed.emit(), self)
_focus_back.call_deferred()
message = Widgets.label(subtitles.message, self, 20)
if not subtitles.changed.is_connected(_subtitle_status): subtitles.changed.connect(_subtitle_status)
var 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
body.add_theme_constant_override("separation", 12)
scroll.add_child(body)
Widgets.label("Audio", body, 28)
if session.active_playback_mode == "original":
Widgets.label("Audio switching and picture subtitles require server conversion.", body, 20)
Widgets.button("Use Automatic for this video", func(): session.active_playback_mode = "auto"; _refresh(), body)
_tracks(body, "Audio")
Widgets.label("Subtitles", body, 28)
var off := Widgets.button("Off", func(): subtitle_selected.emit(-1); _refresh(), body)
off.toggle_mode = true
off.button_pressed = subtitles.index < 0
_tracks(body, "Subtitle")
Widgets.label("Text subtitle appearance", body, 28)
_adjustment(body, "Text size", func(): return str(settings.subtitle_size), func(step: int): settings.subtitle_size = clampi(settings.subtitle_size + step * 4, 28, 80))
_adjustment(body, "Vertical position", func(): return "%d%%" % roundi(settings.subtitle_height * 100), func(step: int): settings.subtitle_height = clampf(settings.subtitle_height + step * 0.05, 0.1, 0.8))
var colors := HBoxContainer.new()
body.add_child(colors)
for color in ["White", "Yellow", "Green"]:
var button := Widgets.button(color, func(): settings.subtitle_color = color; _save(); _refresh(), colors)
button.toggle_mode = true
button.button_pressed = settings.subtitle_color == color
_adjustment(body, "Delay", func(): return "%+.1f s" % (subtitles.delay_ms / 1000.0), func(step: int): subtitles.delay_ms = clampi(subtitles.delay_ms + step * 100, -10000, 10000))
Widgets.label("Positive delay shows subtitles later. Timing resets for each video.", body, 20)
Widgets.button("Reset timing", func(): subtitles.delay_ms = 0; _refresh(), body)
func _tracks(parent: Control, kind: String) -> void:
var count := 0
for stream in session.source.get("MediaStreams", []):
if stream.get("Type", "") != kind: continue
count += 1
var index := int(stream.Index)
var label := str(stream.get("DisplayTitle", stream.get("Language", "Track %d" % index)))
var supported := kind == "Audio" or SubtitleTrack.supported(stream)
if not supported: label += " (requires video conversion)"
var button := Widgets.button(label, func():
if kind == "Audio": audio_selected.emit(index)
else: subtitle_selected.emit(index); _refresh(), parent)
button.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
button.alignment = HORIZONTAL_ALIGNMENT_LEFT
button.toggle_mode = true
var selected := session.audio_index
if selected < 0 and session.source.get("DefaultAudioStreamIndex") != null:
selected = int(session.source.DefaultAudioStreamIndex)
button.button_pressed = index == (selected if kind == "Audio" else subtitles.index)
if button.button_pressed: button.text += " (selected)"
button.disabled = session.active_playback_mode == "original" and (kind == "Audio" or not supported)
if count == 0: Widgets.label("No %s tracks available." % kind.to_lower(), parent, 20)
func _adjustment(parent: Control, title: String, value: Callable, change: Callable) -> void:
var row := HBoxContainer.new()
parent.add_child(row)
var label := Widgets.label(title + ": " + value.call(), row, 22)
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
for step in [-1, 1]:
var button := Widgets.button("-" if step < 0 else "+", func():
change.call(step)
label.text = title + ": " + value.call()
_save(), row)
button.accessibility_name = ("Decrease " if step < 0 else "Increase ") + title.to_lower()
func _save() -> void:
settings.save()
appearance_changed.emit()
func _refresh() -> void:
var scroll_position := 0
for child in get_children():
if child is ScrollContainer: scroll_position = child.scroll_vertical
for child in get_children():
remove_child(child)
child.queue_free()
_ready()
for child in get_children():
if child is ScrollContainer: child.set_deferred("scroll_vertical", scroll_position)
func _subtitle_status() -> void:
message.text = subtitles.message
func _focus_back() -> void:
if is_inside_tree() and is_instance_valid(back_button): back_button.grab_focus()
+1
View File
@@ -0,0 +1 @@
uid://dfo47upix8tug
+47
View File
@@ -0,0 +1,47 @@
extends VBoxContainer
signal changed
var api: JellyfinClient
var item: Dictionary
var button: Button
var status: Label
var busy := false
func _ready() -> void:
button = Widgets.button("", _toggle, self)
button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
status = Widgets.label("", self, 18)
status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
status.custom_minimum_size.x = 200
status.hide()
_refresh()
func _refresh() -> void:
var user: Dictionary = item.get("UserData", {}) if item.get("UserData") else {}
button.text = "Mark as unwatched" if user.get("Played", false) else "Mark as watched"
button.accessibility_name = button.text + " " + DetailsMetadata.title(item)
func _toggle() -> void:
if busy: return
busy = true
button.disabled = true
status.text = "Saving..."
status.show()
var user: Dictionary = item.get("UserData", {}) if item.get("UserData") else {}
var result := await api.set_watched(str(item.Id), not user.get("Played", false))
if not is_inside_tree(): return
busy = false
button.disabled = false
if not result.ok:
status.text = result.error
return
if not result.data is Dictionary or not result.data.get("Played") is bool:
status.text = "Could not confirm watched status. Reopen the item to refresh it."
return
var updated := user.duplicate(true)
updated.merge(result.data, true)
item["UserData"] = updated
status.hide()
_refresh()
changed.emit()
Loaded 100 of 171 files, more files were not shown because too many files have changed in this diff. Show more